Merge branch 'main' into feature/parametric-constraint-system

Brings in 54 commits from origin/main: a selection-performance overhaul,
surface thickening, GPU-native wide/tapered stroke rendering, continued
ocs_doc_api evolution, and assorted fixes/polish (block double-click
editing, DWG hatch/database repair, clipboard, collapsible Properties
sections).

Six real conflicts, all where this branch had independently re-applied
a fix upstream later revised or already carried natively:

- src/app/automation.rs, src/app/command_driver.rs: combined both
  sides — kept this branch's sketch-constraint/named-parameter loading
  on "new"/"open", adopted main's Scene::clear()/material_base_dir and
  its newer rebuild_derived_caches() over the older bump_geometry().
  Scene::clear() (src/scene/entity.rs) now also resets
  sketch_constraints/named_parameters, so every caller gets this for
  free instead of each call site repeating it.
- src/io/mod.rs, src/scene/render_graph.rs: adopted main's design
  outright (commit 973af87d, authored by Hakan directly) — a large
  MINSERT is no longer purged as "corrupt" at load, only its rendering
  is bounded, preserving the source data. This branch's own earlier
  design (reject at load) predates that revision.
- src/scene/pipeline/mesh_gpu.rs: adopted main's valid_rgba_size()
  guard (a real robustness improvement over this branch's version).
- src/snap.rs: replaced wholesale with main's version — this branch's
  copy was an explicit verbatim snapshot from an earlier upstream PR
  (commit 4c264b8c) with zero constraint-system-specific content, and
  main has since evolved it considerably further.

Also removed two now-dead duplicates the merge surfaced without a
conflict marker (both branches had independently fixed the same
things): the OPTIONS/OP command dispatch/autocomplete entries, and
automation.rs's open_finalizes_and_purges_like_the_ui_open_path test
(kept main's version, which covers strictly more: solid_models/
material_base_dir reset on "new").

Full workspace suite passes (812 lib tests + this branch's own 24
constraint/persistence integration tests) except the one pre-existing,
unrelated font-test failure already present on main. Zero clippy
findings in the hand-written merge resolution itself; the ~1,171
warnings clippy reports are pre-existing in the 54 absorbed commits,
not introduced here.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Felix RiestraandClaude Sonnet 5 committed 2026-09-09 20:44:38 +02:00
commit d675244fb8
89 files changed
+5700 -1101

No files matched your search

+18 -20
View File
@@ -300,15 +300,12 @@ impl OpenCADStudio {
match req["op"].as_str().unwrap_or("") {
"new" => {
let i = self.active_tab;
self.tabs[i].scene.document = acadrust::CadDocument::new();
self.tabs[i].scene.sketch_constraints.clear();
self.tabs[i].scene.named_parameters = crate::scene::named_parameters::ParameterTable::new();
self.tabs[i].scene.deselect_all();
self.tabs[i].scene.clear();
self.tabs[i].scene.material_base_dir = None;
self.tabs[i].current_path = None;
// The headless session starts on the welcome (Start) tab, which
// blocks drawing commands; turn it into a real drawing.
self.tabs[i].is_start = false;
self.tabs[i].scene.bump_geometry();
self.entity_summary()
}
#[cfg(not(target_arch = "wasm32"))]
@@ -321,26 +318,21 @@ impl OpenCADStudio {
Ok(b) => b,
Err(e) => return err(format!("open: {e}")),
};
// Runs the same finalization (block-origin normalization,
// raster-path resolution, source_path) and corrupt-entity
// purge the UI open path always gets, using the real path —
// not just its basename — so an automation open behaves the
// same as opening the identical file from the UI instead of
// silently skipping that safety/normalization net.
match crate::io::load_bytes_finalized(&path_buf, bytes) {
Ok((doc, dropped)) => {
let i = self.active_tab;
self.tabs[i].scene.clear();
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.deselect_all();
self.tabs[i].scene.material_base_dir = path_buf.parent().map(PathBuf::from);
crate::app::style_ops::ensure_standard_styles(
&mut self.tabs[i].scene.document,
);
self.tabs[i].adopt_active_ucs_from_header();
self.tabs[i].current_path = Some(path_buf);
self.tabs[i].is_start = false;
self.tabs[i].scene.bump_geometry();
self.tabs[i].scene.rebuild_derived_caches();
let mut summary = self.entity_summary();
if dropped > 0 {
if let Some(obj) = summary.as_object_mut() {
@@ -1409,16 +1401,13 @@ mod tests {
let _ = std::fs::remove_file(&path);
}
/// Audit finding: automation's `"open"` used to call `io::load_bytes`
/// directly, skipping the finalization and corrupt-entity purge every UI
/// open runs — so the same file opened two ways behaved differently.
/// Proves both halves of the fix: `source_path` (only ever set by
/// finalization, never by `load_bytes` alone) is populated, and a
/// zero-radius circle — `io::is_entity_corrupt`'s own rejection case —
/// is purged and reported, exactly like a UI open of the same file.
#[test]
fn open_finalizes_and_purges_like_the_ui_open_path() {
let mut app = OpenCADStudio::new_for_test();
let stale = acadrust::Handle::from(9999);
app.tabs[app.active_tab].scene.solid_models.insert(
stale, cadkernel::brep::make::cuboid([0.0; 3], [1.0; 3]).unwrap(),
);
let path = std::env::temp_dir().join(format!(
"ocs_automation_finalize_test_{}.dxf",
std::process::id()
@@ -1445,11 +1434,20 @@ mod tests {
assert_eq!(result["purged"], 1, "the purge count must be reported, matching the UI open path's diagnostics");
let i = app.active_tab;
assert!(!app.tabs[i].scene.solid_models.contains_key(&stale));
assert_eq!(app.tabs[i].scene.material_base_dir.as_deref(), path.parent());
assert!(
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)"
);
app.tabs[i].scene.solid_models.insert(
stale, cadkernel::brep::make::cuboid([0.0; 3], [1.0; 3]).unwrap(),
);
assert_eq!(app.automation_op(r#"{"op":"new"}"#)["ok"], true);
assert!(app.tabs[i].scene.solid_models.is_empty());
assert!(app.tabs[i].scene.material_base_dir.is_none());
drop(app);
let _ = std::fs::remove_file(&path);
}
+220 -1
View File
@@ -353,7 +353,32 @@ impl OpenCADStudio {
self.reset_tracking_after_point();
self.push_ucs_to_cmd(i);
}
if let StepInput::EntityPick(handle, _) = &input {
if let StepInput::EntityPick(handle, point) = &input {
let solid_pick = matches!(
self.tabs[i].scene.document.get_entity(*handle),
Some(
acadrust::EntityType::Solid3D(_)
| acadrust::EntityType::Surface(_)
| acadrust::EntityType::Region(_)
| acadrust::EntityType::Body(_)
)
);
let direction = if solid_pick {
self.tabs[i]
.scene
.solid_planar_face_normal_at(*handle, *point)
} else {
self.tabs[i]
.scene
.document
.get_entity(*handle)
.and_then(crate::entities::curve::entity_curve)
.and_then(|curve| curve.plane.normal())
.map(glam::DVec3::from_array)
};
if let Some(command) = self.tabs[i].active_cmd.as_mut() {
command.set_entity_pick_direction(direction);
}
if self.tabs[i].active_cmd.as_ref()
.is_some_and(|command| command.inject_before_entity_pick())
{
@@ -4099,6 +4124,102 @@ impl OpenCADStudio {
self.refresh_properties();
}
CmdResult::ThickenEntities { handles, distance } => {
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.tabs[i].scene.clear_preview_wire();
self.restore_pre_cmd_tangent();
if handles.is_empty() {
self.command_line
.push_output(crate::t!("No surfaces selected.").as_ref());
return Task::none();
}
if !distance.is_finite() {
self.command_line.push_error(
crate::t!("Requires numeric distance or two points.").as_ref(),
);
return Task::none();
}
if distance.abs() <= f64::EPSILON {
return Task::none();
}
use crate::modules::insert::solid3d_cmds::empty_solid3d;
let pending = self.begin_undo(i, "THICKEN", handles.len(), true);
let mut created = 0usize;
let mut failed = 0usize;
let mut self_intersections = 0usize;
for handle in handles {
let Some(acadrust::EntityType::Surface(surface)) =
self.tabs[i].scene.document.get_entity(handle)
else {
failed += 1;
continue;
};
let kernel_distance = if surface.kind
== acadrust::entities::SurfaceKind::Revolved
{
-distance
} else {
distance
};
let body = self.tabs[i]
.scene
.solid_models
.get(&handle)
.cloned()
.or_else(|| {
crate::scene::convert::solid3d_tess::kernel_surface_body(surface)
});
let Some(body) = body else {
failed += 1;
continue;
};
let solid = match cadkernel::brep::thicken(&body, kernel_distance) {
Ok(solid) => solid,
Err(cadkernel::brep::ThickenError::SelfIntersection) => {
failed += 1;
self_intersections += 1;
continue;
}
Err(_) => {
failed += 1;
continue;
}
};
let history = crate::scene::model::solid_history::brep_op(&solid);
if self
.add_solid_model(empty_solid3d(), solid, history)
.is_null()
{
failed += 1;
} else {
created += 1;
}
}
if created > 0 {
self.tabs[i].dirty = true;
}
if self_intersections > 0 {
self.command_line.push_error(&crate::tf!(
"{} surface(s) cannot be thickened because the offset intersects itself.",
self_intersections
));
}
if failed > 0 {
self.command_line.push_output(&crate::tf!(
"THICKEN: created {} solid(s); {} surface(s) could not be thickened exactly.",
created, failed
));
}
if let Some(pending) = pending {
self.commit_undo_delta(i, pending);
}
self.refresh_properties();
}
CmdResult::PresspullPick { handle, point, offset, multiple: _ } => {
self.presspull_pick(handle, point, offset);
}
@@ -6345,3 +6466,101 @@ mod sketch_constraint_undo_tests {
);
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod thicken_tests {
use super::*;
use acadrust::entities::{Surface, SurfaceKind};
use cadkernel::geom2d::{Circle, Curve, NurbsCurve};
use cadkernel::space::{Parameterization, Plane};
#[test]
fn thicken_preserves_sources_and_round_trips_results_with_one_undo() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let body = cadkernel::brep::planar_region(Plane::XY, &[
vec![Curve::Circle(Circle { centre: [0.0; 2], radius: 3.0 })],
]).unwrap();
let source = app.add_surface_model(acadrust::EntityType::Surface(Surface::new(SurfaceKind::Plane)), body);
assert!(!source.is_null());
let i = app.active_tab;
let invalid = app.tabs[i].scene.add_entity(acadrust::EntityType::Surface(Surface::new(SurfaceKind::Generic)));
let original = serde_json::to_value(app.tabs[i].scene.document.get_entity(source).unwrap()).unwrap();
let before = app.tabs[i].history.undo_stack.len();
let _ = app.apply_cmd_result(CmdResult::ThickenEntities { handles: vec![source], distance: 0.0 });
assert_eq!(app.tabs[i].history.undo_stack.len(), before);
let _ = app.apply_cmd_result(CmdResult::ThickenEntities { handles: vec![invalid, source], distance: 2.0 });
assert_eq!(app.tabs[i].history.undo_stack.len(), before + 1);
let result = app.tabs[i].scene.document.entities().find_map(|entity| {
matches!(entity, acadrust::EntityType::Solid3D(_)).then_some(entity.common().handle)
}).unwrap();
assert!(app.tabs[i].scene.document.solid_history_operation(result).is_some());
assert_eq!(serde_json::to_value(app.tabs[i].scene.document.get_entity(source).unwrap()).unwrap(), original);
let expected = cadkernel::brep::analytic_mass_properties(&app.tabs[i].scene.solid_models[&result]).unwrap().volume;
let bytes = crate::io::save_to_bytes(&app.tabs[i].scene.document, "dwg", app.tabs[i].scene.document.version).unwrap();
let document = crate::io::load_bytes("thicken.dwg", bytes).unwrap();
let mut restored = crate::scene::Scene::new();
restored.document = document;
restored.restore_solid_models(&[result]);
let actual = cadkernel::brep::analytic_mass_properties(&restored.solid_models[&result]).unwrap().volume;
assert!((expected - actual).abs() < 1e-8);
app.undo_active_tab();
assert!(app.tabs[i].scene.document.get_entity(result).is_none());
assert!(app.tabs[i].scene.document.solid_history_operation(result).is_none());
assert!(app.tabs[i].scene.document.get_entity(source).is_some());
assert!(app.tabs[i].scene.document.get_entity(invalid).is_some());
app.redo_active_tab();
assert!(app.tabs[i].scene.document.get_entity(result).is_some());
assert!(app.tabs[i].scene.document.solid_history_operation(result).is_some());
assert_eq!(serde_json::to_value(app.tabs[i].scene.document.get_entity(source).unwrap()).unwrap(), original);
}
#[test]
fn thicken_creates_a_solid_from_a_free_form_surface() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let profile = NurbsCurve::interpolate(
&[[0.0, 0.0], [0.7, 0.15], [1.3, -0.1], [2.0, 0.0]],
None,
None,
Parameterization::Chord,
)
.unwrap();
let body = cadkernel::brep::extrude_surface(
Plane::XY,
&[Curve::Nurbs(profile)],
[0.0, 0.0, 2.0],
)
.unwrap();
let source = app.add_surface_model(
acadrust::EntityType::Surface(Surface::new(SurfaceKind::Generic)),
body,
);
let i = app.active_tab;
let _ = app.apply_cmd_result(CmdResult::ThickenEntities {
handles: vec![source],
distance: 0.15,
});
let solids = app.tabs[i]
.scene
.document
.entities()
.filter_map(|entity| {
matches!(entity, acadrust::EntityType::Solid3D(_))
.then_some(entity.common().handle)
})
.collect::<Vec<_>>();
assert_eq!(solids.len(), 1);
let solid = &app.tabs[i].scene.solid_models[&solids[0]];
assert!(solid.validate().is_empty());
assert!(solid.edges.iter().all(|(_, edge)| edge.coedges.len() == 2));
assert!(app.tabs[i]
.scene
.document
.solid_history_operation(solids[0])
.is_some());
assert!(app.tabs[i].scene.document.get_entity(source).is_some());
}
}
+14 -1
View File
@@ -734,7 +734,7 @@ impl OpenCADStudio {
}
// ── EXTRUDE ────────────────────────────────────────────────────
"EXTRUDE" | "THICKEN" => {
"EXTRUDE" => {
use crate::modules::insert::solid3d_cmds::ExtrudeCommand;
// A preselection becomes the complete source set; otherwise
// the interactive command gathers any number of profiles.
@@ -769,6 +769,19 @@ impl OpenCADStudio {
}
}
"THICKEN" => {
use crate::modules::insert::solid3d_cmds::ThickenCommand;
let selected = self.tabs[i]
.scene
.selected_entities()
.into_iter()
.map(|(handle, entity)| (handle, entity.clone()))
.collect();
let command = ThickenCommand::new(selected);
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
"PRESSPULL" => {
use crate::modules::insert::solid3d_cmds::PresspullCommand;
let color = self.tabs[i].scene.layer_color(&self.tabs[i].active_layer);
+58
View File
@@ -1400,6 +1400,64 @@ impl OpenCADStudio {
}
}
"SECTIONPLANE" => {
use crate::modules::model::sectionplane_cmd::SectionPlaneCommand;
let (bounds, next_name) = {
let scene = &mut self.tabs[i].scene;
let solid_handles = scene
.document
.entities()
.filter_map(|entity| {
matches!(
entity,
acadrust::EntityType::Solid3D(_)
| acadrust::EntityType::Surface(_)
| acadrust::EntityType::Region(_)
| acadrust::EntityType::Body(_)
)
.then_some(entity.common().handle)
})
.collect::<Vec<_>>();
scene.restore_solid_models(&solid_handles);
let bounds = solid_handles
.iter()
.filter_map(|handle| scene.solid_models.get(handle))
.filter_map(crate::scene::model::solid_model::extent)
.fold(None::<(glam::DVec3, glam::DVec3)>, |bounds, (low, high)| {
let low = glam::DVec3::from_array(low);
let high = glam::DVec3::from_array(high);
Some(match bounds {
None => (low, high),
Some((min, max)) => (min.min(low), max.max(high)),
})
});
let next_name = scene
.document
.entities()
.filter_map(|entity| {
let acadrust::EntityType::Extended(extended) = entity else {
return None;
};
let acadrust::entities::ExtendedEntityData::SectionObject(data) =
&extended.data
else {
return None;
};
data.name
.strip_prefix("Section Plane (")
.and_then(|name| name.strip_suffix(')'))
.and_then(|name| name.parse::<usize>().ok())
})
.max()
.unwrap_or(0)
+ 1;
(bounds, next_name)
};
let command = SectionPlaneCommand::new(bounds, next_name);
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
// 3DALIGN <18 numbers> — align the selected solid by 3 source→3 dest points.
cmd if cmd == "3DALIGN"
|| cmd == "ALIGN3D"
+2
View File
@@ -465,6 +465,7 @@ inventory::submit!(crate::command::CommandRegistration {
"3DALIGN",
"ALIGN3D",
"SECTION",
"SECTIONPLANE",
"PYRAMID",
"PYR",
"SPLINEFIT",
@@ -551,6 +552,7 @@ inventory::submit!(crate::command::CommandRegistration {
"GRIPCOLOR",
"GRIPHOT",
"GRIPHOVER",
"GRIPOBJLIMIT",
// Reset selected entities' overrides to follow their layer.
"SETBYLAYER",
// Remove duplicate objects; set drawing base point; audit integrity;
+26 -1
View File
@@ -918,6 +918,7 @@ impl OpenCADStudio {
| "GRIPCOLOR"
| "GRIPHOT"
| "GRIPHOVER"
| "GRIPOBJLIMIT"
) =>
{
return self.dispatch_styleprops(&format!("SETVAR {cmd}"), i);
@@ -940,7 +941,7 @@ impl OpenCADStudio {
let value = it.next().map(|s| s.trim().to_string());
if name.is_empty() || name == "?" {
self.command_line.push_info(
crate::t!("SETVAR: 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 | CLAYER CELTYPE TEXTSTYLE (read-only)").as_ref(),
crate::t!("SETVAR: 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(),
);
} else {
if matches!(name.as_str(), "SHOWHIST" | "SOLIDHIST") {
@@ -1444,6 +1445,22 @@ impl OpenCADStudio {
},
None => Ok((format!("GRIPSIZE = {}", self.model_space.grip_size), false)),
},
"GRIPOBJLIMIT" => match &value {
Some(v) => match v.parse::<i32>() {
Ok(limit @ 0..=32767) => {
self.grip_object_limit = limit;
Ok((format!("GRIPOBJLIMIT = {limit}"), true))
}
_ => Err(
"SETVAR: integer from 0 to 32767 required (0 = no limit)."
.into(),
),
},
None => Ok((
format!("GRIPOBJLIMIT = {}", self.grip_object_limit),
false,
)),
},
"GRIPCOLOR" => match &value {
Some(v) => match v.parse::<u8>() {
Ok(color) => {
@@ -2126,6 +2143,7 @@ impl OpenCADStudio {
| "GRIPCOLOR"
| "GRIPHOT"
| "GRIPHOVER"
| "GRIPOBJLIMIT"
) {
self.persist_settings_if_changed();
} else {
@@ -2982,6 +3000,13 @@ mod tests {
// SELECTIONPREVIEW
let _ = app.run_command_line("SETVAR SELECTIONPREVIEW 2");
assert_eq!(app.model_space.selection_preview, 2);
let _ = app.run_command_line("SETVAR GRIPOBJLIMIT 0");
assert_eq!(app.grip_object_limit, 0);
let _ = app.run_command_line("GRIPOBJLIMIT 32767");
assert_eq!(app.grip_object_limit, 32767);
let _ = app.run_command_line("GRIPOBJLIMIT 32768");
assert_eq!(app.grip_object_limit, 32767);
}
#[test]
+10
View File
@@ -1566,6 +1566,16 @@ mod tests {
app
}
#[test]
fn options_and_its_alias_dispatch_from_the_start_page() {
let mut app = fresh_app();
let i = app.active_tab;
assert!(app.dispatch_view("OPTIONS", i).is_some());
assert!(app.dispatch_view("OP", i).is_some());
assert!(crate::app::commands::start_allowed("OPTIONS"));
assert!(crate::app::commands::start_allowed("OP"));
}
#[test]
fn hatchtoback_no_hatches_warns_and_noop() {
let mut app = fresh_app();
+1 -1
View File
@@ -270,7 +270,7 @@ pub struct ModelSpaceThemeConfig {
pub selection_highlight_color: u8,
/// SELECTIONEFFECT: whether selected objects glow with solid highlight (true) or dash (false).
pub selection_effect: bool,
/// SELECTIONPREVIEW: rollover/hover highlight mode (0 = off, 1 = in cmd, 2 = idle, 3 = both).
/// SELECTIONPREVIEW bitmask: 1 = idle, 2 = during a command, 3 = both.
pub selection_preview: u8,
/// GRIPSIZE: grip marker half-size in pixels (1–25, default 5).
pub grip_size: u8,
+1
View File
@@ -171,6 +171,7 @@ impl OpenCADStudio {
| "text_color"
| "block_content_color"
| "background_fill_color"
| "indicator_fill_color"
) {
self.update(Message::PropColorFieldChanged {
field: p.field.into(),
+4
View File
@@ -717,6 +717,10 @@ impl OpenCADStudio {
entity,
EntityType::Block(_)
| EntityType::BlockEnd(_)
| EntityType::Extended(acadrust::entities::ExtendedEntity {
data: acadrust::entities::ExtendedEntityData::SectionObject(_),
..
})
) {
return false;
}
+36
View File
@@ -387,6 +387,10 @@ pub(super) struct OpenCADStudio {
/// though the three factors are currently equal — the user unchecked the
/// "Uniform scale" box for them (#427). Keyed by entity handle.
props_asym_scale: std::collections::HashSet<u64>,
/// Collapsed Properties-panel section titles. This belongs to the app,
/// rather than an individual document tab, so the same view preference is
/// used by every currently open drawing/project.
collapsed_property_sections: rustc_hash::FxHashSet<String>,
/// Which Start-page section is shown when the page is too narrow for all
/// three side by side and falls back to a tab bar.
start_section: StartSection,
@@ -496,6 +500,13 @@ pub(super) struct OpenCADStudio {
cursor_size: i32,
/// Selection-box size setting (PICKBOX, 0..=50).
pick_box: i32,
/// Use REFEDIT rather than BEDIT when double-clicking an attribute-free block.
double_click_block_refedit: bool,
/// Open ATTEDIT when double-clicking a block with attributes.
double_click_block_attedit: bool,
/// Selected-object count past which grips stop being generated
/// (GRIPOBJLIMIT, 0..=32767; 0 = no limit).
grip_object_limit: i32,
/// Drawing viewport cursor style (CURSORTYPE).
cursor_type: settings::CursorType,
/// Explicit crosshair colour; `None` retains automatic contrast.
@@ -1962,6 +1973,10 @@ pub enum Message {
CursorSizeChanged(i32),
/// Set PICKBOX from the Selection-page slider.
PickBoxChanged(i32),
/// Choose whether double-clicking a block starts BEDIT or REFEDIT.
DoubleClickBlockRefeditChanged(bool),
/// Choose whether double-clicking a block with attributes starts ATTEDIT.
DoubleClickBlockAtteditChanged(bool),
/// Set CURSORTYPE from Options.
CursorTypeChanged(settings::CursorType),
/// Set the model-space lineweight preview scale from Options.
@@ -2008,6 +2023,19 @@ pub enum Message {
GripHotChanged(u8),
/// Change Hover/Warm Grip color ACI index (0 = Theme Primary Strong, 1..=255 = ACI, GRIPHOVER).
GripHoverChanged(u8),
/// Change the selected-object count past which grips stop being drawn
/// (0..=32767, 0 = no limit, GRIPOBJLIMIT).
GripObjectLimitChanged(i32),
/// Toggle the solid selection highlight (SELECTIONEFFECT).
SelectionEffectToggled(bool),
/// Toggle rollover preview while no command is running (SELECTIONPREVIEW bit 1).
SelectionPreviewIdleToggled(bool),
/// Toggle rollover preview during a command (SELECTIONPREVIEW bit 2).
SelectionPreviewCommandToggled(bool),
/// Toggle "use Shift to add to selection"; this is the inverse of PICKADD.
ShiftToAddToggled(bool),
/// Toggle press-and-drag drawing a rectangle instead of a lasso (PICKDRAG).
PickDragRectToggled(bool),
/// Restore Model Space display/canvas appearance to defaults.
RestoreModelSpaceDisplayDefaults,
/// Restore Selection visual effect settings to defaults.
@@ -2214,6 +2242,8 @@ pub enum Message {
/// clipboard as plain text — issue #232, so output can be pasted for
/// debugging instead of screenshotted.
CommandHistoryCopy,
#[cfg(target_arch = "wasm32")]
CommandHistoryCopied(bool),
/// Clear every line from the command-line history.
CommandHistoryClear,
/// Copy every line currently retained by the PERF panel.
@@ -2604,6 +2634,8 @@ pub enum Message {
/// Toggle a collapsed coordinate group ("Position", "Scale", …) open or
/// closed in the Properties panel, keyed `section:base`.
PropGroupToggle(String),
/// Toggle an entire Properties-panel section, keyed by its title.
PropSectionToggle(String),
/// Toggle the editable-dropdown (block Name) option list open/closed.
PropEditChoiceToggle,
/// User is typing in a block-attribute value field (live buffer update),
@@ -3378,6 +3410,7 @@ impl OpenCADStudio {
discussions: Vec::new(),
discussions_loading: false,
props_asym_scale: std::collections::HashSet::new(),
collapsed_property_sections: rustc_hash::FxHashSet::default(),
start_section: StartSection::default(),
start_action_w: std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0)),
history_content: iced::widget::text_editor::Content::new(),
@@ -3426,6 +3459,9 @@ impl OpenCADStudio {
zoom_factor: 60,
cursor_size: 5,
pick_box: 3,
double_click_block_refedit: false,
double_click_block_attedit: true,
grip_object_limit: settings::DEFAULT_GRIP_OBJECT_LIMIT,
cursor_type: settings::CursorType::Crosshair,
crosshair_color: None,
crosshair_color_input: String::new(),
+270 -24
View File
@@ -32,6 +32,10 @@ impl OpenCADStudio {
/// Rebuild the PropertiesPanel from the current entity selection.
/// Preserves UI state (open pickers, edit buffer) across refreshes.
pub(super) fn refresh_properties(&mut self) {
// A 186 468-entity selection spends ~112 ms in here. Split it into the
// three phases that can own that: resolving the selected handles,
// building the panel, and syncing the ribbon.
let t_all = crate::perf::enabled().then(iced::time::Instant::now);
let i = self.active_tab;
if !crate::entities::object_data::cache_is_prepared(
&self.tabs[i].scene.object_data_cache,
@@ -47,6 +51,10 @@ impl OpenCADStudio {
// Expanded coordinate groups persist across rebuilds AND selection
// changes — it's a per-user view preference, not per-entity state.
let expanded_groups = std::mem::take(&mut self.tabs[i].properties.expanded_groups);
// Section state belongs to the app, not a drawing tab, so the user's
// collapsed sections carry across every currently open drawing/project.
// The empty app-level default keeps a first-run palette fully expanded.
let collapsed_sections = self.collapsed_property_sections.clone();
// Which entities the previous panel was built for — an uncommitted
// edit buffer only survives a rebuild for the *same* selection.
let prev_handles = std::mem::take(&mut self.tabs[i].properties.source_handles);
@@ -101,12 +109,17 @@ impl OpenCADStudio {
// Current-Vertex focus survives only while the same object stays
// selected; a changed selection resets to the first vertex. Seed the
// per-thread focus so the polyline property builder / editor targets it.
// Everything before this is the prelude: the object-data cache, the
// unit context, the layer and linetype lists.
let m_prelude = t_all.map(|t| t.elapsed().as_secs_f64() * 1000.0);
let t_handles = crate::perf::enabled().then(iced::time::Instant::now);
let cur_handles: Vec<acadrust::Handle> = self.tabs[i]
.scene
.selected_entities()
.iter()
.map(|(h, _)| *h)
.selected_handles_in_order()
.into_iter()
.filter(|h| self.tabs[i].scene.document.get_entity(*h).is_some())
.collect();
let handles_ms = t_handles.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0);
let prop_vertex = if cur_handles == prev_handles {
self.tabs[i].properties.prop_vertex
} else {
@@ -2175,7 +2188,9 @@ impl OpenCADStudio {
..Default::default()
},
_ => {
let t_arm = crate::perf::enabled().then(iced::time::Instant::now);
let groups = build_selection_groups(&selected);
let t_groups = t_arm.map(|t| t.elapsed().as_secs_f64() * 1000.0);
let active_group = selected_group
.and_then(|group| groups.iter().find(|g| g.label == group.label).cloned())
.or_else(|| groups.first().cloned());
@@ -2183,30 +2198,57 @@ impl OpenCADStudio {
let filtered: Vec<(Handle, &EntityType)> = active_group
.as_ref()
.map(|group| {
let wanted: rustc_hash::FxHashSet<Handle> =
group.handles.iter().copied().collect();
selected
.iter()
.filter(|(handle, _)| group.handles.contains(handle))
.filter(|(handle, _)| wanted.contains(handle))
.copied()
.collect()
})
.unwrap_or_default();
let t_filter = t_arm.map(|t| t.elapsed().as_secs_f64() * 1000.0);
let plane = if self.tabs[i].editing_model_space() {
self.tabs[i].ucs_xform().working_plane()
} else {
crate::command::WorkingPlane::default()
};
let local_entities: Vec<(Handle, EntityType)> = filtered
.iter()
.map(|(handle, entity)| {
(*handle, dispatch::entity_in_working_plane(entity, plane))
})
.collect();
let local_entities: Vec<(Handle, std::borrow::Cow<'_, EntityType>)> =
filtered
.iter()
.map(|(handle, entity)| {
let local = if plane.is_identity() {
std::borrow::Cow::Borrowed(*entity)
} else {
std::borrow::Cow::Owned(
dispatch::entity_in_working_plane(entity, plane),
)
};
(*handle, local)
})
.collect();
let local_refs: Vec<(Handle, &EntityType)> = local_entities
.iter()
.map(|(handle, entity)| (*handle, entity))
.map(|(handle, entity)| (*handle, entity.as_ref()))
.collect();
let t_local = t_arm.map(|t| t.elapsed().as_secs_f64() * 1000.0);
let mut sections = aggregate_sections(&local_refs, &text_style_names);
if let (Some(t), Some(groups_ms), Some(filter_ms), Some(local_ms)) =
(t_arm, t_groups, t_filter, t_local)
{
let total = t.elapsed().as_secs_f64() * 1000.0;
if total >= 5.0 {
crate::perf_record!(
"[perf] properties-panel {total:>7.1}ms groups={groups_ms:.1} \
filter={:.1} local={:.1} aggregate={:.1} entities={}",
filter_ms - groups_ms,
local_ms - filter_ms,
total - local_ms,
local_refs.len(),
);
}
}
if local_refs.iter().all(|(handle, _)| {
crate::scene::model::solid_history::has_compact_solid_properties(
&self.tabs[i].scene.document,
@@ -2278,6 +2320,7 @@ impl OpenCADStudio {
None
};
panel.expanded_groups = expanded_groups;
panel.collapsed_sections = collapsed_sections;
panel.source_handles = new_handles;
panel.prop_vertex = prop_vertex;
panel.prop_vertex_indicator_active = prop_vertex_indicator_active;
@@ -2310,7 +2353,24 @@ impl OpenCADStudio {
self.tabs[i].properties = new_panel;
self.refresh_selected_grips();
let m_panel = t_all.map(|t| t.elapsed().as_secs_f64() * 1000.0);
let t_ribbon = crate::perf::enabled().then(iced::time::Instant::now);
self.sync_ribbon_from_selection();
if let Some(t) = t_all {
let ribbon_ms = t_ribbon.map_or(0.0, |r| r.elapsed().as_secs_f64() * 1000.0);
let total_ms = t.elapsed().as_secs_f64() * 1000.0;
if total_ms >= 5.0 {
let prelude = m_prelude.unwrap_or(0.0);
let panel = m_panel.unwrap_or(0.0);
crate::perf_record!(
"[perf] properties-detail {total_ms:>7.1}ms prelude={prelude:.1} \
handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={}",
panel - prelude - handles_ms,
total_ms - panel - ribbon_ms,
cur_handles.len(),
);
}
}
}
/// Drive the Home-ribbon Layer / Color / Linetype / Lineweight dropdowns
@@ -2371,11 +2431,16 @@ impl OpenCADStudio {
let mut lineweight_mixed = false;
for (_h, e) in &selected {
if layer_mixed && color_mixed && linetype_mixed && lineweight_mixed {
break;
}
let c = e.common();
// Borrowed, not cloned: this only ever gets compared, and cloning
// it here was one heap allocation per selected entity.
let lt = if c.linetype.is_empty() {
"ByLayer".to_string()
"ByLayer"
} else {
c.linetype.clone()
c.linetype.as_str()
};
match &layer {
None => layer = Some(c.layer.clone()),
@@ -2388,8 +2453,8 @@ impl OpenCADStudio {
_ => {}
}
match &linetype {
None => linetype = Some(lt),
Some(prev) if prev != &lt => linetype_mixed = true,
None => linetype = Some(lt.to_string()),
Some(prev) if prev.as_str() != lt => linetype_mixed = true,
_ => {}
}
match &lineweight {
@@ -2421,6 +2486,10 @@ impl OpenCADStudio {
}
/// Rebuild the cached selected_grips from the current entity selection.
///
/// Selections past `GRIPOBJLIMIT` get no grips at all. That used to be a
/// constant here; it is now [`crate::app::settings::UserSettings::grip_object_limit`],
/// reachable from Options and from the sysvar of the same name.
pub(super) fn refresh_selected_grips(&mut self) {
let i = self.active_tab;
let locked_active_grip = self.tabs[i].active_grip.as_ref().is_some_and(|grip| {
@@ -2448,6 +2517,14 @@ impl OpenCADStudio {
.then(|| selected[0].0);
let mut grips = Vec::new();
let mut handles = Vec::new();
// Avoid rebuilding every entity's grip markers above the configured
// limit. Zero disables the limit.
let limit = self.grip_object_limit;
let selected = if limit > 0 && selected.len() > limit as usize {
Vec::new()
} else {
selected
};
for (handle, entity) in selected {
if self.tabs[i].scene.is_layer_locked(handle) {
continue;
@@ -3068,13 +3145,18 @@ pub(super) fn aggregate_sections(
return vec![];
}
let mut all_sections: Vec<Vec<crate::scene::model::object::PropSection>> = selected
.iter()
.map(|(handle, entity)| dispatch::properties_sectioned(*handle, entity, text_style_names))
.collect();
let mut result = all_sections.remove(0);
for sections in all_sections {
let mut entities = selected.iter();
let Some((handle, entity)) = entities.next() else {
return vec![];
};
let mut result = dispatch::properties_sectioned(*handle, entity, text_style_names);
for (handle, entity) in entities {
// Nothing in common left to narrow: every later entity can only
// intersect against an empty set.
if result.is_empty() {
break;
}
let sections = dispatch::properties_sectioned(*handle, entity, text_style_names);
result = merge_sections(&result, &sections);
}
// Sum the filled area while individual Area rows may still vary.
@@ -3135,8 +3217,7 @@ fn retain_compact_solid_sections(
| "transparency"
| "hyperlink"
| "material"
) || property.field.starts_with("mesh_")
|| crate::scene::model::solid_history::is_specialized_property(property.field)
) || crate::scene::model::solid_history::is_specialized_property(property.field)
});
});
sections.retain(|section| !section.props.is_empty());
@@ -4022,3 +4103,168 @@ mod chprop_integration_tests {
assert_eq!(app.ribbon.active_lineweight, new_lw);
}
}
#[cfg(test)]
mod grip_limit_tests {
use super::*;
#[test]
fn a_selection_past_the_limit_gets_no_grips() {
use acadrust::entities::{EntityType, Line};
use acadrust::types::Vector3;
let select_n = |n: usize, limit: Option<i32>| -> usize {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
if let Some(limit) = limit {
app.grip_object_limit = limit;
}
let i = app.active_tab;
let handles: Vec<_> = (0..n)
.map(|k| {
let x = k as f64;
app.tabs[i].scene.add_entity(EntityType::Line(
Line::from_points(
Vector3::new(x, 0.0, 0.0),
Vector3::new(x + 1.0, 1.0, 0.0),
),
))
})
.collect();
for handle in handles {
app.tabs[i].scene.select_entity(handle, false);
}
app.refresh_selected_grips();
app.tabs[i].selected_grips.len()
};
assert!(
select_n(crate::app::settings::DEFAULT_GRIP_OBJECT_LIMIT as usize, None) > 0,
"a selection at the limit must still show its grips",
);
assert_eq!(
select_n(crate::app::settings::DEFAULT_GRIP_OBJECT_LIMIT as usize + 1, None),
0,
"one past the limit must show none at all",
);
}
/// The Options card writes these four straight onto the app and relies on
/// the snapshot/restore pair to carry them across a restart. Three of them
/// had no UI until now and so no reason for anyone to notice if the pair
/// missed one.
#[test]
fn the_selection_settings_survive_a_save_and_load() {
let mut app = OpenCADStudio::new_for_test();
app.pick_add = false;
app.pick_drag_rect = true;
app.pick_box = 11;
app.grip_object_limit = 0;
let saved = app.current_settings();
let mut restored = OpenCADStudio::new_for_test();
restored.apply_settings(&saved);
assert!(!restored.pick_add);
assert!(restored.pick_drag_rect);
assert_eq!(restored.pick_box, 11);
assert_eq!(
restored.grip_object_limit, 0,
"zero is a real value here, not an unset field",
);
}
/// `GRIPOBJLIMIT 0` means no limit, and the old comparison — a bare `>`
/// against the stored number — could not express that: zero would have
/// suppressed every grip instead of allowing all of them.
#[test]
fn a_limit_of_zero_means_no_limit() {
use acadrust::entities::{EntityType, Line};
use acadrust::types::Vector3;
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
app.grip_object_limit = 0;
let i = app.active_tab;
let over = crate::app::settings::DEFAULT_GRIP_OBJECT_LIMIT as usize * 3;
let handles: Vec<_> = (0..over)
.map(|k| {
let x = k as f64;
app.tabs[i].scene.add_entity(EntityType::Line(Line::from_points(
Vector3::new(x, 0.0, 0.0),
Vector3::new(x + 1.0, 1.0, 0.0),
)))
})
.collect();
for handle in handles {
app.tabs[i].scene.select_entity(handle, false);
}
app.refresh_selected_grips();
assert!(
app.tabs[i].selected_grips.len() > 0,
"zero must read as unlimited, not as a limit of none",
);
}
}
#[cfg(test)]
mod aggregation_tests {
use super::*;
use acadrust::EntityType;
fn line(layer: &str, color: i16) -> EntityType {
let mut line = acadrust::entities::line::Line::default();
line.common.layer = layer.to_string();
line.common.color = acadrust::types::Color::from_index(color);
EntityType::Line(line)
}
fn row<'a>(
sections: &'a [crate::scene::model::object::PropSection],
field: &str,
) -> Option<&'a crate::scene::model::object::Property> {
sections
.iter()
.flat_map(|section| section.props.iter())
.find(|property| property.field == field)
}
#[test]
fn shared_values_survive_the_fold_and_differing_ones_do_not() {
let entities = [line("WALLS", 1), line("WALLS", 3), line("WALLS", 1)];
let selected: Vec<(Handle, &EntityType)> = entities
.iter()
.enumerate()
.map(|(i, entity)| (Handle::new(i as u64 + 1), entity))
.collect();
let sections = aggregate_sections(&selected, &[]);
assert!(!sections.is_empty(), "three lines share their layer rows");
// A colour the entities disagree on has its own variant rather than
// the text label, so the two rows are checked on their own terms.
let layer = row(&sections, "layer").expect("a layer row");
assert!(
format!("{:?}", layer.value).contains("WALLS"),
"one layer across all three: {:?}",
layer.value,
);
let color = row(&sections, "color").expect("a color row");
assert!(
format!("{:?}", color.value).contains("Varies"),
"two colours across three lines: {:?}",
color.value,
);
}
// One entity folds to its own properties, and the fold must not depend on
// there being a second one to merge against.
#[test]
fn a_single_entity_aggregates_to_its_own_rows() {
let entity = line("WALLS", 1);
let selected = [(Handle::new(1), &entity)];
let sections = aggregate_sections(&selected, &[]);
let layer = row(&sections, "layer").expect("a layer row");
assert!(format!("{:?}", layer.value).contains("WALLS"));
}
}
+13
View File
@@ -145,6 +145,9 @@ pub(crate) fn snaps_from_osmode(osmode: i32) -> (Vec<SnapType>, bool) {
(modes, osmode & OSMODE_SUPPRESS == 0)
}
/// GRIPOBJLIMIT default: past this many selected objects, no grips are drawn.
pub const DEFAULT_GRIP_OBJECT_LIMIT: i32 = 100;
/// The "settings" section of the consolidated config ([`crate::app::config`]).
/// Field defaults mirror the app's in-code defaults so a missing key restores
/// the value the app boots with.
@@ -160,6 +163,13 @@ pub struct UserSettings {
pub cursor_size: i32,
/// PICKBOX: normalized visible-box and click-aperture size.
pub pick_box: i32,
/// When true, double-clicking a block reference starts REFEDIT instead of BEDIT.
pub double_click_block_refedit: bool,
/// When true, double-clicking a block with attributes opens ATTEDIT.
pub double_click_block_attedit: bool,
/// GRIPOBJLIMIT: past this many selected objects, no grips are drawn at
/// all. 0 means no limit. The drawing header carries no slot for it.
pub grip_object_limit: i32,
/// CURSORTYPE: crosshair or the platform pointer over the drawing.
pub cursor_type: CursorType,
/// Explicit crosshair RGB. `None` keeps automatic background contrast.
@@ -324,6 +334,9 @@ impl Default for UserSettings {
zoom_factor: 60,
cursor_size: 5,
pick_box: 3,
double_click_block_refedit: false,
double_click_block_attedit: true,
grip_object_limit: DEFAULT_GRIP_OBJECT_LIMIT,
cursor_type: CursorType::Crosshair,
crosshair_color: None,
lineweight_display_scale: 100,
+6
View File
@@ -365,6 +365,9 @@ impl OpenCADStudio {
zoom_factor: self.zoom_factor,
cursor_size: self.cursor_size,
pick_box: self.pick_box,
double_click_block_refedit: self.double_click_block_refedit,
double_click_block_attedit: self.double_click_block_attedit,
grip_object_limit: self.grip_object_limit,
cursor_type: self.cursor_type,
crosshair_color: self.crosshair_color,
lineweight_display_scale: self.lineweight_display_scale,
@@ -420,6 +423,9 @@ impl OpenCADStudio {
self.zoom_factor = s.zoom_factor.clamp(3, 100);
self.cursor_size = s.cursor_size.clamp(1, 100);
self.pick_box = s.pick_box.clamp(0, 50);
self.double_click_block_refedit = s.double_click_block_refedit;
self.double_click_block_attedit = s.double_click_block_attedit;
self.grip_object_limit = s.grip_object_limit.clamp(0, 32767);
self.cursor_type = s.cursor_type;
self.crosshair_color = s.crosshair_color;
self.crosshair_color_input = s
+121 -3
View File
@@ -1839,10 +1839,32 @@ impl OpenCADStudio {
Message::CommandHistoryCopy => {
let text = self.command_line.history_plain_text();
if text.is_empty() {
Task::none()
} else {
iced::clipboard::write(text).discard()
return Task::none();
}
#[cfg(target_arch = "wasm32")]
{
let promise = crate::sys::copy_history_text(
&text,
crate::t!("Clipboard access is unavailable. Press Ctrl+C or Command+C to copy the selected history.").as_ref(),
crate::t!("Close").as_ref(),
);
Task::perform(async move {
wasm_bindgen_futures::JsFuture::from(promise).await
.ok().and_then(|value| value.as_bool()).unwrap_or(false)
}, Message::CommandHistoryCopied)
}
#[cfg(not(target_arch = "wasm32"))]
iced::clipboard::write(text).discard()
}
#[cfg(target_arch = "wasm32")]
Message::CommandHistoryCopied(copied) => {
if copied {
self.command_line.push_info(crate::t!("Copied").as_ref());
} else {
self.command_line.push_error(crate::t!("Clipboard access is unavailable. Press Ctrl+C or Command+C to copy the selected history.").as_ref());
}
Task::none()
}
Message::CommandHistoryClear => {
@@ -5108,6 +5130,20 @@ impl OpenCADStudio {
Task::none()
}
Message::PropSectionToggle(title) => {
let sections = &mut self.collapsed_property_sections;
if !sections.remove(&title) {
sections.insert(title);
}
// Keep already-built panels in other open documents in sync,
// rather than waiting for each one to rebuild on selection.
let sections = self.collapsed_property_sections.clone();
for tab in &mut self.tabs {
tab.properties.collapsed_sections = sections.clone();
}
Task::none()
}
Message::PropEditChoiceToggle => {
let panel = &mut self.tabs[self.active_tab].properties;
panel.edit_choice_open = !panel.edit_choice_open;
@@ -5216,6 +5252,21 @@ impl OpenCADStudio {
Message::PropColorFieldChanged { field, color } => {
let i = self.active_tab;
let handles = self.property_target_handles(i);
if field == "indicator_fill_color" {
self.apply_property_op(i, "CHPROP", &handles, |app, handle| {
if let Some(acadrust::EntityType::Extended(extended)) =
app.tabs[i].scene.document.get_entity_mut(handle)
{
if let acadrust::entities::ExtendedEntityData::SectionObject(data) =
&mut extended.data
{
data.indicator_color = color;
}
}
});
self.tabs[i].properties.open_color_field = None;
return Task::none();
}
// Dim-line colour override (Leader / Dimension): write it as an
// ACAD_DSTYLE code-176 override (an ACI index) so it round-trips
// through DWG and DXF. RGB picks collapse to the nearest ACI, in
@@ -5921,6 +5972,18 @@ impl OpenCADStudio {
Task::none()
}
Message::DoubleClickBlockRefeditChanged(enabled) => {
self.double_click_block_refedit = enabled;
self.persist_settings_if_changed();
Task::none()
}
Message::DoubleClickBlockAtteditChanged(enabled) => {
self.double_click_block_attedit = enabled;
self.persist_settings_if_changed();
Task::none()
}
Message::CursorTypeChanged(value) => {
self.cursor_type = value;
self.persist_settings_if_changed();
@@ -6148,6 +6211,48 @@ impl OpenCADStudio {
Task::none()
}
Message::GripObjectLimitChanged(limit) => {
self.grip_object_limit = limit.clamp(0, 32767);
self.persist_settings_if_changed();
Task::none()
}
Message::SelectionEffectToggled(enabled) => {
self.model_space.selection_effect = enabled;
self.sync_model_space_theme(false);
self.persist_settings_if_changed();
Task::none()
}
// SELECTIONPREVIEW is a bitmask and the two checkboxes own one bit
// each: 1 = rollover while idle, 2 = rollover during a command.
Message::SelectionPreviewIdleToggled(enabled) => {
self.model_space.selection_preview =
set_preview_bit(self.model_space.selection_preview, 1, enabled);
self.persist_settings_if_changed();
Task::none()
}
Message::SelectionPreviewCommandToggled(enabled) => {
self.model_space.selection_preview =
set_preview_bit(self.model_space.selection_preview, 2, enabled);
self.persist_settings_if_changed();
Task::none()
}
// "Use Shift to add" is the inverse of PICKADD.
Message::ShiftToAddToggled(shift_to_add) => {
self.pick_add = !shift_to_add;
self.persist_settings_if_changed();
Task::none()
}
Message::PickDragRectToggled(rectangle) => {
self.pick_drag_rect = rectangle;
self.persist_settings_if_changed();
Task::none()
}
Message::RestoreSelectionVisualDefaults => {
self.model_space.selection_area = true;
self.model_space.selection_opacity = 12;
@@ -6160,6 +6265,10 @@ impl OpenCADStudio {
self.model_space.grip_color = 0;
self.model_space.grip_hot = 0;
self.model_space.grip_hover = 0;
// The button restores the *visual* defaults, which is why the
// grip limit is here and PICKADD / PICKDRAG are not — those are
// how selection behaves, not how it looks.
self.grip_object_limit = crate::app::settings::DEFAULT_GRIP_OBJECT_LIMIT;
self.sync_model_space_theme(false);
self.persist_settings_if_changed();
Task::none()
@@ -8677,3 +8786,12 @@ mod free_text_entry_tests {
assert_eq!(app.text_entry_mode(), TextEntryMode::Command);
}
}
/// Set or clear one bit of `SELECTIONPREVIEW`.
fn set_preview_bit(current: u8, bit: u8, enabled: bool) -> u8 {
if enabled {
current | bit
} else {
current & !bit
}
}
+143 -24
View File
@@ -4124,6 +4124,10 @@ impl OpenCADStudio {
aabb
},
);
// A 186 k-entity box selection sits at ~800 ms in this
// handler and two guesses about which step owns it have
// both been wrong. Split it.
let t_sel = crate::perf::enabled().then(Instant::now);
let area_candidates = self.tabs[i].scene.interaction_candidates_in_aabb(
all_wires,
world_aabb,
@@ -4132,6 +4136,9 @@ impl OpenCADStudio {
eye,
bounds,
);
let cand_ms = t_sel
.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0);
let t_hit = crate::perf::enabled().then(Instant::now);
let candidate_handles = self.tabs[i]
.scene
.interaction_candidate_handles(&area_candidates);
@@ -4192,20 +4199,31 @@ impl OpenCADStudio {
// selection, Shift+box removes the boxed
// entities. Esc / empty-space click still clears.
// PICKADD 0 (#226): a plain box REPLACES.
let hit_ms = t_hit
.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0);
let t_apply = crate::perf::enabled().then(Instant::now);
if self.shift_down || self.select_remove_mode {
for h in &handles {
self.tabs[i].scene.deselect_entity(*h);
}
self.tabs[i].scene.deselect_entities(&handles);
} else {
if !selection_pick_add && !handles.is_empty() {
self.tabs[i].scene.deselect_all();
}
for h in &handles {
self.tabs[i].scene.select_entity(*h, false);
}
self.tabs[i].scene.select_entities(&handles);
self.tabs[i].scene.expand_selection_for_groups(&handles);
}
let apply_ms = t_apply
.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0);
let t_props = crate::perf::enabled().then(Instant::now);
self.refresh_properties();
if crate::perf::enabled() {
crate::perf_record!(
"[perf] select-commit kind=drag-box crossing={crossing} \
candidates={cand_ms:.1}ms hit={hit_ms:.1}ms apply={apply_ms:.1}ms \
properties={:.1}ms picked={}",
t_props.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0),
handles.len(),
);
}
selection_just_completed = true;
}
} else {
@@ -4239,9 +4257,7 @@ impl OpenCADStudio {
// leaves the current selection untouched so a stray
// drag never discards hard-won picks.
if self.shift_down || self.select_remove_mode {
for h in &handles {
self.tabs[i].scene.deselect_entity(*h);
}
self.tabs[i].scene.deselect_entities(&handles);
} else {
// PICKADD 0 (#226): a plain marquee REPLACES
// the selection (empty results still leave it
@@ -4249,9 +4265,7 @@ impl OpenCADStudio {
if !selection_pick_add && !handles.is_empty() {
self.tabs[i].scene.deselect_all();
}
for h in &handles {
self.tabs[i].scene.select_entity(*h, false);
}
self.tabs[i].scene.select_entities(&handles);
self.tabs[i].scene.expand_selection_for_groups(&handles);
}
self.refresh_properties();
@@ -4268,6 +4282,8 @@ impl OpenCADStudio {
if box_anchor.is_none() {
let (view_rot, eye, all_wires) = self.pick_view(i, &edit_cam, bounds);
let click_world = self.cursor_model_point(i, &edit_cam, p, bounds);
let t_arm = crate::perf::enabled().then(Instant::now);
let prior_selection = self.tabs[i].scene.selected.len();
let click_candidates = self.tabs[i].scene.interaction_pick_candidates_near(
all_wires,
click_world,
@@ -4402,6 +4418,16 @@ impl OpenCADStudio {
// (#234). Computed before the selection
// borrow so the &self projection can't clash.
let anchor_world = self.cursor_model_point(i, &edit_cam, p, bounds);
if let Some(t) = t_arm {
let arm_ms = t.elapsed().as_secs_f64() * 1000.0;
if arm_ms >= 5.0 {
crate::perf_record!(
"[perf] select-arm {arm_ms:>7.1}ms pick+clear, \
was_selected={}",
prior_selection,
);
}
}
let mut sel = self.tabs[i].scene.selection.borrow_mut();
// Full-canvas space: ViewportMove updates
// box_current in canvas coords and the overlay
@@ -4485,6 +4511,10 @@ impl OpenCADStudio {
aabb
},
);
// This is the path a click-move-click window takes, as
// opposed to a press-drag; it is the one a large selection
// actually goes through.
let t_sel = crate::perf::enabled().then(Instant::now);
let area_candidates = self.tabs[i].scene.interaction_candidates_in_aabb(
all_wires,
world_aabb,
@@ -4493,9 +4523,16 @@ impl OpenCADStudio {
eye,
bounds,
);
let cand_ms =
t_sel.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0);
let t_hit = crate::perf::enabled().then(Instant::now);
let candidate_handles = self.tabs[i]
.scene
.interaction_candidate_handles(&area_candidates);
// `hit` covers five things, and which of them owns it has
// never been separated: resolving candidate handles, the wire
// box test, the two hatch tests and the two mesh tests.
let m_handles = t_hit.map(|t| t.elapsed().as_secs_f64() * 1000.0);
let mut handles: Vec<Handle> = scene::pick::hit_test::box_hit(
a,
p,
@@ -4508,6 +4545,7 @@ impl OpenCADStudio {
.into_iter()
.filter_map(|s| Scene::handle_from_wire_name(s))
.collect();
let m_wires = t_hit.map(|t| t.elapsed().as_secs_f64() * 1000.0);
handles.extend(scene::pick::hit_test::box_hit_hatch(
a,
p,
@@ -4530,6 +4568,7 @@ impl OpenCADStudio {
bounds,
candidate_handles.as_ref(),
));
let m_hatch = t_hit.map(|t| t.elapsed().as_secs_f64() * 1000.0);
handles.extend(self.tabs[i].scene.mesh_box_hit(
a,
p,
@@ -4548,27 +4587,45 @@ impl OpenCADStudio {
bounds,
candidate_handles.as_ref(),
));
let hit_ms = t_hit.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0);
let t_filter = crate::perf::enabled().then(Instant::now);
// Selection filter: keep only allowed types.
handles.retain(|&h| self.tabs[i].scene.passes_selection_filter(h));
let filter_ms =
t_filter.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0);
let t_apply = crate::perf::enabled().then(Instant::now);
// Accumulate (issue #83): a plain box adds to the
// current selection, Shift+box removes the boxed
// entities. An empty box leaves the selection alone
// so an accidental empty drag never discards it.
// PICKADD 0 (#226): a plain box REPLACES instead.
if self.shift_down || self.select_remove_mode {
for h in &handles {
self.tabs[i].scene.deselect_entity(*h);
}
self.tabs[i].scene.deselect_entities(&handles);
} else {
if !selection_pick_add && !handles.is_empty() {
self.tabs[i].scene.deselect_all();
}
for h in &handles {
self.tabs[i].scene.select_entity(*h, false);
}
self.tabs[i].scene.select_entities(&handles);
self.tabs[i].scene.expand_selection_for_groups(&handles);
}
let apply_ms =
t_apply.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0);
let t_props = crate::perf::enabled().then(Instant::now);
self.refresh_properties();
if crate::perf::enabled() {
crate::perf_record!(
"[perf] select-commit kind=window crossing={crossing} \
candidates={cand_ms:.1}ms hit={hit_ms:.1}ms [handles={:.1} wires={:.1} \
hatch={:.1} mesh={:.1}] filter={filter_ms:.1}ms apply={apply_ms:.1}ms \
properties={:.1}ms picked={}",
m_handles.unwrap_or(0.0),
m_wires.unwrap_or(0.0) - m_handles.unwrap_or(0.0),
m_hatch.unwrap_or(0.0) - m_wires.unwrap_or(0.0),
hit_ms - m_hatch.unwrap_or(0.0),
t_props.map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0),
handles.len(),
);
}
let mut sel = self.tabs[i].scene.selection.borrow_mut();
sel.box_last = Some((a, p));
sel.box_last_crossing = crossing;
@@ -4704,15 +4761,17 @@ impl OpenCADStudio {
return self.begin_text_edit(handle);
}
// Double-clicking a block with attributes opens the
// attribute editor (edit its values); a block with
// no attributes opens the block editor (BEDIT) — a
// space tab scoped to the block's own geometry, so
// edits reflect in every instance. (#136, #192, #261)
// attribute editor (edit its values). For blocks without
// attributes, Options > Drawing chooses BEDIT (default)
// or in-place REFEDIT. (#136, #192, #261)
let insert_has_attrs = matches!(
self.tabs[i].scene.document.get_entity(handle),
Some(AcadEntityType::Insert(ins)) if !ins.attributes.is_empty()
);
if insert_has_attrs && self.tabs[i].active_block_edit.is_none() {
if insert_has_attrs
&& self.double_click_block_attedit
&& self.tabs[i].active_block_edit.is_none()
{
return Task::done(Message::AttrEditorOpen(handle));
}
let is_insert = matches!(
@@ -4720,8 +4779,14 @@ impl OpenCADStudio {
Some(AcadEntityType::Insert(_))
);
if is_insert && self.tabs[i].refedit_session.is_none() {
let command = if self.double_click_block_refedit {
"REFEDIT_BEGIN"
} else {
"BEDIT_BEGIN"
};
return Task::done(Message::Command(format!(
"BEDIT_BEGIN:{}",
"{}:{}",
command,
handle.value()
)));
}
@@ -5744,3 +5809,57 @@ impl OpenCADStudio {
Task::none()
}
}
#[cfg(test)]
mod selection_preview_tests {
use super::*;
use crate::app::{HoverDwell, OpenCADStudio, HOVER_DWELL_MS};
/// Drive one settled rollover pick over a line and report what the scene
/// ended up highlighting.
fn rollover_hits(preview: u8) -> bool {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let i = app.active_tab;
app.model_space.selection_preview = preview;
let _ = app.run_command_line("LINE 0,0 10,10");
app.tabs[i].scene.selection.borrow_mut().vp_size = (800.0, 600.0);
let _ = app.run_command_line("ZOOM EXTENTS");
// The pick only runs once the cursor has been still for the dwell
// window, so the arming timestamp is backdated past it.
app.hover_dwell = Some(HoverDwell {
last_move_at: Instant::now()
- std::time::Duration::from_millis(HOVER_DWELL_MS as u64 * 2),
point: iced::Point::new(400.0, 300.0),
tile_size: (800.0, 600.0),
tab: i,
});
let _ = app.on_hover_dwell_tick();
app.tabs[i].scene.hover_highlight.is_some()
}
/// Bit 1 of `SELECTIONPREVIEW` is the rollover that runs with no command
/// active, and the Options card gives it a checkbox. The config comment
/// used to describe the bits the wrong way round, so which bit does what
/// is worth asserting rather than reading.
#[test]
fn the_idle_rollover_follows_bit_one() {
assert!(
rollover_hits(1),
"bit 1 set: the line under the cursor must be highlighted",
);
assert!(
rollover_hits(3),
"both bits set: still highlighted",
);
assert!(
!rollover_hits(0),
"preview off: nothing may be highlighted",
);
assert!(
!rollover_hits(2),
"only the in-command bit: the idle rollover stays off",
);
}
}
+2 -2
View File
@@ -2977,7 +2977,7 @@ pub(super) fn doc_tab_bar<'a>(
let new_btn = button(text("+").size(14))
.on_press(Message::TabNew)
.height(iced::Length::Fixed(28.0))
.padding([5, 10])
.padding([4, 8])
.style(|theme: &Theme, status| {
let palette = theme.palette();
let hovered = matches!(
@@ -3007,7 +3007,7 @@ pub(super) fn doc_tab_bar<'a>(
top: 0.0,
right: 0.0,
bottom: 0.0,
left: 6.0,
left: 2.0,
})
.into(),
);
+8 -1
View File
@@ -201,7 +201,14 @@ impl OpenCADStudio {
self.language,
self.options_tab,
self.cursor_size,
self.pick_box,
crate::ui::window::options::SelectionPrefs {
pick_box: self.pick_box,
pick_add: self.pick_add,
pick_drag_rect: self.pick_drag_rect,
grip_object_limit: self.grip_object_limit,
},
self.double_click_block_refedit,
self.double_click_block_attedit,
self.cursor_type,
self.crosshair_color,
&self.crosshair_color_input,
+5
View File
@@ -1578,6 +1578,11 @@ pub enum CmdResult {
taper_angle: f64,
color: [f32; 4],
},
/// Thicken one or more persistent surface bodies without consuming them.
ThickenEntities {
handles: Vec<Handle>,
distance: f64,
},
/// Resolve a profile, bounded area, or solid face for PRESSPULL.
PresspullPick {
handle: Option<Handle>,
+443 -52
View File
@@ -3,6 +3,7 @@ use acadrust::entities::{
PointCloudData, PointCloudExData, RemoteTextData, SectionObjectData,
};
use acadrust::types::{Handle, Transform, Vector3};
use acadrust::xdata::{ExtendedDataRecord, XDataValue};
use crate::t;
use crate::command::EntityTransform;
@@ -17,6 +18,69 @@ use crate::scene::model::object::{
use crate::scene::model::wire_model::SnapHint;
const NAN: [f64; 3] = [f64::NAN; 3];
const SECTION_SLICE_APP: &str = "IsSlice";
const SECTION_THICKNESS_APP: &str = "ThicknessDepth";
pub(crate) fn section_is_slice(entity: &ExtendedEntity) -> bool {
entity
.common
.extended_data
.get_record(SECTION_SLICE_APP)
.is_some_and(|record| {
record
.values
.iter()
.rev()
.find_map(|value| match value {
XDataValue::Integer16(value) => Some(*value != 0),
XDataValue::Integer32(value) => Some(*value != 0),
_ => None,
})
.unwrap_or(false)
})
}
pub(crate) fn section_slice_depth(entity: &ExtendedEntity) -> Option<f64> {
entity
.common
.extended_data
.get_record(SECTION_THICKNESS_APP)?
.values
.iter()
.find_map(|value| match value {
XDataValue::Real(value) | XDataValue::Distance(value) => Some(*value),
_ => None,
})
.filter(|value| value.is_finite())
.map(f64::abs)
}
pub(crate) fn set_section_slice_metadata(
entity: &mut ExtendedEntity,
enabled: bool,
depth: f64,
) {
if !enabled {
entity.common.extended_data.remove_record(SECTION_SLICE_APP);
entity
.common
.extended_data
.remove_record(SECTION_THICKNESS_APP);
return;
}
let mut slice = ExtendedDataRecord::new(SECTION_SLICE_APP);
slice.values = vec![XDataValue::Integer16(0), XDataValue::Integer16(1)];
entity.common.extended_data.upsert_record(slice);
let mut thickness = ExtendedDataRecord::new(SECTION_THICKNESS_APP);
thickness.values = vec![XDataValue::Real(if depth.is_finite() {
depth.abs()
} else {
0.0
})];
entity.common.extended_data.upsert_record(thickness);
}
fn vector_text(value: Vector3) -> String {
format!("{:.6}, {:.6}, {:.6}", value.x, value.y, value.z)
@@ -46,6 +110,22 @@ fn bool_prop(label: &str, field: &'static str, value: bool) -> Property {
}
}
fn choice_prop(
label: &str,
field: &'static str,
selected: &str,
options: &[&str],
) -> Property {
Property {
label: label.into(),
field,
value: PropValue::Choice {
selected: selected.to_string(),
options: options.iter().map(|option| (*option).to_string()).collect(),
},
}
}
fn push_segment(points: &mut Vec<[f64; 3]>, first: [f64; 3], second: [f64; 3]) {
if !points.is_empty() {
points.push(NAN);
@@ -211,14 +291,73 @@ fn append_arc_aligned_text(points: &mut Vec<[f64; 3]>, data: &ArcAlignedTextData
fn section_lines(data: &SectionObjectData) -> Vec<[f64; 3]> {
let mut points = Vec::new();
let vertical = normalized(data.vertical_direction, Vector3::UNIT_Z);
let offset = |point: &Vector3, distance: f64| {
[
point.x + vertical.x * distance,
point.y + vertical.y * distance,
point.z + vertical.z * distance,
]
};
push_chain(
&mut points,
data.vertices.iter().map(|p| [p.x, p.y, p.z]),
);
push_chain(
&mut points,
data.vertices.iter().map(|point| offset(point, data.top_height)),
);
push_chain(
&mut points,
data.vertices.iter().map(|point| offset(point, -data.bottom_height)),
);
for point in &data.vertices {
push_segment(
&mut points,
offset(point, -data.bottom_height),
offset(point, data.top_height),
);
}
push_chain(
&mut points,
data.back_line_vertices.iter().map(|p| [p.x, p.y, p.z]),
);
if !data.back_line_vertices.is_empty() {
push_chain(
&mut points,
data.back_line_vertices
.iter()
.map(|point| offset(point, data.top_height)),
);
push_chain(
&mut points,
data.back_line_vertices
.iter()
.map(|point| offset(point, -data.bottom_height)),
);
for point in &data.back_line_vertices {
push_segment(
&mut points,
offset(point, -data.bottom_height),
offset(point, data.top_height),
);
}
if let (Some(front), Some(back)) = (
data.vertices.first().zip(data.vertices.last()),
data.back_line_vertices
.first()
.zip(data.back_line_vertices.last()),
) {
for (a, b) in [(front.0, back.0), (front.1, back.1)] {
push_segment(&mut points, offset(a, data.top_height), offset(b, data.top_height));
push_segment(
&mut points,
offset(a, -data.bottom_height),
offset(b, -data.bottom_height),
);
}
}
}
points
}
@@ -483,56 +622,220 @@ fn to_render(entity: &ExtendedEntity, document: &acadrust::CadDocument) -> Optio
})
}
fn section_properties(data: &SectionObjectData) -> Vec<PropSection> {
fn section_properties(entity: &ExtendedEntity, data: &SectionObjectData) -> Vec<PropSection> {
let vertices = data
.vertices
.iter()
.enumerate()
.map(|(index, point)| format!("{}: {}", index + 1, vector_text(*point)))
.map(|point| vector_text(*point))
.collect::<Vec<_>>()
.join("\n");
let back_vertices = data
.back_line_vertices
.join("; ");
let kind = section_kind(entity, data);
let viewing = section_viewing_direction(data);
let offset = section_plane_offset(data);
let depth = section_slice_depth(entity).unwrap_or_else(|| section_depth(data));
vec![PropSection {
title: t!("Section Plane").into_owned(),
props: vec![
text_prop(t!("Name").as_ref(), "ext_section_name", &data.name),
choice_prop(
t!("State").as_ref(),
"ext_section_state",
kind,
&["Plane", "Slice", "Boundary", "Volume"],
),
text_prop(
t!("Viewing Direction").as_ref(),
"ext_section_viewing",
&vector_text(viewing),
),
text_prop(
t!("Vertical Direction").as_ref(),
"ext_section_vertical",
&vector_text(data.vertical_direction),
),
ro_prop(t!("Normal").as_ref(), "ext_section_normal", vector_text(viewing)),
bool_prop(
t!("Live Section Enabled").as_ref(),
"ext_section_live",
data.flags & 1 != 0,
),
edit_prop(
t!("Indicator Transparency").as_ref(),
"ext_section_alpha",
f64::from(data.indicator_alpha),
),
Property {
label: t!("Indicator Fill Color").into_owned(),
field: "indicator_fill_color",
value: PropValue::ColorChoice(data.indicator_color),
},
edit_prop(t!("Elevation").as_ref(), "ext_section_elevation", offset),
edit_prop(t!("Top Height").as_ref(), "ext_section_top", data.top_height),
edit_prop(t!("Bottom Height").as_ref(), "ext_section_bottom", data.bottom_height),
ro_prop(
t!("Number of Vertices").as_ref(),
"ext_section_vertex_count",
data.vertices.len().to_string(),
),
text_prop(t!("Vertices").as_ref(), "ext_section_vertices", &vertices),
ro_prop(
t!("Settings").as_ref(),
"ext_section_settings",
handle_text(data.settings_handle),
),
choice_prop(
t!("State2").as_ref(),
"ext_section_state2",
kind,
&["Plane", "Slice", "Boundary", "Volume"],
),
edit_prop(t!("Slice Depth").as_ref(), "ext_section_depth", depth),
edit_prop(
t!("Section Plane Offset").as_ref(),
"ext_section_offset",
offset,
),
],
}]
}
fn section_tangent(data: &SectionObjectData) -> Vector3 {
let Some((first, last)) = data.vertices.first().zip(data.vertices.last()) else {
return Vector3::UNIT_X;
};
normalized(*last - *first, Vector3::UNIT_X)
}
fn section_viewing_direction(data: &SectionObjectData) -> Vector3 {
let tangent = section_tangent(data);
let vertical = normalized(data.vertical_direction, Vector3::UNIT_Z);
let base = normalized(vertical.cross(&tangent), Vector3::new(0.0, 0.0, -1.0));
if data.flags & 4 != 0 { base } else { -base }
}
fn section_depth(data: &SectionObjectData) -> f64 {
data.vertices
.first()
.zip(data.back_line_vertices.first())
.map_or(0.0, |(front, back)| (*back - *front).length())
}
fn section_kind(entity: &ExtendedEntity, data: &SectionObjectData) -> &'static str {
if section_is_slice(entity) {
return "Slice";
}
match data.state {
1 => "Plane",
4 => "Volume",
2 => "Boundary",
_ => "Plane",
}
}
fn section_plane_offset(data: &SectionObjectData) -> f64 {
let Some(first) = data.vertices.first() else {
return 0.0;
};
-section_viewing_direction(data).dot(first)
}
fn parse_vector(value: &str) -> Option<Vector3> {
let values = value
.split(|character: char| character == ',' || character == ';' || character.is_whitespace())
.filter(|part| !part.is_empty())
.map(str::parse::<f64>)
.collect::<Result<Vec<_>, _>>()
.ok()?;
(values.len() == 3).then(|| Vector3::new(values[0], values[1], values[2]))
}
fn parse_vertices(value: &str) -> Option<Vec<Vector3>> {
let values = value
.split(|character: char| {
character == ',' || character == ';' || character == ':' || character.is_whitespace()
})
.filter(|part| !part.trim().is_empty())
.map(str::parse::<f64>)
.collect::<Result<Vec<_>, _>>()
.ok()?;
if values.len() < 6 || values.len() % 3 != 0 {
return None;
}
let vertices = values
.chunks_exact(3)
.map(|point| Vector3::new(point[0], point[1], point[2]))
.collect::<Vec<_>>();
let valid = vertices
.first()
.zip(vertices.last())
.is_some_and(|(first, last)| (*last - *first).length() > 1e-12);
valid.then_some(vertices)
}
fn set_section_depth(data: &mut SectionObjectData, depth: f64) {
let depth = depth.max(0.0);
if depth <= 1e-12 || data.vertices.is_empty() {
data.back_line_vertices.clear();
return;
}
let viewing = section_viewing_direction(data);
data.back_line_vertices = data
.vertices
.iter()
.enumerate()
.map(|(index, point)| format!("{}: {}", index + 1, vector_text(*point)))
.collect::<Vec<_>>()
.join("\n");
vec![
PropSection {
title: t!("Section Object").into_owned(),
props: vec![
text_prop(t!("Name").as_ref(), "ext_section_name", &data.name),
ro_prop(t!("State").as_ref(), "ext_section_state", data.state.to_string()),
ro_prop(t!("Flags").as_ref(), "ext_section_flags", data.flags.to_string()),
ro_prop(t!("Vertical Direction").as_ref(),
"ext_section_vertical",
vector_text(data.vertical_direction),
),
edit_prop(t!("Top Height").as_ref(), "ext_section_top", data.top_height),
edit_prop(t!("Bottom Height").as_ref(), "ext_section_bottom", data.bottom_height),
ro_prop(t!("Indicator Alpha").as_ref(),
"ext_section_alpha",
data.indicator_alpha.to_string(),
),
ro_prop(t!("Indicator Color").as_ref(),
"ext_section_color",
format!("{:?}", data.indicator_color),
),
ro_prop(t!("Settings").as_ref(),
"ext_section_settings",
handle_text(data.settings_handle),
),
],
},
PropSection {
title: t!("Section Vertices").into_owned(),
props: vec![
ro_prop(t!("Cutting Line").as_ref(), "ext_section_vertices", vertices),
ro_prop(t!("Back Line").as_ref(), "ext_section_back_vertices", back_vertices),
],
},
]
.map(|point| *point + viewing * depth)
.collect();
}
fn set_section_kind(data: &mut SectionObjectData, value: &str) {
let span = data
.vertices
.first()
.zip(data.vertices.last())
.map_or(1.0, |(first, last)| (*last - *first).length().max(1e-4));
match value.trim().to_ascii_uppercase().as_str() {
"SLICE" => {
data.state = 1;
data.back_line_vertices.clear();
}
"BOUNDARY" => {
data.state = 2;
set_section_depth(data, span);
}
"VOLUME" => {
data.state = 4;
set_section_depth(data, span);
}
_ => {
data.state = 1;
data.back_line_vertices.clear();
}
}
}
fn move_section_to_offset(data: &mut SectionObjectData, desired: f64) {
let current = section_plane_offset(data);
let delta = section_viewing_direction(data) * (current - desired);
for point in data.vertices.iter_mut().chain(data.back_line_vertices.iter_mut()) {
*point = *point + delta;
}
}
fn set_viewing_direction(data: &mut SectionObjectData, value: Vector3) {
if value.length() <= 1e-12 || data.vertices.len() < 2 {
return;
}
let desired = normalized(value, section_viewing_direction(data));
let tangent = section_tangent(data);
let vertical = tangent.cross(&desired);
if vertical.length() <= 1e-12 {
return;
}
data.vertical_direction = normalized(vertical, data.vertical_direction);
data.flags |= 4;
let depth = section_depth(data);
if depth > 0.0 {
set_section_depth(data, depth);
}
}
fn arc_text_properties(data: &ArcAlignedTextData) -> Vec<PropSection> {
@@ -892,7 +1195,7 @@ fn properties(entity: &ExtendedEntity) -> Vec<PropSection> {
title: t!("Camera").into_owned(),
props: vec![ro_prop(t!("View").as_ref(), "ext_camera_view", handle_text(*view_handle))],
}],
ExtendedEntityData::SectionObject(data) => section_properties(data),
ExtendedEntityData::SectionObject(data) => section_properties(entity, data),
ExtendedEntityData::ArcAlignedText(data) => arc_text_properties(data),
ExtendedEntityData::RemoteText(data) => remote_text_properties(data),
ExtendedEntityData::GeoPositionMarker(data) => geo_marker_properties(data),
@@ -1039,14 +1342,97 @@ fn set_f64(value: &str, target: &mut f64) {
}
}
fn apply_section_prop(entity: &mut ExtendedEntity, field: &str, value: &str) {
let was_slice = section_is_slice(entity);
let stored_slice_depth = section_slice_depth(entity).unwrap_or(0.0);
let ExtendedEntityData::SectionObject(data) = &mut entity.data else {
return;
};
let mut slice_update = None;
match field {
"ext_section_name" => data.name = value.to_string(),
"ext_section_state" | "ext_section_state2" => {
let kind = value.trim().to_ascii_uppercase();
let span = data
.vertices
.first()
.zip(data.vertices.last())
.map_or(1.0, |(first, last)| (*last - *first).length().max(1e-4));
set_section_kind(data, &kind);
slice_update = Some((kind == "SLICE", (span / 60.0).max(1e-4)));
}
"ext_section_viewing" => {
if let Some(direction) = parse_vector(value) {
set_viewing_direction(data, direction);
}
}
"ext_section_vertical" => {
if let Some(direction) = parse_vector(value) {
let depth = section_depth(data);
let direction = normalized(direction, data.vertical_direction);
if direction.dot(&section_tangent(data)).abs() < 1.0 - 1e-9 {
data.vertical_direction = direction;
if depth > 0.0 {
set_section_depth(data, depth);
}
}
}
}
"ext_section_live" => data.flags ^= 1,
"ext_section_alpha" => {
if let Ok(alpha) = value.trim().parse::<i16>() {
data.indicator_alpha = alpha.clamp(0, 100);
}
}
"ext_section_elevation" | "ext_section_offset" => {
if let Some(offset) = parse_f64(value) {
move_section_to_offset(data, offset);
}
}
"ext_section_top" => {
if let Some(height) = parse_f64(value).filter(|height| *height >= 0.0) {
data.top_height = height;
}
}
"ext_section_bottom" => {
if let Some(height) = parse_f64(value).filter(|height| *height >= 0.0) {
data.bottom_height = height;
}
}
"ext_section_vertices" => {
if let Some(vertices) = parse_vertices(value) {
let depth = section_depth(data);
data.vertices = vertices;
if depth > 0.0 {
set_section_depth(data, depth);
}
}
}
"ext_section_depth" => {
if let Some(depth) = parse_f64(value) {
if was_slice {
slice_update = Some((true, depth));
} else {
set_section_depth(data, depth);
}
}
}
_ => {}
}
if let Some((enabled, depth)) = slice_update {
set_section_slice_metadata(entity, enabled, depth);
} else if was_slice && field != "ext_section_depth" {
set_section_slice_metadata(entity, true, stored_slice_depth);
}
}
fn apply_geom_prop(entity: &mut ExtendedEntity, field: &str, value: &str) {
if matches!(entity.data, ExtendedEntityData::SectionObject(_)) {
apply_section_prop(entity, field, value);
return;
}
match &mut entity.data {
ExtendedEntityData::SectionObject(data) => match field {
"ext_section_name" => data.name = value.to_string(),
"ext_section_top" => set_f64(value, &mut data.top_height),
"ext_section_bottom" => set_f64(value, &mut data.bottom_height),
_ => {}
},
ExtendedEntityData::ArcAlignedText(data) => match field {
"ext_arc_text" => data.text = value.to_string(),
"ext_arc_font" => data.font_name = value.to_string(),
@@ -1398,6 +1784,8 @@ fn transformed_planar_angle(
fn apply_transform(entity: &mut ExtendedEntity, requested: &EntityTransform) {
let transform = entity_transform(requested);
let scale = scalar_scale(requested);
let slice_depth = section_is_slice(entity)
.then(|| section_slice_depth(entity).unwrap_or(0.0) * scale);
match &mut entity.data {
ExtendedEntityData::SectionObject(data) => {
for point in data
@@ -1536,6 +1924,9 @@ fn apply_transform(entity: &mut ExtendedEntity, requested: &EntityTransform) {
}
_ => {}
}
if let Some(depth) = slice_depth {
set_section_slice_metadata(entity, true, depth);
}
}
impl RenderConvertible for ExtendedEntity {
+16
View File
@@ -66,6 +66,11 @@ pub fn ui_name(e: &EntityType) -> &'static str {
EntityType::Light(_) => "Light",
EntityType::SectionSymbol(_) => "Section Symbol",
EntityType::ViewBorder(_) => "View Border",
EntityType::Extended(extended)
if matches!(
extended.data,
acadrust::entities::ExtendedEntityData::SectionObject(_)
) => "Section Plane",
EntityType::Extended(_) => "Extended Entity",
EntityType::Seqend(_) => "Seqend",
EntityType::Unknown(_) => "Unknown",
@@ -79,6 +84,12 @@ pub fn ui_name(e: &EntityType) -> &'static str {
/// (the numeric `DWG_TYPE_<n>` placeholder).
pub fn ui_name_or_class(e: &EntityType) -> String {
if let EntityType::Extended(entity) = e {
if matches!(
entity.data,
acadrust::entities::ExtendedEntityData::SectionObject(_)
) {
return "Section Plane".to_string();
}
return entity.class_name().to_string();
}
if let EntityType::Unknown(u) = e {
@@ -148,6 +159,11 @@ pub fn dxf_name(e: &EntityType) -> &'static str {
EntityType::Light(_) => "LIGHT",
EntityType::SectionSymbol(_) => "SECTIONLINE",
EntityType::ViewBorder(_) => "DRAWINGVIEW",
EntityType::Extended(extended)
if matches!(
extended.data,
acadrust::entities::ExtendedEntityData::SectionObject(_)
) => "SECTIONOBJECT",
_ => "ENTITY",
}
}
+2 -24
View File
@@ -744,18 +744,6 @@ pub fn load_bytes(name: &str, bytes: Vec<u8>) -> Result<CadDocument, String> {
}
}
/// [`load_bytes`] plus the same finalization and corruption handling the
/// canonical path-based open (`load_file_for_open`/`finalize_loaded_outcome`)
/// already runs — for a byte-based open that nonetheless has a real
/// filesystem path behind it (automation's `"open"` op reads the file into
/// memory itself, e.g. to go through an edit lease, but the drawing still
/// lives at `path`). Without this, automation-opened documents kept
/// legacy non-zero block origins un-normalized, left relative raster/material
/// paths unresolved, carried no `source_path`, and skipped the corrupt-entity
/// purge the UI open path always runs — silently behaving differently from a
/// UI open of the exact same file. `load_bytes` itself is unchanged (and
/// still used directly by the byte round-trip tests, which have no real path
/// and don't want this finalization).
#[cfg(not(target_arch = "wasm32"))]
pub fn load_bytes_finalized(path: &Path, bytes: Vec<u8>) -> Result<(CadDocument, usize), String> {
let name = path
@@ -2061,17 +2049,7 @@ pub(crate) fn is_entity_corrupt(e: &EntityType) -> bool {
// rarely use this many — and parser desync produces exactly-100_000-vertex
// junk records.
const MAX_VERTS: usize = 100_000;
// A MINSERT's row/column counts are parsed `u16`s whose unchecked product
// (`Insert::instance_count`) drives a per-instance allocation in
// `render_graph::array_offsets` and a full per-instance render-graph walk
// for every one — a corrupt or adversarial 65535x65535 pair requests over
// four billion instances (tens of GiB) long before any GPU-side chunking
// limit could help. Kept in sync with the identical failsafe clamp in
// `render_graph::array_offsets`, which guards inserts that reach the
// render graph some other way (e.g. built at runtime, not loaded).
const MAX_MINSERT_INSTANCES: usize = 20_000;
match e {
E::Insert(i) => i.instance_count() > MAX_MINSERT_INSTANCES,
E::LwPolyline(p) => {
!finite_unit_normal(&p.normal)
|| p.vertices.len() >= MAX_VERTS
@@ -2451,11 +2429,11 @@ mod corrupt_guard_tests {
// product can reach into the billions) must be rejected before it can
// drive the render graph's per-instance allocation and expansion.
#[test]
fn rejects_pathological_minsert_counts() {
fn preserves_large_minsert_data_while_rendering_is_bounded() {
let mut i = acadrust::entities::Insert::new("BLOCK", Vector3::ZERO);
i.row_count = u16::MAX;
i.column_count = u16::MAX;
assert!(is_entity_corrupt(&EntityType::Insert(i)));
assert!(!is_entity_corrupt(&EntityType::Insert(i)));
}
// An ordinary array insert, well under the budget, is valid source data.
+11 -1
View File
@@ -4120,6 +4120,16 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static
"Pick box size" => Some(("common", "pick-box-size")),
"Controls both the visible selection box and the click aperture." => Some(("common", "controls-both-the-visible-selection-box-and-the-click-aperture")),
"Visual Effect Settings" => Some(("common", "visual-effect-settings")),
"Selection Modes" => Some(("common", "selection-modes")),
"Use Shift to add to selection (PICKADD)" => Some(("common", "use-shift-to-add-to-selection-pickadd")),
"Press and drag draws a rectangle instead of a lasso (PICKDRAG)" => Some(("common", "press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag")),
"Show selection effect (SELECTIONEFFECT)" => Some(("common", "show-selection-effect-selectioneffect")),
"Preview selection when no command is active" => Some(("common", "preview-selection-when-no-command-is-active")),
"Preview selection during a command" => Some(("common", "preview-selection-during-a-command")),
"Highlights the object under the cursor before it is picked (SELECTIONPREVIEW)." => Some(("common", "highlights-the-object-under-the-cursor-before-it-is-picked-selectionpreview")),
"Object limit for grips" => Some(("common", "object-limit-for-grips")),
"Unlimited" => Some(("common", "unlimited")),
"Past this many selected objects no grips are drawn; 0 removes the limit (GRIPOBJLIMIT)." => Some(("common", "past-this-many-selected-objects-no-grips-are-drawn-0-removes-the-limit-gripobjlimit")),
"Indicate selection area with transparent fill (SELECTIONAREA)" => Some(("common", "indicate-selection-area-with-transparent-fill-selectionarea")),
"Selection area opacity" => Some(("common", "selection-area-opacity")),
"Transparency of the window and crossing selection areas (SELECTIONAREAOPACITY)." => Some(("common", "transparency-of-the-window-and-crossing-selection-areas-selectionareaopacity")),
@@ -4406,7 +4416,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static
"LAYERSTATE: no saved states. Use LAYERSTATE SAVE <name>." => Some(("common", "layerstate-no-saved-states-use-layerstate-save-name")),
"Usage: COLOR <ByLayer|ByBlock|1-255|red|yellow|green|cyan|blue|magenta|white>" => Some(("common", "usage-color-bylayer-byblock-1-255-red-yellow-green-cyan-blue-magenta-white")),
"Usage: CHPROP <prop> <val> (props: LAYER COLOR LINETYPE LTSCALE)" => Some(("common", "usage-chprop-prop-val-props-layer-color-linetype-ltscale")),
"SETVAR: 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 | CLAYER CELTYPE TEXTSTYLE (read-only)" => Some(("common", "setvar-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-fram")),
"SETVAR: 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)" => Some(("common", "setvar-ltscale-celtscale-pdmode-pdsize-textsize-orthomode-fillmode-mirrtext-fram")),
"SETVAR: DIMCONTINUEMODE requires 0 or 1." => Some(("common", "setvar-dimcontinuemode-requires-0-or-1")),
"SETVAR: DONUTID requires a finite value greater than or equal to zero." => Some(("common", "setvar-donutid-requires-a-finite-value-greater-than-or-equal-to-zero")),
"SETVAR: DONUTOD requires a finite value greater than zero." => Some(("common", "setvar-donutod-requires-a-finite-value-greater-than-zero")),
+1 -1
View File
@@ -253,7 +253,7 @@ fn donut_wire(
WireModel::solid_f64("rubber_band".into(), points, WireModel::CYAN, false)
}
fn make_donut(cx: f64, cy: f64, elevation: f64, inner_r: f64, outer_r: f64) -> EntityType {
pub(crate) fn make_donut(cx: f64, cy: f64, elevation: f64, inner_r: f64, outer_r: f64) -> EntityType {
use acadrust::types::Vector2;
let r_avg = (inner_r + outer_r) / 2.0;
let width = outer_r - inner_r;
+192
View File
@@ -513,6 +513,172 @@ impl CadCommand for ExtrudeCommand {
}
}
// ── THICKEN command ────────────────────────────────────────────────────────
pub struct ThickenCommand {
step: ThickenStep,
handles: Vec<Handle>,
first_point: Option<DVec3>,
last_distance: f64,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum ThickenStep {
Pick,
Distance,
SecondPoint,
}
fn thicken_default() -> &'static Mutex<f64> {
static DEFAULT: OnceLock<Mutex<f64>> = OnceLock::new();
DEFAULT.get_or_init(|| Mutex::new(0.0))
}
impl ThickenCommand {
pub fn new(preselection: Vec<(Handle, EntityType)>) -> Self {
let handles = preselection
.into_iter()
.filter_map(|(handle, entity)| {
matches!(entity, EntityType::Surface(_)).then_some(handle)
})
.collect::<Vec<_>>();
Self {
step: if handles.is_empty() {
ThickenStep::Pick
} else {
ThickenStep::Distance
},
handles,
first_point: None,
last_distance: *thicken_default()
.lock()
.unwrap_or_else(|error| error.into_inner()),
}
}
fn finish(&mut self, distance: f64) -> CmdResult {
self.last_distance = distance;
*thicken_default()
.lock()
.unwrap_or_else(|error| error.into_inner()) = distance;
CmdResult::ThickenEntities {
handles: self.handles.clone(),
distance,
}
}
}
impl CadCommand for ThickenCommand {
fn name(&self) -> &'static str {
"THICKEN"
}
fn prompt(&self) -> String {
match self.step {
ThickenStep::Pick => t!("Select surfaces to thicken:").into_owned(),
ThickenStep::Distance => format!(
"{} <{}>:",
t!("Specify thickness"),
crate::entities::common::format_length(self.last_distance)
),
ThickenStep::SecondPoint => {
t!("Specify second point:").into_owned()
}
}
}
fn wants_text_input(&self) -> bool {
self.step == ThickenStep::Distance
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
if self.step != ThickenStep::Distance {
return None;
}
let value = text.trim();
if value.is_empty() {
return None;
}
Some(match crate::entities::common::parse_typed_length(value) {
Some(distance) if distance.is_finite() => self.finish(distance),
_ => CmdResult::ReportError(
t!("Requires numeric distance or two points.").into_owned(),
),
})
}
fn on_point(&mut self, point: DVec3) -> CmdResult {
match self.step {
ThickenStep::Distance => {
self.first_point = Some(point);
self.step = ThickenStep::SecondPoint;
CmdResult::NeedPoint
}
ThickenStep::SecondPoint => {
let distance = point.distance(self.first_point.unwrap_or(point));
if distance.is_finite() {
self.finish(distance)
} else {
CmdResult::ReportError(
t!("Requires numeric distance or two points.").into_owned(),
)
}
}
ThickenStep::Pick => CmdResult::NeedPoint,
}
}
fn on_enter(&mut self) -> CmdResult {
match self.step {
ThickenStep::Pick if self.handles.is_empty() => {
CmdResult::Measurement(t!("No surfaces selected.").into_owned())
}
ThickenStep::Pick => {
self.step = ThickenStep::Distance;
CmdResult::NeedPoint
}
ThickenStep::Distance => self.finish(self.last_distance),
ThickenStep::SecondPoint => CmdResult::ReportError(
t!("Requires numeric distance or two points.").into_owned(),
),
}
}
fn is_selection_gathering(&self) -> bool {
self.step == ThickenStep::Pick
}
fn selection_forces_add(&self) -> bool {
self.step == ThickenStep::Pick
}
fn inject_selection_entities(&mut self, entities: Vec<SelectionEntity>) {
if self.step != ThickenStep::Pick {
return;
}
self.handles = entities
.into_iter()
.filter_map(|entry| {
matches!(entry.entity, EntityType::Surface(_)).then_some(entry.handle)
})
.collect();
}
fn on_selection_complete(&mut self, _handles: Vec<Handle>) -> CmdResult {
CmdResult::NeedPoint
}
fn on_undo_step(&mut self) -> Option<CmdResult> {
if self.step == ThickenStep::SecondPoint {
self.first_point = None;
self.step = ThickenStep::Distance;
Some(CmdResult::NeedPoint)
} else {
None
}
}
}
// ── PRESSPULL command ─────────────────────────────────────────────────────
pub struct PresspullCommand {
@@ -2701,3 +2867,29 @@ mod revolve_tests {
}
}
inventory::submit!(crate::command::CommandRegistration { names: &["SWEEP"] });
#[cfg(test)]
mod thicken_command_tests {
use super::*;
#[test]
fn thicken_filters_selection_recovers_from_invalid_text_and_measures_two_points() {
let handle = Handle::new(42);
let mut command = ThickenCommand::new(vec![
(Handle::new(1), EntityType::Line(acadrust::entities::Line::default())),
(handle, EntityType::Surface(acadrust::entities::Surface::new(acadrust::entities::SurfaceKind::Plane))),
]);
assert!(!command.is_selection_gathering());
assert_eq!(command.handles, vec![handle]);
assert!(matches!(command.on_text_input("bad"), Some(CmdResult::ReportError(_))));
assert!(command.input_kind().wants_text());
assert!(matches!(command.on_text_input("-2"), Some(CmdResult::ThickenEntities { distance: -2.0, .. })));
assert!(matches!(command.on_point(DVec3::ZERO), CmdResult::NeedPoint));
assert!(command.on_undo_step().is_some());
let _ = command.on_point(DVec3::ZERO);
assert!(matches!(command.on_point(DVec3::new(3.0, 4.0, 0.0)), CmdResult::ThickenEntities { distance: 5.0, .. }));
let mut empty = ThickenCommand::new(Vec::new());
assert!(empty.is_selection_gathering());
assert!(matches!(empty.on_enter(), CmdResult::Measurement(_)));
}
}
+1
View File
@@ -5,6 +5,7 @@ pub mod cylinder_cmd;
pub mod edge_cmd;
pub mod polysolid_cmd;
pub mod primitive_cmd;
pub mod sectionplane_cmd;
pub mod shell_cmd;
pub mod slice_cmd;
+537
View File
@@ -0,0 +1,537 @@
use acadrust::entities::{EntityCommon, ExtendedEntity, ExtendedEntityData, SectionObjectData};
use acadrust::types::{Color, Handle, Vector3};
use acadrust::EntityType;
use glam::DVec3;
use crate::command::{CadCommand, CmdOption, CmdResult, WorkingPlane};
use crate::scene::model::wire_model::WireModel;
#[derive(Clone, Copy, PartialEq, Eq)]
enum SectionKind {
Plane,
Slice,
Boundary,
Volume,
}
impl SectionKind {
fn label(self) -> &'static str {
match self {
Self::Plane => "Plane",
Self::Slice => "Slice",
Self::Boundary => "Boundary",
Self::Volume => "Volume",
}
}
fn state(self) -> i32 {
match self {
Self::Plane | Self::Slice => 1,
Self::Boundary => 2,
Self::Volume => 4,
}
}
}
#[derive(Clone, Copy)]
enum Orthographic {
Front,
Back,
Top,
Bottom,
Left,
Right,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Step {
Locate,
Through,
DrawStart,
DrawNext,
DrawDirection,
Orthographic,
Type,
}
/// Creates persistent SECTIONOBJECT entities without modifying source solids.
pub struct SectionPlaneCommand {
step: Step,
kind: SectionKind,
first: Option<DVec3>,
draw_points: Vec<DVec3>,
picked_direction: Option<DVec3>,
working: WorkingPlane,
bounds: (DVec3, DVec3),
empty_extents: bool,
next_name: usize,
notice: Option<&'static str>,
}
impl SectionPlaneCommand {
pub fn new(bounds: Option<(DVec3, DVec3)>, next_name: usize) -> Self {
let empty_extents = bounds.is_none();
let bounds = bounds.unwrap_or((DVec3::ZERO, DVec3::splat(4000.0)));
Self {
step: Step::Locate,
kind: SectionKind::Plane,
first: None,
draw_points: Vec::new(),
picked_direction: None,
working: WorkingPlane::default(),
bounds,
empty_extents,
next_name: next_name.max(1),
notice: None,
}
}
fn corners(&self) -> [DVec3; 8] {
let (lo, hi) = self.bounds;
[
DVec3::new(lo.x, lo.y, lo.z),
DVec3::new(hi.x, lo.y, lo.z),
DVec3::new(lo.x, hi.y, lo.z),
DVec3::new(hi.x, hi.y, lo.z),
DVec3::new(lo.x, lo.y, hi.z),
DVec3::new(hi.x, lo.y, hi.z),
DVec3::new(lo.x, hi.y, hi.z),
DVec3::new(hi.x, hi.y, hi.z),
]
}
fn frame_extents(&self, centre: DVec3, tangent: DVec3, vertical: DVec3) -> (f64, f64, f64, f64) {
let mut t_min = f64::INFINITY;
let mut t_max = f64::NEG_INFINITY;
let mut v_min = f64::INFINITY;
let mut v_max = f64::NEG_INFINITY;
for corner in self.corners() {
let delta = corner - centre;
let t = delta.dot(tangent);
let v = delta.dot(vertical);
t_min = t_min.min(t);
t_max = t_max.max(t);
v_min = v_min.min(v);
v_max = v_max.max(v);
}
let t_span = t_max - t_min;
let v_span = v_max - v_min;
let t_margin = if t_span > 1e-12 { t_span * 0.1 } else { 1.0 };
let v_margin = if v_span > 1e-12 {
v_span * if self.empty_extents { 0.1 } else { 0.15 }
} else {
1.0
};
(t_min - t_margin, t_max + t_margin, v_min - v_margin, v_max + v_margin)
}
fn entity(&self, vertices: Vec<DVec3>, vertical: DVec3, positive_view: bool) -> EntityType {
let first = vertices.first().copied().unwrap_or(DVec3::ZERO);
let last = vertices.last().copied().unwrap_or(DVec3::X);
let tangent = (last - first).normalize_or(DVec3::X);
let vertical = (vertical - tangent * vertical.dot(tangent)).normalize_or(DVec3::Z);
let viewing = vertical.cross(tangent).normalize_or(-DVec3::Z)
* if positive_view { 1.0 } else { -1.0 };
let centre = (first + last) * 0.5;
let (_, _, v_min, v_max) = self.frame_extents(centre, tangent, vertical);
let span = (last - first).length().max(1.0);
let depth = match self.kind {
SectionKind::Plane => 0.0,
SectionKind::Slice => (span / 60.0).max(1e-4),
SectionKind::Boundary | SectionKind::Volume => span,
};
let back_line_vertices = if matches!(self.kind, SectionKind::Boundary | SectionKind::Volume) {
vertices.iter().map(|point| *point + viewing * depth).collect()
} else {
Vec::new()
};
let mut entity = ExtendedEntity {
common: EntityCommon::new(),
data: ExtendedEntityData::SectionObject(SectionObjectData {
state: self.kind.state(),
flags: 1 | if positive_view { 4 } else { 0 },
name: format!("Section Plane ({})", self.next_name),
vertical_direction: Vector3::new(vertical.x, vertical.y, vertical.z),
top_height: v_max.max(0.0),
bottom_height: (-v_min).max(0.0),
indicator_alpha: 70,
indicator_color: Color::from_index(9),
vertices: vertices
.into_iter()
.map(|point| Vector3::new(point.x, point.y, point.z))
.collect(),
back_line_vertices: back_line_vertices
.into_iter()
.map(|point| Vector3::new(point.x, point.y, point.z))
.collect(),
settings_handle: Handle::NULL,
}),
};
crate::entities::extended::set_section_slice_metadata(
&mut entity,
self.kind == SectionKind::Slice,
depth,
);
EntityType::Extended(entity)
}
fn line_at(&self, point: DVec3, viewing: DVec3, vertical_hint: DVec3) -> (Vec<DVec3>, DVec3) {
let viewing = viewing.normalize_or(-DVec3::Z);
let vertical_candidate = vertical_hint - viewing * vertical_hint.dot(viewing);
let vertical = if vertical_candidate.length_squared() > 1e-18 {
vertical_candidate.normalize()
} else {
viewing.any_orthonormal_vector()
};
let tangent = viewing.cross(vertical).normalize_or(DVec3::X);
let (t_min, t_max, _, _) = self.frame_extents(point, tangent, vertical);
(vec![point + tangent * t_min, point + tangent * t_max], vertical)
}
fn orthographic(&self, kind: Orthographic) -> EntityType {
let (lo, hi) = self.bounds;
let centre = (lo + hi) * 0.5;
let (viewing, vertical, positive) = match kind {
Orthographic::Front => (DVec3::Y, DVec3::Z, true),
Orthographic::Back => (DVec3::Y, DVec3::Z, false),
Orthographic::Top => (-DVec3::Z, DVec3::Y, true),
Orthographic::Bottom => (-DVec3::Z, DVec3::Y, false),
Orthographic::Left => (-DVec3::X, DVec3::Z, false),
Orthographic::Right => (-DVec3::X, DVec3::Z, true),
};
let (vertices, vertical) = self.line_at(centre, viewing, vertical);
self.entity(vertices, vertical, positive)
}
fn finish_through(&mut self, through: DVec3) -> CmdResult {
let Some(first) = self.first else {
return CmdResult::NeedPoint;
};
let tangent = through - first;
if tangent.length_squared() <= 1e-18 {
self.notice = Some("SECTIONPLANE The through point must differ from the first point:");
return CmdResult::NeedPoint;
}
let tangent = tangent.normalize();
let vertical_hint = if tangent.dot(self.working.z).abs() < 0.98 {
self.working.z
} else {
self.working.y
};
let vertical = (vertical_hint - tangent * vertical_hint.dot(tangent)).normalize();
CmdResult::CommitAndExit(self.entity(vec![first, through], vertical, true))
}
fn finish_draw(&mut self, direction: DVec3) -> CmdResult {
if self.draw_points.len() < 2 {
self.notice = Some("SECTIONPLANE Specify at least two different section points:");
self.step = Step::DrawNext;
return CmdResult::NeedPoint;
}
let first = self.draw_points[0];
let last = *self.draw_points.last().unwrap_or(&first);
let tangent = (last - first).normalize_or(DVec3::X);
let vertical_hint = if tangent.dot(self.working.z).abs() < 0.98 {
self.working.z
} else {
self.working.y
};
let vertical = (vertical_hint - tangent * vertical_hint.dot(tangent)).normalize();
let base_view = vertical.cross(tangent).normalize_or(-DVec3::Z);
let centre = (first + last) * 0.5;
let positive = (direction - centre).dot(base_view) >= 0.0;
let vertices = std::mem::take(&mut self.draw_points);
CmdResult::CommitAndExit(self.entity(vertices, vertical, positive))
}
fn choose_kind(&mut self, kind: SectionKind) -> CmdResult {
self.kind = kind;
self.step = Step::Locate;
self.notice = None;
CmdResult::NeedPoint
}
}
impl CadCommand for SectionPlaneCommand {
fn name(&self) -> &'static str {
"SECTIONPLANE"
}
fn prompt(&self) -> String {
if let Some(notice) = self.notice {
return crate::t!(notice).into_owned();
}
match self.step {
Step::Locate => format!(
"SECTIONPLANE Type = {}\n{}",
self.kind.label(),
crate::t!("Select face or any point to locate section line or [Draw section/Orthographic/Type]:")
),
Step::Through => crate::t!("SECTIONPLANE Specify through point:").into_owned(),
Step::DrawStart => crate::t!("SECTIONPLANE Specify start point:").into_owned(),
Step::DrawNext if self.draw_points.len() == 1 => {
crate::t!("SECTIONPLANE Specify next point:").into_owned()
}
Step::DrawNext => crate::t!("SECTIONPLANE Specify next point or ENTER to complete:").into_owned(),
Step::DrawDirection => crate::t!("SECTIONPLANE Specify point in direction of section view:").into_owned(),
Step::Orthographic => crate::t!("SECTIONPLANE Align section to [Front/bAck/Top/Bottom/Left/Right] <Top>:").into_owned(),
Step::Type => crate::t!("SECTIONPLANE Enter section plane type [Plane/Slice/Boundary/Volume] <Plane>:").into_owned(),
}
}
fn options(&self) -> Vec<CmdOption> {
match self.step {
Step::Locate => vec![
CmdOption::new(crate::t!("Draw section").as_ref(), "D"),
CmdOption::new(crate::t!("Orthographic").as_ref(), "O"),
CmdOption::new(crate::t!("Type").as_ref(), "T"),
],
Step::Orthographic => vec![
CmdOption::new(crate::t!("Front").as_ref(), "F"),
CmdOption::new(crate::t!("Back").as_ref(), "A"),
CmdOption::new(crate::t!("Top").as_ref(), "T"),
CmdOption::new(crate::t!("Bottom").as_ref(), "B"),
CmdOption::new(crate::t!("Left").as_ref(), "L"),
CmdOption::new(crate::t!("Right").as_ref(), "R"),
],
Step::Type => vec![
CmdOption::new(crate::t!("Plane").as_ref(), "P"),
CmdOption::new(crate::t!("Slice").as_ref(), "S"),
CmdOption::new(crate::t!("Boundary").as_ref(), "B"),
CmdOption::new(crate::t!("Volume").as_ref(), "V"),
],
_ => Vec::new(),
}
}
fn set_working_plane(&mut self, plane: WorkingPlane) {
self.working = plane;
}
fn on_point(&mut self, point: DVec3) -> CmdResult {
self.notice = None;
match self.step {
Step::Locate => {
self.first = Some(point);
self.step = Step::Through;
CmdResult::NeedPoint
}
Step::Through => self.finish_through(point),
Step::DrawStart => {
self.draw_points.clear();
self.draw_points.push(point);
self.step = Step::DrawNext;
CmdResult::NeedPoint
}
Step::DrawNext => {
if self.draw_points.last().is_some_and(|last| last.distance(point) <= 1e-9) {
self.notice = Some("SECTIONPLANE Consecutive section points must differ:");
} else {
self.draw_points.push(point);
}
CmdResult::NeedPoint
}
Step::DrawDirection => self.finish_draw(point),
_ => CmdResult::NeedPoint,
}
}
fn on_enter(&mut self) -> CmdResult {
self.notice = None;
match self.step {
Step::DrawNext if self.draw_points.len() >= 2 => {
self.step = Step::DrawDirection;
CmdResult::NeedPoint
}
Step::Orthographic => CmdResult::CommitAndExit(self.orthographic(Orthographic::Top)),
Step::Type => self.choose_kind(SectionKind::Plane),
_ => CmdResult::NeedPoint,
}
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let keyword = text.trim().to_ascii_uppercase();
Some(match self.step {
Step::Locate => match keyword.as_str() {
"D" | "DRAW" | "DRAWSECTION" => {
self.step = Step::DrawStart;
CmdResult::NeedPoint
}
"O" | "ORTHO" | "ORTHOGRAPHIC" => {
self.step = Step::Orthographic;
CmdResult::NeedPoint
}
"T" | "TYPE" => {
self.step = Step::Type;
CmdResult::NeedPoint
}
_ => {
self.notice = Some("SECTIONPLANE Unknown option. Choose Draw section, Orthographic, or Type:");
CmdResult::NeedPoint
}
},
Step::Orthographic => {
let kind = match keyword.as_str() {
"F" | "FRONT" => Some(Orthographic::Front),
"A" | "BACK" => Some(Orthographic::Back),
"T" | "TOP" => Some(Orthographic::Top),
"B" | "BOTTOM" => Some(Orthographic::Bottom),
"L" | "LEFT" => Some(Orthographic::Left),
"R" | "RIGHT" => Some(Orthographic::Right),
_ => None,
};
match kind {
Some(kind) => CmdResult::CommitAndExit(self.orthographic(kind)),
None => {
self.notice = Some("SECTIONPLANE Unknown alignment. Choose Front, Back, Top, Bottom, Left, or Right:");
CmdResult::NeedPoint
}
}
}
Step::Type => match keyword.as_str() {
"P" | "PLANE" => self.choose_kind(SectionKind::Plane),
"S" | "SLICE" => self.choose_kind(SectionKind::Slice),
"B" | "BOUNDARY" => self.choose_kind(SectionKind::Boundary),
"V" | "VOLUME" => self.choose_kind(SectionKind::Volume),
_ => {
self.notice = Some("SECTIONPLANE Unknown type. Choose Plane, Slice, Boundary, or Volume:");
CmdResult::NeedPoint
}
},
_ => CmdResult::NeedPoint,
})
}
fn point_step_accepts_keywords(&self) -> bool {
matches!(self.step, Step::Locate | Step::Orthographic | Step::Type)
}
fn needs_entity_pick(&self) -> bool {
self.step == Step::Locate
}
fn entity_pick_accepts_points(&self) -> bool {
true
}
fn entity_pick_uses_surface_point(&self) -> bool {
true
}
fn entity_pick_highlights_hover(&self) -> bool {
true
}
fn set_entity_pick_direction(&mut self, direction: Option<DVec3>) {
self.picked_direction = direction;
}
fn on_entity_pick(&mut self, handle: Handle, point: DVec3) -> CmdResult {
if handle.is_null() {
self.first = Some(point);
self.step = Step::Through;
return CmdResult::NeedPoint;
}
let Some(face_normal) = self.picked_direction.take().filter(|normal| normal.length_squared() > 1e-18) else {
self.notice = Some("SECTIONPLANE Select a planar face or specify a point:");
return CmdResult::NeedPoint;
};
let viewing = -face_normal.normalize();
let vertical = if viewing.dot(self.working.z).abs() < 0.98 {
self.working.z
} else {
self.working.y
};
let (vertices, vertical) = self.line_at(point, viewing, vertical);
CmdResult::CommitAndExit(self.entity(vertices, vertical, true))
}
fn on_undo_step(&mut self) -> Option<CmdResult> {
if self.step != Step::DrawNext {
return None;
}
self.draw_points.pop();
self.step = if self.draw_points.is_empty() { Step::DrawStart } else { Step::DrawNext };
Some(CmdResult::NeedPoint)
}
fn on_preview_wires(&mut self, point: DVec3) -> Vec<WireModel> {
let mut points = self.draw_points.iter().map(|p| p.to_array()).collect::<Vec<_>>();
match self.step {
Step::Through => {
if let Some(first) = self.first {
points = vec![first.to_array(), point.to_array()];
}
}
Step::DrawNext => points.push(point.to_array()),
Step::DrawDirection if self.draw_points.len() >= 2 => {
let centre = (self.draw_points[0] + *self.draw_points.last().unwrap()) * 0.5;
points.push([f64::NAN; 3]);
points.push(centre.to_array());
points.push(point.to_array());
}
_ => {}
}
if points.len() < 2 {
Vec::new()
} else {
vec![WireModel::solid_f64(
"sectionplane_preview".into(),
points,
WireModel::CYAN,
false,
)]
}
}
}
inventory::submit!(crate::command::CommandRegistration {
names: &["SECTIONPLANE"]
});
#[cfg(test)]
mod tests {
use super::*;
fn data(entity: &EntityType) -> (&ExtendedEntity, &SectionObjectData) {
let EntityType::Extended(entity) = entity else {
panic!("expected extended entity");
};
let ExtendedEntityData::SectionObject(data) = &entity.data else {
panic!("expected section object");
};
(entity, data)
}
#[test]
fn slice_uses_persistent_thickness_without_boundary_geometry() {
let mut command = SectionPlaneCommand::new(
Some((DVec3::ZERO, DVec3::splat(10.0))),
3,
);
command.kind = SectionKind::Slice;
let entity = command.entity(vec![DVec3::ZERO, DVec3::Y * 6.0], DVec3::Z, true);
let (entity, section) = data(&entity);
assert_eq!(section.state, 1);
assert!(section.back_line_vertices.is_empty());
assert!(crate::entities::extended::section_is_slice(entity));
assert!((crate::entities::extended::section_slice_depth(entity).unwrap() - 0.1).abs()
< 1e-12);
}
#[test]
fn boundary_keeps_a_real_back_line_and_no_slice_marker() {
let mut command = SectionPlaneCommand::new(
Some((DVec3::ZERO, DVec3::splat(10.0))),
1,
);
command.kind = SectionKind::Boundary;
let entity = command.entity(vec![DVec3::ZERO, DVec3::Y * 6.0], DVec3::Z, true);
let (entity, section) = data(&entity);
assert_eq!(section.state, 2);
assert_eq!(section.back_line_vertices.len(), section.vertices.len());
assert!(!crate::entities::extended::section_is_slice(entity));
}
}
+8
View File
@@ -75,3 +75,11 @@ pub mod prelude {
impl<I: Iterator> IterParExt for I {}
}
#[cfg(not(target_arch = "wasm32"))]
pub use rayon::join;
#[cfg(target_arch = "wasm32")]
pub fn join<A, B>(a: impl FnOnce() -> A, b: impl FnOnce() -> B) -> (A, B) {
(a(), b())
}
+46 -17
View File
@@ -11,7 +11,7 @@
use acadrust::entities::{EntityCommon, EntityType};
use acadrust::objects::{
Dictionary, DimContext, DimSubtype, EmbeddedMTextContext, HatchScaleContext,
Dictionary, DimContext, DimSubtype, EmbeddedMTextContext, HatchLoopContext, HatchScaleContext,
MTextAttributeContext, MTextContext, ObjectContextData, ObjectContextKind, ObjectType,
};
use acadrust::types::{Vector2, Vector3};
@@ -289,12 +289,15 @@ fn context_kind_for(
pattern_lines: hatch.pattern.lines.clone(),
pattern_scale: hatch.pattern_scale,
pattern_base: Vector3::ZERO,
loop_types: hatch
loops: hatch
.paths
.iter()
.map(|path| path.flags.bits() as i32)
.map(|path| HatchLoopContext {
loop_type: path.flags.bits() as i32,
supports_context: true,
boundary: None,
})
.collect(),
supports_context: true,
}),
)),
_ => None,
@@ -891,9 +894,43 @@ fn apply_hatch_context(hatch: &mut acadrust::entities::Hatch, context: &HatchSca
line.base_point.x += context.pattern_base.x;
line.base_point.y += context.pattern_base.y;
}
for (path, bits) in hatch.paths.iter_mut().zip(&context.loop_types) {
path.flags = acadrust::entities::BoundaryPathFlags::from_bits(*bits as u32);
}
let base_paths = std::mem::take(&mut hatch.paths);
hatch.paths = context
.loops
.iter()
.enumerate()
.map(|(index, loop_data)| {
let flags =
acadrust::entities::BoundaryPathFlags::from_bits(loop_data.loop_type as u32);
let mut path = if loop_data.supports_context {
base_paths.get(index).cloned()
} else {
loop_data.boundary.clone()
}
.unwrap_or_else(|| acadrust::entities::BoundaryPath::with_flags(flags));
path.flags = flags;
path
})
.collect();
}
fn sync_hatch_loops(loops: &mut Vec<HatchLoopContext>, paths: &[acadrust::entities::BoundaryPath]) {
let previous = std::mem::take(loops);
*loops = paths
.iter()
.enumerate()
.map(|(index, path)| {
let supports_context = previous
.get(index)
.map(|loop_data| loop_data.supports_context)
.unwrap_or(true);
HatchLoopContext {
loop_type: path.flags.bits() as i32,
supports_context,
boundary: (!supports_context).then(|| path.clone()),
}
})
.collect();
}
pub fn entity_for_annotation_context<'a>(
@@ -1235,11 +1272,7 @@ pub fn sync_annotation_context_from_entity(
line.base_point.y -= context.pattern_base.y;
}
context.pattern_scale = hatch.pattern_scale;
context.loop_types = hatch
.paths
.iter()
.map(|path| path.flags.bits() as i32)
.collect();
sync_hatch_loops(&mut context.loops, &hatch.paths);
}
(EntityType::Hatch(hatch), ObjectContextKind::HatchView(context)) => {
context.hatch.pattern_lines.clone_from(&hatch.pattern.lines);
@@ -1248,11 +1281,7 @@ pub fn sync_annotation_context_from_entity(
line.base_point.y -= context.hatch.pattern_base.y;
}
context.hatch.pattern_scale = hatch.pattern_scale;
context.hatch.loop_types = hatch
.paths
.iter()
.map(|path| path.flags.bits() as i32)
.collect();
sync_hatch_loops(&mut context.hatch.loops, &hatch.paths);
context.view_normal = hatch.normal;
}
_ => {}
+38 -15
View File
@@ -9,26 +9,37 @@ fn rendered_wire_bounds(
) -> Option<(glam::DVec3, glam::DVec3)> {
let mut min = glam::DVec3::splat(f64::INFINITY);
let mut max = glam::DVec3::splat(f64::NEG_INFINITY);
let mut include = |point: glam::DVec3| {
if point.is_finite() {
min = min.min(point);
max = max.max(point);
}
};
macro_rules! include_bound {
($p:expr) => {
let point: glam::DVec3 = $p;
if point.is_finite() {
min = min.min(point);
max = max.max(point);
}
};
}
for wire in wires {
let mut has_precise_position = false;
let hw = wire.world_width as f64 * 0.5;
for (index, &[x, y, z]) in wire.points.iter().enumerate() {
let low = wire.points_low.get(index).copied().unwrap_or([0.0; 3]);
include(glam::DVec3::new(
let p = glam::DVec3::new(
x as f64 + low[0] as f64,
y as f64 + low[1] as f64,
z as f64 + low[2] as f64,
));
);
if hw > 0.0 {
include_bound!(p - glam::DVec3::splat(hw));
include_bound!(p + glam::DVec3::splat(hw));
} else {
include_bound!(p);
}
has_precise_position = true;
}
for vertex in &wire.text_verts {
include(glam::DVec3::new(
include_bound!(glam::DVec3::new(
vertex.pos[0] as f64 + vertex.pos_low[0] as f64,
vertex.pos[1] as f64 + vertex.pos_low[1] as f64,
vertex.pos[2] as f64 + vertex.pos_low[2] as f64,
@@ -36,11 +47,17 @@ fn rendered_wire_bounds(
has_precise_position = true;
}
for &[x, y, z] in &wire.key_vertices {
include(glam::DVec3::new(x, y, z));
let p = glam::DVec3::new(x, y, z);
if hw > 0.0 {
include_bound!(p - glam::DVec3::splat(hw));
include_bound!(p + glam::DVec3::splat(hw));
} else {
include_bound!(p);
}
has_precise_position = true;
}
for &(point, _) in &wire.snap_pts {
include(point);
include_bound!(point);
has_precise_position = true;
}
@@ -51,8 +68,8 @@ fn rendered_wire_bounds(
&& x0 <= x1
&& y0 <= y1
{
include(glam::DVec3::new(x0 as f64, y0 as f64, fallback_z));
include(glam::DVec3::new(x1 as f64, y1 as f64, fallback_z));
include_bound!(glam::DVec3::new(x0 as f64 - hw, y0 as f64 - hw, fallback_z - hw));
include_bound!(glam::DVec3::new(x1 as f64 + hw, y1 as f64 + hw, fallback_z + hw));
}
}
}
@@ -1455,12 +1472,18 @@ impl Scene {
continue;
}
let wire = &wires[c.idx];
let hw = (wire.world_width * 0.5) as f32;
for &[x, y, z] in &wire.points {
if !x.is_finite() || !y.is_finite() || !z.is_finite() {
continue;
}
min = min.min(glam::Vec3::new(x, y, z));
max = max.max(glam::Vec3::new(x, y, z));
if hw > 0.0 {
min = min.min(glam::Vec3::new(x - hw, y - hw, z - hw));
max = max.max(glam::Vec3::new(x + hw, y + hw, z + hw));
} else {
min = min.min(glam::Vec3::new(x, y, z));
max = max.max(glam::Vec3::new(x, y, z));
}
}
}
// Fold in 3D-solid mesh AABBs (not subject to the wire IQR reject).
+1 -1
View File
@@ -167,7 +167,7 @@ pub fn kernel_surface_body(surface: &Surface) -> Option<KernelBody> {
kernel_acis_body(&surface.acis_data)
}
fn kernel_acis_body(acis: &acadrust::entities::AcisData) -> Option<KernelBody> {
pub(crate) fn kernel_acis_body(acis: &acadrust::entities::AcisData) -> Option<KernelBody> {
let sat = parse_acis(
|| acis.parse(),
acis.is_binary,
+159 -31
View File
@@ -202,6 +202,66 @@ pub(crate) fn points_to_ds(
(high, low)
}
fn polyline_segment_widths(entity: &EntityType) -> Vec<(f32, f32)> {
match entity {
EntityType::LwPolyline(p) => {
let count = p.vertices.len();
let seg_count = if p.is_closed {
count
} else {
count.saturating_sub(1)
};
let c = p.constant_width;
(0..seg_count)
.map(|i| {
let v = &p.vertices[i];
let sw = if v.start_width > 1e-9 {
v.start_width
} else {
c
} as f32;
let ew = if v.end_width > 1e-9 {
v.end_width
} else {
c
} as f32;
(sw, ew)
})
.collect()
}
EntityType::Polyline2D(p) => {
let filtered = crate::entities::polyline::drawn_vertices2d(p);
let verts: &[acadrust::entities::Vertex2D] =
filtered.as_deref().unwrap_or(&p.vertices);
let count = verts.len();
let seg_count = if p.is_closed() {
count
} else {
count.saturating_sub(1)
};
let def_start = p.start_width;
let def_end = p.end_width;
(0..seg_count)
.map(|i| {
let v = &verts[i];
let sw = if v.start_width > 1e-9 {
v.start_width
} else {
def_start
} as f32;
let ew = if v.end_width > 1e-9 {
v.end_width
} else {
def_end
} as f32;
(sw, ew)
})
.collect()
}
_ => Vec::new(),
}
}
fn split_mixed_polyline(
tangent_geoms: &[TangentGeom],
key_vertices: &[[f64; 3]],
@@ -214,11 +274,15 @@ fn split_mixed_polyline(
snap_pts: Vec<(glam::DVec3, SnapHint)>,
point_marker: Option<crate::scene::model::wire_model::PointMarker>,
plinegen: bool,
seg_widths: &[(f32, f32)],
global_world_width: f32,
mut pick_tris: Vec<[f32; 3]>,
mut pick_tris_low: Vec<[f32; 3]>,
) -> Vec<WireModel> {
let mut out = Vec::new();
// Emit each arc segment as an analytical wire (CircleGpu target)
for tg in tangent_geoms {
for (i, tg) in tangent_geoms.iter().enumerate() {
if let TangentGeom::Arc {
center,
axis_x,
@@ -246,20 +310,48 @@ fn split_mixed_polyline(
arc_pts.push(p);
}
let (points, points_low) = points_to_ds(arc_pts);
let (sw, ew) = if let Some(&(w0, w1)) = seg_widths.get(i) {
if w0 > 1e-9 || w1 > 1e-9 {
(w0, w1)
} else if global_world_width > 1e-9 {
(global_world_width, global_world_width)
} else {
(0.0, 0.0)
}
} else if global_world_width > 1e-9 {
(global_world_width, global_world_width)
} else {
(0.0, 0.0)
};
let taper_widths = if (sw - ew).abs() > 1e-6 {
vec![sw, ew]
} else {
Vec::new()
};
let world_width = sw.max(ew);
let (arc_pt, arc_ptl) = if out.is_empty() && !pick_tris.is_empty() {
(std::mem::take(&mut pick_tris), std::mem::take(&mut pick_tris_low))
} else {
(Vec::new(), Vec::new())
};
out.push(WireModel {
bg_adapt: None,
point_marker: None,
taper_widths: Vec::new(),
taper_widths,
pattern_stations: Vec::new(),
world_width: 0.0,
world_width,
depth_override: None,
display_visible: true,
plot_visible: true,
fill_is_3d: false,
fill_is_2d_solid: false,
render_instance: None,
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
pick_tris: arc_pt,
pick_tris_low: arc_ptl,
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
@@ -330,20 +422,25 @@ fn split_mixed_polyline(
let (line_pts, line_pts_low) = points_to_ds(straight_pts);
if !line_pts.is_empty() {
let (pt, ptl) = if !pick_tris.is_empty() {
(pick_tris, pick_tris_low)
} else {
(Vec::new(), Vec::new())
};
out.push(WireModel {
bg_adapt: None,
point_marker,
taper_widths: Vec::new(),
pattern_stations: Vec::new(),
world_width: 0.0,
world_width: global_world_width,
depth_override: None,
display_visible: true,
plot_visible: true,
fill_is_3d: false,
fill_is_2d_solid: false,
render_instance: None,
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
pick_tris: pt,
pick_tris_low: ptl,
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
@@ -367,6 +464,10 @@ fn split_mixed_polyline(
} else if let Some(first_arc) = out.first_mut() {
first_arc.snap_pts = snap_pts;
first_arc.key_vertices = key_vertices.to_vec();
if first_arc.pick_tris.is_empty() && !pick_tris.is_empty() {
first_arc.pick_tris = pick_tris;
first_arc.pick_tris_low = pick_tris_low;
}
}
out
@@ -1744,17 +1845,13 @@ pub fn tessellate(
.tangent_geoms
.iter()
.any(|tg| matches!(tg, TangentGeom::Arc { .. }));
let has_line = te
.tangent_geoms
.iter()
.any(|tg| matches!(tg, TangentGeom::Line { .. }));
let is_thin_polyline = !is_thick_extrusion
&& polyline_band_width(entity, document.header.fill_mode) <= 1e-9
let can_split = !is_thick_extrusion
&& fill_tris.is_empty()
&& pick_tris.is_empty()
&& matches!(entity, EntityType::LwPolyline(_) | EntityType::Polyline2D(_));
if has_arc && has_line && is_thin_polyline {
if has_arc && can_split {
let seg_widths = polyline_segment_widths(entity);
let ww = polyline_band_width(entity, document.header.fill_mode);
out.extend(split_mixed_polyline(
&te.tangent_geoms,
&keys,
@@ -1767,6 +1864,10 @@ pub fn tessellate(
snap,
point_marker,
true,
&seg_widths,
ww,
pick_tris,
pick_tris_low,
));
} else {
out.push(WireModel {
@@ -1966,20 +2067,14 @@ pub fn tessellate(
entity,
EntityType::Polyline2D(p) if p.thickness.abs() > 1e-10
);
let is_thin_polyline = !is_thick_extrusion
&& polyline_band_width(entity, document.header.fill_mode) <= 1e-9
&& pick_tris.is_empty()
let can_split = !is_thick_extrusion
&& matches!(entity, EntityType::LwPolyline(_) | EntityType::Polyline2D(_));
let has_arc = te
.tangent_geoms
.iter()
.any(|tg| matches!(tg, TangentGeom::Arc { .. }));
let has_line = te
.tangent_geoms
.iter()
.any(|tg| matches!(tg, TangentGeom::Line { .. }));
if has_arc && has_line && is_thin_polyline {
if has_arc && can_split {
let edge_color = if is_thick_extrusion {
[0.0, 0.0, 0.0, 1.0]
} else {
@@ -1987,6 +2082,8 @@ pub fn tessellate(
};
let point_marker =
crate::entities::point::relative_marker_spec(entity, document);
let seg_widths = polyline_segment_widths(entity);
let ww = polyline_band_width(entity, document.header.fill_mode);
return split_mixed_polyline(
&te.tangent_geoms,
&key_vertices,
@@ -1999,6 +2096,10 @@ pub fn tessellate(
snap_pts,
point_marker,
false,
&seg_widths,
ww,
pick_tris,
pick_tris_low,
);
}
return vec![WireModel {
@@ -2038,6 +2139,40 @@ pub fn tessellate(
}
RenderObject::TaperedLines(points, widths) => {
let has_arc = te
.tangent_geoms
.iter()
.any(|tg| matches!(tg, TangentGeom::Arc { .. }));
let can_split = matches!(entity, EntityType::LwPolyline(_) | EntityType::Polyline2D(_));
let (pick_tris, pick_tris_low) = points_to_ds(te.pick_tris);
let world_width = widths.iter().copied().fold(0.0f32, f32::max);
let key_vertices: Vec<[f64; 3]> = te
.key_vertices
.into_iter()
.map(|[x, y, z]| [x, y, z])
.collect();
if has_arc && can_split {
let seg_widths = polyline_segment_widths(entity);
return split_mixed_polyline(
&te.tangent_geoms,
&key_vertices,
&name,
color,
selected,
pattern_length,
pattern,
line_weight_px,
te.snap_pts,
None,
true,
&seg_widths,
world_width,
pick_tris,
pick_tris_low,
);
}
// A wide polyline whose width varies: one continuous band wire
// carrying a per-point width; the shader interpolates each
// segment's two endpoint widths. `world_width` (the widest edge)
@@ -2045,13 +2180,6 @@ pub fn tessellate(
// floor when zoomed out.
let (local_pts, local_pts_low) = points_to_ds(points);
let snap_pts = te.snap_pts;
let key_vertices: Vec<[f64; 3]> = te
.key_vertices
.into_iter()
.map(|[x, y, z]| [x, y, z])
.collect();
let (pick_tris, pick_tris_low) = points_to_ds(te.pick_tris);
let world_width = widths.iter().copied().fold(0.0f32, f32::max);
return vec![WireModel {
bg_adapt: None,
point_marker: None,
+115 -1
View File
@@ -143,6 +143,106 @@ impl Scene {
let _ = self.document.app_ids.add(app_id);
}
fn prepare_section_objects(&mut self, entity: &mut EntityType) -> bool {
use acadrust::entities::ExtendedEntityData;
use acadrust::objects::{
ClassObject, ClassObjectData, SectionManager, SectionSettings,
SectionTypeSettings,
};
let EntityType::Extended(extended) = entity else {
return false;
};
let ExtendedEntityData::SectionObject(data) = &mut extended.data else {
return false;
};
if extended.common.handle.is_null() {
extended.common.handle = self.document.allocate_handle();
}
let entity_handle = extended.common.handle;
let valid_settings = matches!(
self.document.objects.get(&data.settings_handle),
Some(ObjectType::ClassObject(object))
if matches!(&object.data, ClassObjectData::SectionSettings(_))
);
if !valid_settings {
let settings_handle = self.document.allocate_handle();
let mut settings = ClassObject::new(ClassObjectData::SectionSettings(
SectionSettings {
current_type: 4,
types: vec![SectionTypeSettings {
section_type: 4,
generation: 17,
..SectionTypeSettings::default()
}],
},
));
settings.handle = settings_handle;
settings.owner = entity_handle;
self.document
.objects
.insert(settings_handle, ObjectType::ClassObject(settings));
data.settings_handle = settings_handle;
} else if let Some(ObjectType::ClassObject(settings)) =
self.document.objects.get_mut(&data.settings_handle)
{
settings.owner = entity_handle;
}
let root = crate::scene::annotative::root_named_dict_handle(&mut self.document);
let manager_handle = self
.document
.objects
.get(&root)
.and_then(|object| match object {
ObjectType::Dictionary(dictionary) => dictionary
.entries
.iter()
.find(|(name, _)| name.eq_ignore_ascii_case("ACAD_SECTION_MANAGER"))
.map(|(_, handle)| *handle),
_ => None,
})
.filter(|handle| {
matches!(
self.document.objects.get(handle),
Some(ObjectType::ClassObject(object))
if matches!(&object.data, ClassObjectData::SectionManager(_))
)
})
.unwrap_or_else(|| {
let handle = self.document.allocate_handle();
let mut manager =
ClassObject::new(ClassObjectData::SectionManager(SectionManager::default()));
manager.handle = handle;
manager.owner = root;
self.document
.objects
.insert(handle, ObjectType::ClassObject(manager));
if let Some(ObjectType::Dictionary(dictionary)) =
self.document.objects.get_mut(&root)
{
dictionary.entries.retain(|(name, _)| {
!name.eq_ignore_ascii_case("ACAD_SECTION_MANAGER")
});
dictionary.add_entry("ACAD_SECTION_MANAGER", handle);
}
handle
});
if let Some(ObjectType::ClassObject(manager)) =
self.document.objects.get_mut(&manager_handle)
{
manager.owner = root;
if let ClassObjectData::SectionManager(manager) = &mut manager.data {
if !manager.sections.contains(&entity_handle) {
manager.sections.push(entity_handle);
}
}
}
true
}
pub fn add_entity(&mut self, entity: EntityType) -> Handle {
self.add_entity_internal(entity, true)
}
@@ -195,6 +295,7 @@ impl Scene {
// bookkeeping and remains in place while an entity delta is undone.
let mutates_block_structure =
matches!(&entity, EntityType::Block(_) | EntityType::BlockEnd(_));
let creates_section_structure = self.prepare_section_objects(&mut entity);
let hatch_seed = if let EntityType::Hatch(dxf) = &entity {
let color = self.render_style(&entity).0;
Self::hatch_model_from_dxf(dxf, color)
@@ -298,6 +399,9 @@ impl Scene {
};
if !handle.is_null() {
if let Some(keep) = self.refedit_keep.as_mut() {
keep.insert(handle);
}
self.invalidate_dependency_index();
if let Some(model) = hatch_seed {
self.hatches.insert(handle, model);
@@ -334,7 +438,11 @@ impl Scene {
// state (a new layer, application ID, or block).
if self.is_recording_undo() {
self.record_undo_before(handle, None);
if creates_layer || creates_app_id || mutates_block_structure {
if creates_layer
|| creates_app_id
|| mutates_block_structure
|| creates_section_structure
{
self.poison_undo_recording();
}
}
@@ -2583,6 +2691,12 @@ impl Scene {
self.solid_models = HashMap::default();
*self.camera.borrow_mut() = Camera::default();
self.camera_generation += 1;
// A brand-new/replaced document has none of these yet — without
// resetting them, a "new"/"open" (e.g. via automation, which reuses
// this same reset) would leak the previous document's constraints
// and named parameters into the fresh one.
self.sketch_constraints.clear();
self.named_parameters = crate::scene::named_parameters::ParameterTable::new();
self.bump_geometry();
}
}
+52 -15
View File
@@ -183,21 +183,14 @@ impl Scene {
handles: &[Handle],
) -> HashSet<Handle> {
let mut expanded: HashSet<Handle> = handles.iter().copied().collect();
expanded.extend(
self.document
.objects
.values()
.filter_map(|obj| match obj {
ObjectType::Group(g)
if g.selectable
&& handles.iter().any(|handle| g.contains(*handle)) =>
{
Some(g.entities.clone())
}
_ => None,
})
.flatten(),
);
let wanted: HashSet<Handle> = handles.iter().copied().collect();
for obj in self.document.objects.values() {
if let ObjectType::Group(g) = obj {
if g.selectable && g.entities.iter().any(|e| wanted.contains(e)) {
expanded.extend(g.entities.iter().copied());
}
}
}
expanded
}
@@ -211,3 +204,47 @@ impl Scene {
}
}
}
#[cfg(test)]
mod group_expansion_tests {
use super::*;
use crate::scene::Scene;
#[test]
fn group_expansion_pulls_in_siblings_and_nothing_else() {
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) = (line(0.0), line(1.0), line(2.0));
let (d, e) = (line(3.0), line(4.0));
let lonely = line(9.0);
scene.create_group("GRUPPO".to_string(), vec![a, b, c]);
scene.create_group("ALTRO".to_string(), vec![d, e]);
// One member pulls in its siblings, and only its own group's.
let from_a = scene.handles_expanded_for_selectable_groups(&[a]);
assert_eq!(
from_a,
[a, b, c].into_iter().collect::<HashSet<_>>(),
"selecting a member must select that group and no other",
);
// An entity in no group expands to itself.
assert_eq!(
scene.handles_expanded_for_selectable_groups(&[lonely]),
[lonely].into_iter().collect::<HashSet<_>>(),
);
// Touching both groups pulls in both, and nothing outside them.
let both = scene.handles_expanded_for_selectable_groups(&[a, e]);
assert_eq!(both, [a, b, c, d, e].into_iter().collect::<HashSet<_>>());
assert!(!both.contains(&lonely));
}
}
+636 -13
View File
@@ -1901,6 +1901,7 @@ pub struct Scene {
entity_block_map_cache: RefCell<Option<(u64, HashMap<Handle, Handle>)>>,
/// Candidate handles per block, in document order and keyed by geometry epoch.
block_members_cache: RefCell<Option<BlockMembers>>,
leaders_by_annotation_cache: RefCell<Option<(u64, HashMap<Handle, Vec<Handle>>)>>,
/// Insert/Viewport/Block/BlockEnd handles omitted by the spatial index.
unindexable_cache: RefCell<Option<(u64, Vec<Handle>)>>,
/// `(epoch, layout block, present type names, list exposed to the UI)`.
@@ -2069,6 +2070,90 @@ pub struct Scene {
undo_recording: Option<UndoRecording>,
}
fn section_frame(
data: &acadrust::entities::SectionObjectData,
) -> Option<(glam::DVec3, glam::DVec3, glam::DVec3, glam::DVec3)> {
let first = data.vertices.first()?;
let last = data.vertices.last()?;
let origin = glam::DVec3::new(first.x, first.y, first.z);
let tangent = (glam::DVec3::new(last.x, last.y, last.z) - origin).try_normalize()?;
let raw_vertical = glam::DVec3::new(
data.vertical_direction.x,
data.vertical_direction.y,
data.vertical_direction.z,
);
let vertical = (raw_vertical - tangent * raw_vertical.dot(tangent)).try_normalize()?;
let base_view = vertical.cross(tangent).try_normalize()?;
let viewing = if data.flags & 4 != 0 { base_view } else { -base_view };
Some((origin, tangent, vertical, viewing))
}
#[derive(Clone)]
struct LiveSection {
data: acadrust::entities::SectionObjectData,
slice_depth: Option<f64>,
}
fn section_plane(
origin: glam::DVec3,
tangent: glam::DVec3,
normal: glam::DVec3,
body_to_world: Option<&acadrust::types::Transform>,
) -> Option<cadkernel::space::Plane> {
let Some(transform) = body_to_world else {
return cadkernel::space::Plane::orthonormal(
origin.to_array(),
tangent.to_array(),
normal.to_array(),
);
};
let matrix = transform.matrix.m;
let linear = acadrust::types::Matrix3::from_rows(
[matrix[0][0], matrix[0][1], matrix[0][2]],
[matrix[1][0], matrix[1][1], matrix[1][2]],
[matrix[2][0], matrix[2][1], matrix[2][2]],
);
let inverse = linear.inverse()?;
let translation = acadrust::types::Vector3::new(
matrix[0][3],
matrix[1][3],
matrix[2][3],
);
let origin = inverse.transform_point(
acadrust::types::Vector3::new(origin.x, origin.y, origin.z) - translation,
);
let tangent = inverse.transform_point(acadrust::types::Vector3::new(
tangent.x, tangent.y, tangent.z,
));
let normal = linear
.transpose()
.transform_point(acadrust::types::Vector3::new(normal.x, normal.y, normal.z));
cadkernel::space::Plane::orthonormal(
[origin.x, origin.y, origin.z],
[tangent.x, tangent.y, tangent.z],
[normal.x, normal.y, normal.z],
)
}
fn keep_section_positive(
body: &cadkernel::brep::Body,
plane: cadkernel::space::Plane,
) -> Result<Option<cadkernel::brep::Body>, ()> {
match cadkernel::brep::slice_by_plane(body, plane) {
Ok(Some(result)) => Ok(Some(result.positive)),
Ok(None) => {
let bounds = cadkernel::brep::body_bounds(body).ok_or(())?;
let centre = [
(bounds.min[0] + bounds.max[0]) * 0.5,
(bounds.min[1] + bounds.max[1]) * 0.5,
(bounds.min[2] + bounds.max[2]) * 0.5,
];
Ok((plane.distance_to(centre).ok_or(())? >= 0.0).then(|| body.clone()))
}
Err(_) => Err(()),
}
}
impl Scene {
pub fn new() -> Self {
Self {
@@ -2176,6 +2261,7 @@ impl Scene {
model_extents_cache: RefCell::new(None),
entity_block_map_cache: RefCell::new(None),
block_members_cache: RefCell::new(None),
leaders_by_annotation_cache: RefCell::new(None),
unindexable_cache: RefCell::new(None),
layout_type_names_cache: RefCell::new(None),
dependency_index_cache: RefCell::new(None),
@@ -2481,7 +2567,17 @@ impl Scene {
bits |= CACHE_CATEGORY_IMAGE;
}
let is_insert = matches!(entity, EntityType::Insert(_));
if self.meshes.contains_key(&handle) || self.block_meshes.contains_key(&handle) || is_insert
let is_section = matches!(
entity,
EntityType::Extended(acadrust::entities::ExtendedEntity {
data: acadrust::entities::ExtendedEntityData::SectionObject(_),
..
})
);
if self.meshes.contains_key(&handle)
|| self.block_meshes.contains_key(&handle)
|| is_insert
|| is_section
{
bits |= CACHE_CATEGORY_MESH | CACHE_CATEGORY_INTERACTION;
}
@@ -6671,6 +6767,12 @@ impl Scene {
|| matches!(
self.document.get_entity(h),
Some(EntityType::Insert(_))
| Some(EntityType::Extended(
acadrust::entities::ExtendedEntity {
data: acadrust::entities::ExtendedEntityData::SectionObject(_),
..
}
))
)
},
) =>
@@ -6857,6 +6959,7 @@ impl Scene {
all_visible: bool,
viewport: Option<Handle>,
) -> Vec<MeshLodSet> {
let live_section = self.active_live_section(target_block);
// Top-level solids: drop those whose layer is off/frozen or that are
// flagged invisible / isolated-hidden, mirroring the 2D wire path, plus
// any whose layer is frozen in the requesting viewport.
@@ -6878,8 +6981,19 @@ impl Scene {
})
.unwrap_or(false)
})
.map(|(&handle, set)| {
let mut set = set.clone();
.filter_map(|(&handle, set)| {
let mut set = if let Some(section) = live_section.as_ref() {
match self.sectioned_body(handle, section) {
Ok(Some(body)) => self
.prepare_solid_model_display(handle, &body)
.map(|display| display.0)
.unwrap_or_else(|| set.clone()),
Ok(None) => return None,
Err(()) => set.clone(),
}
} else {
set.clone()
};
if let Some(entity) = self.document.get_entity(handle) {
let style = crate::scene::view::render::render_style_for_viewport(
&self.document,
@@ -6903,7 +7017,7 @@ impl Scene {
material.diffuse[3] = style.0[3];
}
}
set
Some(set)
})
.collect();
// Block-definition solids are instanced per INSERT of the ACTIVE space's
@@ -6916,10 +7030,179 @@ impl Scene {
annotation_scale_handle,
all_visible,
viewport,
live_section.as_ref(),
));
all
}
fn active_live_section(
&self,
target_block: Handle,
) -> Option<LiveSection> {
self.document
.entities()
.filter_map(|entity| {
let EntityType::Extended(extended) = entity else {
return None;
};
let acadrust::entities::ExtendedEntityData::SectionObject(data) = &extended.data else {
return None;
};
let owner = extended.common.owner_handle;
(data.flags & 1 != 0
&& !extended.common.invisible
&& (owner.is_null() || owner == target_block))
.then(|| LiveSection {
data: data.clone(),
slice_depth: crate::entities::extended::section_is_slice(extended)
.then(|| {
crate::entities::extended::section_slice_depth(extended)
.unwrap_or(0.0)
}),
})
})
.last()
}
fn sectioned_body(
&self,
handle: Handle,
section: &LiveSection,
) -> Result<Option<cadkernel::brep::Body>, ()> {
let body = self.section_source_body(handle).ok_or(())?;
Self::section_body(&body, &section.data, section.slice_depth, None)
}
fn section_source_body(
&self,
handle: Handle,
) -> Option<std::borrow::Cow<'_, cadkernel::brep::Body>> {
if let Some(body) = self.solid_models.get(&handle) {
return Some(std::borrow::Cow::Borrowed(body));
}
let body = match self.document.get_entity(handle) {
Some(EntityType::Solid3D(solid)) => {
crate::scene::convert::solid3d_tess::kernel_body(solid)
}
Some(EntityType::Region(region)) => {
crate::scene::convert::solid3d_tess::kernel_region_body(region)
}
Some(EntityType::Body(body)) => {
crate::scene::convert::solid3d_tess::kernel_acis_body(&body.acis_data)
}
Some(EntityType::Surface(surface)) => {
crate::scene::convert::solid3d_tess::kernel_surface_body(surface)
}
_ => None,
}?;
Some(std::borrow::Cow::Owned(body))
}
fn section_body(
body: &cadkernel::brep::Body,
data: &acadrust::entities::SectionObjectData,
slice_depth: Option<f64>,
body_to_world: Option<&acadrust::types::Transform>,
) -> Result<Option<cadkernel::brep::Body>, ()> {
let Some((origin, tangent, vertical, viewing)) = section_frame(data) else {
return Err(());
};
let front = section_plane(origin, tangent, viewing, body_to_world).ok_or(())?;
let mut clipped = keep_section_positive(body, front)?;
let Some(mut current) = clipped.take() else {
return Ok(None);
};
if data.state == 1 && slice_depth.is_none() {
return Ok(Some(current));
}
let depth = slice_depth.unwrap_or_else(|| {
data.vertices
.first()
.zip(data.back_line_vertices.first())
.map_or(0.0, |(front, back)| {
(glam::DVec3::new(back.x, back.y, back.z)
- glam::DVec3::new(front.x, front.y, front.z))
.dot(viewing)
.abs()
})
});
if depth <= 1e-12 {
return Ok(Some(current));
}
let back = section_plane(
origin + viewing * depth,
tangent,
-viewing,
body_to_world,
)
.ok_or(())?;
let Some(next) = keep_section_positive(&current, back)? else {
return Ok(None);
};
current = next;
if data.state == 1 {
return Ok(Some(current));
}
let span = data
.vertices
.first()
.zip(data.vertices.last())
.map_or(0.0, |(first, last)| {
(glam::DVec3::new(last.x, last.y, last.z)
- glam::DVec3::new(first.x, first.y, first.z))
.length()
});
if span <= 1e-12 {
return Ok(Some(current));
}
let first = data.vertices.first().map(|point| glam::DVec3::new(point.x, point.y, point.z)).ok_or(())?;
let last = data.vertices.last().map(|point| glam::DVec3::new(point.x, point.y, point.z)).ok_or(())?;
for plane in [
section_plane(first, vertical, tangent, body_to_world),
section_plane(last, vertical, -tangent, body_to_world),
] {
let Some(plane) = plane else {
return Err(());
};
let Some(next) = keep_section_positive(&current, plane)? else {
return Ok(None);
};
current = next;
}
if data.state != 4 {
return Ok(Some(current));
}
for plane in [
section_plane(
origin - vertical * data.bottom_height,
tangent,
vertical,
body_to_world,
),
section_plane(
origin + vertical * data.top_height,
tangent,
-vertical,
body_to_world,
),
] {
let Some(plane) = plane else {
return Err(());
};
let Some(next) = keep_section_positive(&current, plane)? else {
return Ok(None);
};
current = next;
}
Ok(Some(current))
}
/// True when `layer` is turned off or frozen — entities on it never render.
fn layer_hidden(&self, layer: &str) -> bool {
self.document
@@ -7199,6 +7482,7 @@ impl Scene {
annotation_scale_handle: Option<Handle>,
all_visible: bool,
viewport: Option<Handle>,
live_section: Option<&LiveSection>,
) -> Vec<MeshLodSet> {
if self.block_meshes.is_empty() {
return Vec::new();
@@ -7225,9 +7509,37 @@ impl Scene {
return;
}
let handle = entity.common().handle;
let Some(set) = self.block_meshes.get(&handle) else {
let Some(original) = self.block_meshes.get(&handle) else {
return;
};
let sectioned;
let set = if let Some((section, body)) = live_section.and_then(|section| {
self.section_source_body(handle)
.map(|body| (section, body))
}) {
match Self::section_body(
&body,
&section.data,
section.slice_depth,
Some(&context.transform),
) {
Ok(Some(body)) => {
if let Some((mut mesh, _, _)) =
self.prepare_solid_model_display(handle, &body)
{
mesh.prepare_instance_source(handle);
sectioned = mesh;
&sectioned
} else {
original
}
}
Ok(None) => return,
Err(()) => original,
}
} else {
original
};
let inherit = self.mesh_inherit_for_path(&context.insert_path);
let own_alpha = set.display_color().map_or(1.0, |color| color[3]);
let mut transformed =
@@ -7939,6 +8251,7 @@ impl Scene {
wires: Arc<Vec<WireModel>>,
aabb: [f64; 4],
allow_pending_empty: bool,
area_only: bool,
) -> crate::scene::pick::interaction_index::InteractionCandidates {
if let Some((base_epoch, base, changes)) = self.interaction_overlay_base() {
let perf = crate::perf::enabled();
@@ -7957,11 +8270,20 @@ impl Scene {
);
let local_ms = t_local.elapsed().as_secs_f64() * 1000.0;
let t_remap = iced::time::Instant::now();
let mut result =
base.query_remapped_xy(Arc::clone(&local), &base_slots, aabb);
result.extend_indexed(
changed_index.query_remapped_xy(local, &changed_slots, aabb),
);
let mut result = if area_only {
base.query_remapped_xy_area(Arc::clone(&local), &base_slots, aabb)
} else {
base.query_remapped_xy(Arc::clone(&local), &base_slots, aabb)
};
if area_only {
result.extend_indexed_area(
changed_index.query_remapped_xy_area(local, &changed_slots, aabb),
);
} else {
result.extend_indexed(
changed_index.query_remapped_xy(local, &changed_slots, aabb),
);
}
let remap_ms = t_remap.elapsed().as_secs_f64() * 1000.0;
let total_ms = t0.elapsed().as_secs_f64() * 1000.0;
if perf && total_ms >= 50.0 {
@@ -7979,7 +8301,11 @@ impl Scene {
return result;
}
if let Some(index) = self.cached_interaction_index(&wires) {
index.query_xy(wires, aabb)
if area_only {
index.query_xy_area(wires, aabb)
} else {
index.query_xy(wires, aabb)
}
} else if allow_pending_empty
&& self.interaction_index_pending_key.get()
== Some((self.geometry_epoch, Arc::as_ptr(&wires) as usize))
@@ -8302,12 +8628,18 @@ impl Scene {
cursor.x + radius,
cursor.y + radius,
];
self.indexed_interaction_candidates_xy(wires, query, allow_pending_empty)
self.indexed_interaction_candidates_xy(wires, query, allow_pending_empty, false)
}
/// Shared rectangular broad phase for box/lasso/fence and command windows.
/// Flat orthographic views query world XY; tilted/perspective views query
/// projected 3D bounds using the supplied screen rectangle.
/// Candidates for an area selection: box, crossing, fence and lasso.
///
/// Every caller feeds the result to the box/fence hit tests and to
/// `interaction_candidate_handles`, none of which snap, so the flat-ortho
/// path asks for the reduced category set. A caller that needs snapping
/// belongs on `interaction_candidates_near` instead.
pub fn interaction_candidates_in_aabb(
&self,
wires: Arc<Vec<WireModel>>,
@@ -8344,6 +8676,7 @@ impl Scene {
wires,
[aabb[0] - pad, aabb[1] - pad, aabb[2] + pad, aabb[3] + pad],
false,
true,
)
} else {
self.indexed_interaction_candidates_screen(
@@ -8399,7 +8732,7 @@ impl Scene {
pub fn interaction_handles_in_world_aabb(&self, aabb: [f64; 4]) -> HashSet<Handle> {
let wires = self.hit_test_wires();
let candidates = self.indexed_interaction_candidates_xy(wires, aabb, false);
let candidates = self.indexed_interaction_candidates_xy(wires, aabb, false, false);
let mut handles: HashSet<Handle> = candidates
.iter()
.filter_map(|wire| Self::handle_from_wire_name(&wire.name))
@@ -10577,6 +10910,169 @@ impl Default for Scene {
}
}
#[cfg(test)]
mod section_tests {
use super::*;
use acadrust::entities::{
EntityCommon, ExtendedEntity, ExtendedEntityData, SectionObjectData, Solid3D,
};
use acadrust::objects::ClassObjectData;
use acadrust::types::{Color, Vector3};
fn section(state: i32, depth: f64) -> SectionObjectData {
let vertices = vec![Vector3::new(5.0, 2.0, 5.0), Vector3::new(5.0, 8.0, 5.0)];
SectionObjectData {
state,
flags: 5,
name: "Section Plane (1)".to_string(),
vertical_direction: Vector3::UNIT_Z,
top_height: 1.0,
bottom_height: 2.0,
indicator_alpha: 70,
indicator_color: Color::from_index(9),
back_line_vertices: (depth > 0.0)
.then(|| {
vertices
.iter()
.map(|point| *point + Vector3::new(-depth, 0.0, 0.0))
.collect()
})
.unwrap_or_default(),
vertices,
settings_handle: Handle::NULL,
}
}
fn bounds(body: Option<cadkernel::brep::Body>) -> ([f64; 3], [f64; 3]) {
let body = body.expect("section should retain material");
assert!(body.validate().is_empty());
let bounds = cadkernel::brep::body_bounds(&body).expect("sectioned body should be bounded");
(bounds.min, bounds.max)
}
fn assert_near(actual: [f64; 3], expected: [f64; 3]) {
for (actual, expected) in actual.into_iter().zip(expected) {
assert!((actual - expected).abs() < 1e-8, "{actual} != {expected}");
}
}
#[test]
fn kernel_clipping_obeys_plane_slice_boundary_and_volume_limits() {
let body = cadkernel::brep::make::cuboid([0.0; 3], [10.0; 3]).unwrap();
let (min, max) = bounds(Scene::section_body(&body, &section(1, 0.0), None, None).unwrap());
assert_near(min, [0.0, 0.0, 0.0]);
assert_near(max, [5.0, 10.0, 10.0]);
let (min, max) = bounds(
Scene::section_body(&body, &section(1, 0.0), Some(2.0), None).unwrap(),
);
assert_near(min, [3.0, 0.0, 0.0]);
assert_near(max, [5.0, 10.0, 10.0]);
let (min, max) = bounds(Scene::section_body(&body, &section(2, 4.0), None, None).unwrap());
assert_near(min, [1.0, 2.0, 0.0]);
assert_near(max, [5.0, 8.0, 10.0]);
let (min, max) = bounds(Scene::section_body(&body, &section(4, 4.0), None, None).unwrap());
assert_near(min, [1.0, 2.0, 3.0]);
assert_near(max, [5.0, 8.0, 6.0]);
}
#[test]
fn instance_transform_pulls_the_section_plane_into_body_space() {
let body = cadkernel::brep::make::cuboid([0.0; 3], [10.0; 3]).unwrap();
let transform = acadrust::types::Transform::from_scaling(
acadrust::types::Vector3::new(2.0, 1.0, 1.0),
)
.then(&acadrust::types::Transform::from_translation(
acadrust::types::Vector3::new(10.0, 0.0, 0.0),
));
let mut data = section(1, 0.0);
for point in &mut data.vertices {
point.x = 20.0;
}
let (min, max) = bounds(
Scene::section_body(&body, &data, None, Some(&transform)).unwrap(),
);
assert_near(min, [0.0, 0.0, 0.0]);
assert_near(max, [5.0, 10.0, 10.0]);
}
#[test]
fn adding_and_erasing_a_section_keeps_its_object_graph_consistent() {
let mut scene = Scene::new();
let handle = scene.add_entity(EntityType::Extended(ExtendedEntity {
common: EntityCommon::new(),
data: ExtendedEntityData::SectionObject(section(1, 0.0)),
}));
let settings_handle = match scene.document.get_entity(handle).unwrap() {
EntityType::Extended(ExtendedEntity {
data: ExtendedEntityData::SectionObject(data),
..
}) => data.settings_handle,
_ => unreachable!(),
};
assert!(matches!(
scene.document.objects.get(&settings_handle),
Some(ObjectType::ClassObject(object))
if object.owner == handle
&& matches!(&object.data, ClassObjectData::SectionSettings(_))
));
let root = scene.document.header.named_objects_dict_handle;
let manager_handle = match scene.document.objects.get(&root).unwrap() {
ObjectType::Dictionary(dictionary) => dictionary
.entries
.iter()
.find(|(name, _)| name == "ACAD_SECTION_MANAGER")
.map(|(_, handle)| *handle)
.unwrap(),
_ => panic!("root must be a dictionary"),
};
assert!(matches!(
scene.document.objects.get(&manager_handle),
Some(ObjectType::ClassObject(object))
if matches!(&object.data, ClassObjectData::SectionManager(manager)
if manager.sections == vec![handle])
));
scene.erase_entities(&[handle]);
assert!(!scene.document.objects.contains_key(&settings_handle));
assert!(matches!(
scene.document.objects.get(&manager_handle),
Some(ObjectType::ClassObject(object))
if matches!(&object.data, ClassObjectData::SectionManager(manager)
if manager.sections.is_empty())
));
}
#[test]
fn live_section_rebuilds_an_uncached_persisted_solid() {
let body = cadkernel::brep::make::cuboid([0.0; 3], [10.0; 3]).unwrap();
let sat = crate::scene::convert::acis_export::solid_to_sat(&body).unwrap();
let mut solid = Solid3D::new();
solid.set_sat_document(&sat);
let mut scene = Scene::new();
let handle = scene.add_entity(EntityType::Solid3D(solid));
assert!(!scene.solid_models.contains_key(&handle));
let clipped = scene
.sectioned_body(
handle,
&LiveSection {
data: section(1, 0.0),
slice_depth: None,
},
)
.unwrap();
let (min, max) = bounds(clipped);
assert_near(min, [0.0, 0.0, 0.0]);
assert_near(max, [5.0, 10.0, 10.0]);
}
}
#[cfg(test)]
mod journal_tests {
use super::*;
@@ -10874,6 +11370,8 @@ mod journal_tests {
assert_eq!(folded.len(), before.len() + 1);
}
// Differential oracle: the incrementally-patched entity index must always
// equal a from-scratch rebuild after any add / move / erase.
#[test]
fn block_member_index_matches_the_full_scan() {
use acadrust::entities::Line;
@@ -12090,4 +12588,129 @@ mod layout_cache_tests {
println!("Zoom Frame Overhead: {:.3} ms ({:.0} FPS)", per_zoom_ms, 1000.0 / per_zoom_ms.max(0.001));
println!("============================================================================\n");
}
#[test]
#[ignore = "benchmark"]
fn bench_wide_and_tapered_arc_rendering() {
use std::time::Instant;
use acadrust::entities::LwPolyline;
use acadrust::types::Vector2;
let n_wide_arcs = 2500;
let n_tapered_arcs = 2500;
let n_donuts = 2500;
let total_entities = n_wide_arcs + n_tapered_arcs + n_donuts;
let mut scene = Scene::new();
// 1. Wide polyline arcs (constant width 6.0)
for i in 0..n_wide_arcs {
let x = (i % 50) as f64 * 80.0;
let y = (i / 50) as f64 * 80.0;
let mut pline = LwPolyline::new();
pline.constant_width = 6.0;
pline.vertices = vec![
acadrust::entities::LwVertex {
location: Vector2::new(x, y),
bulge: 0.5,
start_width: 0.0,
end_width: 0.0,
vertex_id: 0,
},
acadrust::entities::LwVertex {
location: Vector2::new(x + 40.0, y),
bulge: 0.0,
start_width: 0.0,
end_width: 0.0,
vertex_id: 1,
},
];
scene.add_entity(EntityType::LwPolyline(pline));
}
// 2. Tapered arcs (width: 2.0 -> 12.0)
for i in 0..n_tapered_arcs {
let x = (i % 50) as f64 * 80.0;
let y = (i / 50) as f64 * 80.0 + 4500.0;
let mut pline = LwPolyline::new();
pline.vertices = vec![
acadrust::entities::LwVertex {
location: Vector2::new(x, y),
bulge: 0.7,
start_width: 2.0,
end_width: 12.0,
vertex_id: 0,
},
acadrust::entities::LwVertex {
location: Vector2::new(x + 35.0, y + 10.0),
bulge: 0.0,
start_width: 0.0,
end_width: 0.0,
vertex_id: 1,
},
];
scene.add_entity(EntityType::LwPolyline(pline));
}
// 3. Donuts (2 semicircular arc segments, constant width 16.0)
for i in 0..n_donuts {
let cx = (i % 50) as f64 * 80.0;
let cy = (i / 50) as f64 * 80.0 + 9000.0;
let donut = crate::modules::draw::draw::donut::make_donut(cx, cy, 0.0, 10.0, 26.0);
scene.add_entity(donut);
}
let cam = Camera::default();
let t_tess_start = Instant::now();
let wires = scene.model_tile_wires_arc(0, &cam, 1.0, 1000.0);
let tess_duration = t_tess_start.elapsed();
let total_wires = wires.len();
let mut chord_points = 0usize;
let mut analytical_tangents = 0usize;
for w in wires.iter() {
chord_points += w.points.len();
analytical_tangents += w.tangent_geoms.len();
}
let depths = rustc_hash::FxHashMap::default();
let t_part_start = Instant::now();
let iters = 100;
let mut part = None;
for _ in 0..iters {
part = Some(crate::scene::pipeline::wire_arena::partition_wires(&wires, &depths));
}
let part_duration = t_part_start.elapsed() / (iters as u32);
let p = part.unwrap();
// Zoom simulation
let zoom_levels = [0.1f32, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 50.0, 100.0, 1000.0];
let t_zoom_start = Instant::now();
let mut dynamic_cam = Camera::default();
for &zoom in zoom_levels.iter().cycle().take(100) {
dynamic_cam.distance = 1000.0 / zoom;
let _w = scene.model_tile_wires_arc(0, &dynamic_cam, 1.0, 1000.0);
}
let zoom_100_duration = t_zoom_start.elapsed();
let per_zoom_ms = zoom_100_duration.as_secs_f64() * 1000.0 / 100.0;
let line_vram_mb = (chord_points * 36) as f64 / (1024.0 * 1024.0);
let inst_vram_mb = (p.circle_instances.len() * 128) as f64 / (1024.0 * 1024.0);
println!("\n=== BENCHMARK REPORT: THICK & TAPERED ANALYTICAL ARCS ===");
println!("Thick & Tapered Entities: {total_entities}");
println!("Initial Wire Build Time: {:.2?}", tess_duration);
println!("Total Wires: {total_wires}");
println!("Tessellated Chord Vertices: {chord_points}");
println!("Analytical Tangent Geometries: {analytical_tangents}");
println!("Partitioning Time (per frame): {:.3?}", part_duration);
println!("Regular Line Wires: {}", p.regular.len());
println!("GPU Circle/Arc Instances: {}", p.circle_instances.len());
println!("Line Arena VRAM (chord lines): {:.2} MB", line_vram_mb);
println!("GPU Analytical VRAM: {:.2} MB", inst_vram_mb);
println!("VRAM Reduction Ratio: {:.1}x", if inst_vram_mb > 0.0 { line_vram_mb / inst_vram_mb } else { 0.0 });
println!("100 Camera Zoom Navigations: {:.2?}", zoom_100_duration);
println!("Zoom Frame Overhead: {:.3} ms ({:.0} FPS)", per_zoom_ms, 1000.0 / per_zoom_ms.max(0.001));
println!("========================================================\n");
}
}
+4 -40
View File
@@ -1013,44 +1013,9 @@ fn brep_properties(
history_flags(document, handle).unwrap_or((false, false, 1));
let (show_history, show_history_editable) =
displayed_history_state(object_show_history, show_history_mode);
let mut geometry = vec![Property {
label: t!("Solid type").into_owned(),
field: "solid_history_type",
value: PropValue::ReadOnly(t!("3D Solid").into_owned()),
}];
if let Some(position) = document
.solid_history_operation(handle)
.and_then(|operation| match operation {
SolidHistoryOperation::Brep(value) => world_point(value.base.transform, [0.0; 3]),
_ => None,
})
{
geometry.extend([
crate::entities::common::edit_prop(
t!("Position X").as_ref(),
PROP_POSITION_X,
position.x,
),
crate::entities::common::edit_prop(
t!("Position Y").as_ref(),
PROP_POSITION_Y,
position.y,
),
crate::entities::common::edit_prop(
t!("Position Z").as_ref(),
PROP_POSITION_Z,
position.z,
),
]);
}
vec![
PropSection {
title: t!("Geometry").into_owned(),
props: geometry,
},
PropSection {
title: t!("Solid History").into_owned(),
props: vec![
vec![PropSection {
title: t!("Solid History").into_owned(),
props: vec![
Property {
label: t!("History").into_owned(),
field: PROP_HISTORY,
@@ -1072,8 +1037,7 @@ fn brep_properties(
},
},
],
},
]
}]
}
fn cylinder_properties(
+117 -16
View File
@@ -13,7 +13,7 @@ use iced::{Point, Rectangle};
use crate::scene::model::hatch_model::HatchModel;
use crate::scene::model::mesh_model::MeshModel;
use crate::scene::model::wire_model::WireModel;
use crate::scene::pick::interaction_index::WireSource;
use crate::scene::pick::interaction_index::{SegmentRef, WireSource};
/// Pick radius for one wire, in screen pixels.
///
@@ -1247,13 +1247,14 @@ fn indexed_box_crossing_hits<'a, W: WireSource + ?Sized>(
|| (0..4).any(|edge| segments_intersect(a, b, corners[edge], corners[(edge + 1) % 4]))
};
for segment in wires.segments().unwrap_or_default() {
let Some(wire) = wires.source_wire(segment.wire) else {
continue;
};
// One projection pair per segment, reading nothing but the wire, the view
// and the box. Hoisted into a closure so the sequential and the parallel
// path below run identical code.
let hit_name = |segment: &SegmentRef| -> Option<&'a str> {
let wire = wires.source_wire(segment.wire)?;
let start = segment.start as usize;
if start + 1 >= wire.points.len() {
continue;
return None;
}
let a = world_to_screen(
wire_point_world(wire, start, view_rot, eye),
@@ -1267,8 +1268,30 @@ fn indexed_box_crossing_hits<'a, W: WireSource + ?Sized>(
eye,
bounds,
);
if segment_hits(a, b) && seen.insert(wire.name.as_str()) {
out.push(wire.name.as_str());
segment_hits(a, b).then_some(wire.name.as_str())
};
let mut wire_hit: Vec<bool> = Vec::new();
fn mark(wire_hit: &mut Vec<bool>, wire: u32) {
let index = wire as usize;
if index >= wire_hit.len() {
wire_hit.resize(index + 1, false);
}
wire_hit[index] = true;
}
fn already(wire_hit: &[bool], wire: u32) -> bool {
wire_hit.get(wire as usize).copied().unwrap_or(false)
}
for segment in wires.segments().unwrap_or_default() {
if already(&wire_hit, segment.wire) {
continue;
}
if let Some(name) = hit_name(segment) {
mark(&mut wire_hit, segment.wire);
if seen.insert(name) {
out.push(name);
}
}
}
for wire in wires.iter().filter(|wire| wire.point_marker.is_some()) {
@@ -1287,7 +1310,7 @@ fn indexed_box_crossing_hits<'a, W: WireSource + ?Sized>(
let Some(wire) = wires.source_wire(triangle.wire) else {
continue;
};
if seen.contains(wire.name.as_str()) {
if already(&wire_hit, triangle.wire) {
continue;
}
if projected_wire_triangle(
@@ -1299,9 +1322,11 @@ fn indexed_box_crossing_hits<'a, W: WireSource + ?Sized>(
bounds,
)
.is_some_and(|triangle| triangle_crosses_box(triangle, corners))
&& seen.insert(wire.name.as_str())
{
out.push(wire.name.as_str());
mark(&mut wire_hit, triangle.wire);
if seen.insert(wire.name.as_str()) {
out.push(wire.name.as_str());
}
}
}
}
@@ -1309,7 +1334,7 @@ fn indexed_box_crossing_hits<'a, W: WireSource + ?Sized>(
let Some(wire) = wires.source_wire(glyph.wire) else {
continue;
};
if seen.contains(wire.name.as_str()) {
if already(&wire_hit, glyph.wire) {
continue;
}
let Some(screen) =
@@ -1330,11 +1355,11 @@ fn indexed_box_crossing_hits<'a, W: WireSource + ?Sized>(
// Degenerate point-only wires have no indexed segment or surface primitive.
for wire in wires.iter() {
if seen.contains(wire.name.as_str())
|| wire.points.len() >= 2
if wire.points.len() >= 2
|| !wire.fill_tris.is_empty()
|| !wire.pick_tris.is_empty()
|| !wire.text_verts.is_empty()
|| seen.contains(wire.name.as_str())
{
continue;
}
@@ -1540,11 +1565,11 @@ fn indexed_polygon_crossing_hits<'a, W: WireSource + ?Sized>(
}
}
for wire in wires.iter() {
if seen.contains(wire.name.as_str())
|| wire.points.len() >= 2
if wire.points.len() >= 2
|| !wire.fill_tris.is_empty()
|| !wire.pick_tris.is_empty()
|| !wire.text_verts.is_empty()
|| seen.contains(wire.name.as_str())
{
continue;
}
@@ -2712,3 +2737,79 @@ mod aabb_reject_tests {
}
}
#[cfg(test)]
mod parallel_selection_tests {
use super::*;
use crate::scene::pick::interaction_index::{InteractionCandidates, InteractionIndex};
use std::sync::Arc;
fn many_wires_with_spans(count: usize, spans: usize) -> Vec<WireModel> {
(0..count)
.map(|i| {
let t = i as f32 / count as f32;
let x = -0.95 + 1.9 * t;
let y = -0.95 + 1.9 * t;
let points: Vec<[f32; 3]> = (0..=spans)
.map(|s| {
let f = s as f32 / spans as f32;
[x + 0.01 * f, y + 0.01 * f, 0.0]
})
.collect();
let mut w = WireModel::solid(
(i as u64 + 1).to_string(),
points,
[1.0; 4],
false,
);
w.aabb = [x, y, x + 0.01, y + 0.01];
w
})
.collect()
}
#[test]
#[ignore = "measurement, not a check"]
fn selection_scaling_numbers() {
use std::time::Instant;
let wires = many_wires_with_spans(186_468, 24);
let index = InteractionIndex::build(&wires);
let arc = Arc::new(wires);
let aabb = [-2.0, -2.0, 2.0, 2.0];
let t = Instant::now();
let full = index.query_xy(Arc::clone(&arc), aabb);
let full_ms = t.elapsed().as_secs_f64() * 1000.0;
let t = Instant::now();
let area = index.query_xy_area(Arc::clone(&arc), aabb);
let area_ms = t.elapsed().as_secs_f64() * 1000.0;
let bounds = Rectangle {
x: 0.0,
y: 0.0,
width: 200.0,
height: 200.0,
};
let run = |candidates: &InteractionCandidates| {
let t = Instant::now();
let hits = box_hit(
Point::new(0.0, 0.0),
Point::new(200.0, 200.0),
true,
candidates,
Mat4::IDENTITY,
glam::DVec3::ZERO,
bounds,
);
(t.elapsed().as_secs_f64() * 1000.0, hits.len())
};
let (scan_ms, scan_n) = run(&area);
println!(
"candidates: full={full_ms:.1}ms ({} wires) area={area_ms:.1}ms ({} wires)",
full.len(),
area.len(),
);
println!("crossing scan: {scan_ms:.1}ms ({scan_n} hits)");
}
}
+331 -19
View File
@@ -458,6 +458,62 @@ impl SpatialGrid {
}
}
fn query_unordered<T: Copy>(&self, entries: &[Entry3<T>], query: [f64; 4]) -> Vec<T> {
let mut indices = Vec::new();
self.oversized.query(entries, query, &mut indices);
let mut out: Vec<T> = indices
.into_iter()
.filter_map(|idx| {
let entry = entries.get(idx as usize)?;
aabb_overlaps(entry_aabb2(entry), query).then_some(entry.value)
})
.collect();
let grid_max_x = self.min[0] + self.cols as f64 * self.cell;
let grid_max_y = self.min[1] + self.rows as f64 * self.cell;
if query[2] < self.min[0]
|| query[3] < self.min[1]
|| query[0] > grid_max_x
|| query[1] > grid_max_y
{
return out;
}
let col = |x: f64| {
(((x - self.min[0]) / self.cell).floor()).clamp(0.0, (self.cols - 1) as f64) as u32
};
let row = |y: f64| {
(((y - self.min[1]) / self.cell).floor()).clamp(0.0, (self.rows - 1) as f64) as u32
};
let (row_lo, row_hi) = (row(query[1]), row(query[3]));
let (col_lo, col_hi) = (col(query[0]), col(query[2]));
// One growth instead of a chain of reallocations: a box over the whole
// drawing lands nearly every entry here.
let spanned: usize = (row_lo..=row_hi)
.map(|r| {
let base = r as usize * self.cols as usize;
let start = self.cell_offsets[base + col_lo as usize] as usize;
let end = self.cell_offsets[base + col_hi as usize + 1] as usize;
end.saturating_sub(start)
})
.sum();
out.reserve(spanned);
for r in row_lo..=row_hi {
let base = r as usize * self.cols as usize;
for c in col_lo..=col_hi {
let cell_idx = base + c as usize;
let start = self.cell_offsets[cell_idx] as usize;
let end = self.cell_offsets[cell_idx + 1] as usize;
out.extend(self.cell_entries[start..end].iter().filter_map(|&idx| {
let entry = entries.get(idx as usize)?;
aabb_overlaps(entry_aabb2(entry), query).then_some(entry.value)
}));
}
}
out
}
fn query<T: Copy + Ord>(&self, entries: &[Entry3<T>], query: [f64; 4]) -> Vec<T> {
let mut entry_indices = Vec::new();
self.oversized.query(entries, query, &mut entry_indices);
@@ -520,6 +576,11 @@ impl<T: Copy + Ord + Sync> SpatialSet<T> {
self.xy.query(&self.entries, aabb)
}
/// See [`SpatialGrid::query_unordered`] — same set, no sort, duplicates in.
fn query_xy_unordered(&self, aabb: [f64; 4]) -> Vec<T> {
self.xy.query_unordered(&self.entries, aabb)
}
fn prepare_screen(&self) {
self.xyz
.get_or_init(|| SpatialBvh3::build(&self.entries));
@@ -839,17 +900,17 @@ impl InteractionIndex {
let (
((segment_entries, snap_point_entries), (key_vertex_entries, key_segment_entries)),
((fill_triangle_entries, pick_triangle_entries), glyph_entries),
) = rayon::join(
) = crate::par::join(
|| {
rayon::join(
crate::par::join(
|| {
rayon::join(
crate::par::join(
|| flatten_entry_parts(segment_parts),
|| flatten_entry_parts(snap_point_parts),
)
},
|| {
rayon::join(
crate::par::join(
|| flatten_entry_parts(key_vertex_parts),
|| flatten_entry_parts(key_segment_parts),
)
@@ -857,7 +918,7 @@ impl InteractionIndex {
)
},
|| {
let (fill, pick) = rayon::join(
let (fill, pick) = crate::par::join(
|| flatten_entry_parts(fill_triangle_parts),
|| flatten_entry_parts(pick_triangle_parts),
);
@@ -891,17 +952,17 @@ impl InteractionIndex {
let (
((wires, segments), (snap_points, key_vertices)),
((key_segments, fill_triangles), (pick_triangles, glyphs)),
) = rayon::join(
) = crate::par::join(
|| {
rayon::join(
crate::par::join(
|| {
rayon::join(
crate::par::join(
|| SpatialSet::build(wire_entries),
|| SpatialSet::build(segment_entries),
)
},
|| {
rayon::join(
crate::par::join(
|| SpatialSet::build(snap_point_entries),
|| SpatialSet::build(key_vertex_entries),
)
@@ -909,15 +970,15 @@ impl InteractionIndex {
)
},
|| {
rayon::join(
crate::par::join(
|| {
rayon::join(
crate::par::join(
|| SpatialSet::build(key_segment_entries),
|| SpatialSet::build(fill_triangle_entries),
)
},
|| {
rayon::join(
crate::par::join(
|| SpatialSet::build(pick_triangle_entries),
|| SpatialSet::build(glyph_entries),
)
@@ -979,12 +1040,12 @@ impl InteractionIndex {
/// thread. Perspective/orbit hover must never pay this one-time cost.
pub fn prepare_screen(&self) {
#[cfg(not(target_arch = "wasm32"))]
rayon::join(
crate::par::join(
|| {
rayon::join(
|| rayon::join(|| self.wires.prepare_screen(), || self.segments.prepare_screen()),
crate::par::join(
|| crate::par::join(|| self.wires.prepare_screen(), || self.segments.prepare_screen()),
|| {
rayon::join(
crate::par::join(
|| self.snap_points.prepare_screen(),
|| self.key_vertices.prepare_screen(),
)
@@ -992,15 +1053,15 @@ impl InteractionIndex {
)
},
|| {
rayon::join(
crate::par::join(
|| {
rayon::join(
crate::par::join(
|| self.key_segments.prepare_screen(),
|| self.fill_triangles.prepare_screen(),
)
},
|| {
rayon::join(
crate::par::join(
|| self.pick_triangles.prepare_screen(),
|| self.glyphs.prepare_screen(),
)
@@ -1136,6 +1197,52 @@ impl InteractionIndex {
.collect()
}
pub(crate) fn query_remapped_xy_area(
&self,
wires: Arc<Vec<WireModel>>,
slots: &rustc_hash::FxHashMap<(u64, u32), u32>,
aabb: [f64; 4],
) -> InteractionCandidates {
InteractionCandidates {
wires,
wire_indices: Some(self.remap_wire_indices(
self.wires.query_xy_unordered(aabb),
slots,
false,
)),
segments: Some(self.remap_refs(
self.segments.query_xy_unordered(aabb),
slots,
|value| value.wire,
|value, wire| value.wire = wire,
)),
snap_points: None,
key_vertices: None,
key_segments: None,
fill_triangles: Some(self.remap_refs(
self.fill_triangles.query_xy_unordered(aabb),
slots,
|value| value.wire,
|value, wire| value.wire = wire,
)),
pick_triangles: Some(self.remap_refs(
self.pick_triangles.query_xy_unordered(aabb),
slots,
|value| value.wire,
|value, wire| value.wire = wire,
)),
glyphs: Some(self.remap_refs(
self.glyphs.query_xy_unordered(aabb),
slots,
|value| value.wire,
|value, wire| value.wire = wire,
)),
query_aabb: Some(aabb),
screen_rect: None,
screen_view: None,
}
}
pub(crate) fn query_remapped_xy(
&self,
wires: Arc<Vec<WireModel>>,
@@ -1269,6 +1376,53 @@ impl InteractionIndex {
}
}
pub fn query_xy_area(
&self,
wires: Arc<Vec<WireModel>>,
aabb: [f64; 4],
) -> InteractionCandidates {
let ((wire_indices, segments), ((fill_triangles, pick_triangles), glyphs)) =
crate::par::join(
|| {
crate::par::join(
|| {
let mut indices = self.wires.query_xy_unordered(aabb);
indices.extend_from_slice(&self.unbounded_wires);
indices.sort_unstable();
indices.dedup();
indices
},
|| self.segments.query_xy_unordered(aabb),
)
},
|| {
crate::par::join(
|| {
crate::par::join(
|| self.fill_triangles.query_xy_unordered(aabb),
|| self.pick_triangles.query_xy_unordered(aabb),
)
},
|| self.glyphs.query_xy_unordered(aabb),
)
},
);
InteractionCandidates {
wires,
wire_indices: Some(wire_indices),
segments: Some(segments),
snap_points: None,
key_vertices: None,
key_segments: None,
fill_triangles: Some(fill_triangles),
pick_triangles: Some(pick_triangles),
glyphs: Some(glyphs),
query_aabb: Some(aabb),
screen_rect: None,
screen_view: None,
}
}
pub fn query_xy(&self, wires: Arc<Vec<WireModel>>, aabb: [f64; 4]) -> InteractionCandidates {
let mut wire_indices = self.wires.query_xy(aabb);
wire_indices.extend_from_slice(&self.unbounded_wires);
@@ -1505,6 +1659,29 @@ impl InteractionCandidates {
.map(|(rect, (view, eye, bounds))| (rect, view, eye, bounds))
}
pub(crate) fn extend_indexed_area(&mut self, other: Self) {
debug_assert!(Arc::ptr_eq(&self.wires, &other.wires));
fn append<T>(target: &mut Option<Vec<T>>, incoming: Option<Vec<T>>) {
let (Some(target), Some(incoming)) = (target.as_mut(), incoming) else {
return;
};
target.extend(incoming);
}
if let (Some(target), Some(incoming)) =
(self.wire_indices.as_mut(), other.wire_indices)
{
target.extend(incoming);
target.sort_unstable();
target.dedup();
}
append(&mut self.segments, other.segments);
append(&mut self.fill_triangles, other.fill_triangles);
append(&mut self.pick_triangles, other.pick_triangles);
append(&mut self.glyphs, other.glyphs);
}
pub(crate) fn extend_indexed(&mut self, other: Self) {
debug_assert!(Arc::ptr_eq(&self.wires, &other.wires));
@@ -1830,3 +2007,138 @@ fn aabb3_projects_into(
fn aabb_overlaps(a: [f64; 4], b: [f64; 4]) -> bool {
a[2] >= b[0] && a[0] <= b[2] && a[3] >= b[1] && a[1] <= b[3]
}
#[cfg(test)]
mod area_query_tests {
use super::*;
use iced::{Point, Rectangle};
fn wire(name: &str, pts: Vec<[f32; 3]>, aabb: [f32; 4]) -> WireModel {
let mut w = WireModel::solid(name.to_string(), pts, [1.0; 4], false);
w.aabb = aabb;
w
}
fn sample() -> Vec<WireModel> {
vec![
wire("5", vec![[-0.5, -0.5, 0.0], [0.5, 0.5, 0.0]], [-0.5, -0.5, 0.5, 0.5]),
wire("9", vec![[0.1, -0.9, 0.0], [0.9, -0.1, 0.0]], [0.1, -0.9, 0.9, -0.1]),
wire("13", vec![[-0.9, 0.2, 0.0], [-0.2, 0.9, 0.0]], [-0.9, 0.2, -0.2, 0.9]),
]
}
#[test]
fn area_query_matches_full_query_where_it_is_read() {
let wires = sample();
let index = InteractionIndex::build(&wires);
let arc = Arc::new(wires);
let aabb = [-2.0, -2.0, 2.0, 2.0];
let full = index.query_xy(Arc::clone(&arc), aabb);
let area = index.query_xy_area(Arc::clone(&arc), aabb);
fn normalise<T: Copy + Ord>(v: &Option<Vec<T>>) -> Option<Vec<T>> {
v.as_ref().map(|items| {
let mut items = items.clone();
items.sort_unstable();
items.dedup();
items
})
}
assert_eq!(area.wire_indices, full.wire_indices);
assert_eq!(normalise(&area.segments), normalise(&full.segments));
assert_eq!(
normalise(&area.fill_triangles),
normalise(&full.fill_triangles),
);
assert_eq!(
normalise(&area.pick_triangles),
normalise(&full.pick_triangles),
);
assert_eq!(normalise(&area.glyphs), normalise(&full.glyphs));
assert_eq!(area.query_aabb, full.query_aabb);
// The sample carries no hatches or text, so the equality above is
// thin for those three. What matters is that they stay indexed at all:
// dropping one to `None` is what would silently unselect a hatch.
assert!(area.fill_triangles.is_some());
assert!(area.pick_triangles.is_some());
assert!(area.glyphs.is_some());
assert!(area.snap_points.is_none());
assert!(area.key_vertices.is_none());
assert!(area.key_segments.is_none());
assert!(full.snap_points.is_some(), "the full query still snaps");
}
#[test]
fn the_area_merge_dedups_wire_indices_and_nothing_else() {
let arc = Arc::new(sample());
let base = |indices: Vec<u32>, segments: Vec<SegmentRef>| InteractionCandidates {
wires: Arc::clone(&arc),
wire_indices: Some(indices),
segments: Some(segments),
snap_points: None,
key_vertices: None,
key_segments: None,
fill_triangles: Some(Vec::new()),
pick_triangles: Some(Vec::new()),
glyphs: Some(Vec::new()),
query_aabb: Some([-2.0, -2.0, 2.0, 2.0]),
screen_rect: None,
screen_view: None,
};
let seg = |wire: u32| SegmentRef { wire, start: 0 };
let mut merged = base(vec![2, 0], vec![seg(2), seg(0)]);
merged.extend_indexed_area(base(vec![1, 0], vec![seg(1), seg(0)]));
assert_eq!(merged.wire_indices, Some(vec![0, 1, 2]));
assert_eq!(
merged.segments,
Some(vec![seg(2), seg(0), seg(1), seg(0)]),
"the other categories are appended as they come",
);
}
// World (x,y) → screen ((x+1)*100, (1-y)*100) over a 200×200 viewport, the
// same identity ortho view the hit-test tests use.
#[test]
fn box_selection_picks_the_same_handles_from_either_query() {
let wires = sample();
let index = InteractionIndex::build(&wires);
let arc = Arc::new(wires);
let aabb = [-2.0, -2.0, 2.0, 2.0];
let bounds = Rectangle {
x: 0.0,
y: 0.0,
width: 200.0,
height: 200.0,
};
let eye = glam::DVec3::ZERO;
for crossing in [false, true] {
let full = index.query_xy(Arc::clone(&arc), aabb);
let area = index.query_xy_area(Arc::clone(&arc), aabb);
let hit = |c: &InteractionCandidates| {
let mut names = crate::scene::pick::hit_test::box_hit(
Point::new(0.0, 0.0),
Point::new(200.0, 200.0),
crossing,
c,
glam::Mat4::IDENTITY,
eye,
bounds,
)
.into_iter()
.map(str::to_string)
.collect::<Vec<_>>();
names.sort();
names
};
let from_full = hit(&full);
assert!(!from_full.is_empty(), "the box covers the whole sample");
assert_eq!(hit(&area), from_full, "crossing={crossing}");
}
}
}
+131 -9
View File
@@ -11,7 +11,7 @@ use iced::wgpu;
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub struct CircleInstance {
pub center_high: [f32; 3],
pub _pad0: f32,
pub start_width: f32,
pub center_low: [f32; 3],
pub radius: f32,
pub axis_x: [f32; 3],
@@ -19,7 +19,7 @@ pub struct CircleInstance {
pub axis_y: [f32; 3],
pub end_angle: f32,
pub color: [f32; 4],
/// `[half_width, pattern_length, draw_depth, padding]`
/// `[half_width, pattern_length, draw_depth, end_width]`
pub params: [f32; 4],
pub pat0: [f32; 4],
pub pat1: [f32; 4],
@@ -145,7 +145,7 @@ pub fn create_pipelines(
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth24PlusStencil8,
depth_write_enabled: Some(true),
depth_write_enabled: Some(false),
depth_compare: Some(wgpu::CompareFunction::LessEqual),
stencil: content_stencil.clone(),
bias: wgpu::DepthBiasState::default(),
@@ -204,6 +204,16 @@ pub fn extract_circle_instance_from_geom(
geom: &crate::scene::model::wire_model::TangentGeom,
wire: &crate::scene::WireModel,
draw_depth: f32,
) -> Option<CircleInstance> {
extract_circle_instance_from_geom_indexed(geom, wire, draw_depth, 0)
}
/// Helper to extract an analytical circle or circular arc instance at a specific segment index.
pub fn extract_circle_instance_from_geom_indexed(
geom: &crate::scene::model::wire_model::TangentGeom,
wire: &crate::scene::WireModel,
draw_depth: f32,
geom_index: usize,
) -> Option<CircleInstance> {
let (center, axis_x, axis_y, radius, start_angle, end_angle) = match *geom {
crate::scene::model::wire_model::TangentGeom::Circle { center, radius } => (
@@ -246,9 +256,25 @@ pub fn extract_circle_instance_from_geom(
let pat0 = [wire.pattern[0], wire.pattern[1], wire.pattern[2], wire.pattern[3]];
let pat1 = [wire.pattern[4], wire.pattern[5], wire.pattern[6], wire.pattern[7]];
let (start_width, end_width) = if !wire.taper_widths.is_empty() {
if wire.taper_widths.len() >= 2 && wire.tangent_geoms.len() == 1 {
(wire.taper_widths[0], wire.taper_widths[1])
} else if wire.taper_widths.len() > geom_index {
let sw = wire.taper_widths[geom_index];
let ew = wire.taper_widths.get(geom_index + 1).copied().unwrap_or(sw);
(sw, ew)
} else {
(wire.world_width, wire.world_width)
}
} else if wire.world_width > 0.0 {
(wire.world_width, wire.world_width)
} else {
(0.0, 0.0)
};
Some(CircleInstance {
center_high: ch,
_pad0: 0.0,
start_width,
center_low: cl,
radius: radius as f32,
axis_x: [axis_x[0] as f32, axis_x[1] as f32, axis_x[2] as f32],
@@ -256,7 +282,7 @@ pub fn extract_circle_instance_from_geom(
axis_y: [axis_y[0] as f32, axis_y[1] as f32, axis_y[2] as f32],
end_angle,
color: wire.color,
params: [hw, wire.pattern_length, draw_depth, 0.0],
params: [hw, wire.pattern_length, draw_depth, end_width],
pat0,
pat1,
})
@@ -265,14 +291,14 @@ pub fn extract_circle_instance_from_geom(
/// Helper to extract all analytical circle/arc instances from a `WireModel`.
///
/// Returns `Some(instances)` if the wire is non-empty, contains solely analytical
/// circle/arc tangent geometries, and has no mesh/pick fills, text, or block instance.
/// circle/arc tangent geometries, and has no mesh fills, text, or block instance.
pub fn extract_circle_instances(
wire: &crate::scene::WireModel,
draw_depth: f32,
) -> Option<Vec<CircleInstance>> {
if wire.tangent_geoms.is_empty()
|| !wire.fill_tris.is_empty()
|| !wire.pick_tris.is_empty()
|| wire.fill_is_3d
|| !wire.text_verts.is_empty()
|| wire.render_instance.is_some()
{
@@ -280,8 +306,8 @@ pub fn extract_circle_instances(
}
let mut instances = Vec::with_capacity(wire.tangent_geoms.len());
for geom in &wire.tangent_geoms {
if let Some(inst) = extract_circle_instance_from_geom(geom, wire, draw_depth) {
for (i, geom) in wire.tangent_geoms.iter().enumerate() {
if let Some(inst) = extract_circle_instance_from_geom_indexed(geom, wire, draw_depth, i) {
instances.push(inst);
} else {
return None;
@@ -410,4 +436,100 @@ mod tests {
};
assert!(extract_circle_instance(&wire, 0.0).is_none());
}
#[test]
fn extract_circle_instance_constant_wide_arc() {
let mut wire = crate::scene::WireModel::default();
wire.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::Arc {
center: [100.0, 50.0, 0.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: 40.0,
start_angle: 0.0,
end_angle: std::f64::consts::PI,
});
wire.world_width = 12.0;
wire.color = [1.0, 0.5, 0.0, 1.0];
let inst = extract_circle_instance(&wire, 0.0).expect("extract constant wide arc");
assert_eq!(inst.start_width, 12.0);
assert_eq!(inst.params[3], 12.0); // end_width
assert_eq!(inst.radius, 40.0);
}
#[test]
fn extract_circle_instance_tapered_arc() {
let mut wire = crate::scene::WireModel::default();
wire.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::Arc {
center: [0.0, 0.0, 0.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: 30.0,
start_angle: 0.2,
end_angle: 1.8,
});
wire.world_width = 20.0;
wire.taper_widths = vec![4.0, 20.0];
wire.color = [0.2, 0.8, 1.0, 1.0];
let inst = extract_circle_instance(&wire, 0.0).expect("extract tapered arc");
assert_eq!(inst.start_width, 4.0);
assert_eq!(inst.params[3], 20.0); // end_width
assert_eq!(inst.radius, 30.0);
}
#[test]
fn extract_circle_instance_accepts_pick_tris() {
let mut wire = crate::scene::WireModel::default();
wire.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::Circle {
center: [5.0, 5.0, 0.0],
radius: 15.0,
});
wire.world_width = 6.0;
wire.pick_tris = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
wire.pick_tris_low = vec![[0.0; 3]; 3];
let insts = extract_circle_instances(&wire, 0.0).expect("wide curve with pick_tris should extract");
assert_eq!(insts.len(), 1);
assert_eq!(insts[0].start_width, 6.0);
assert_eq!(insts[0].params[3], 6.0);
}
#[test]
fn test_donut_polyline_tessellates_to_analytical_arcs() {
use acadrust::CadDocument;
let mut doc = CadDocument::new();
let donut = crate::modules::draw::draw::donut::make_donut(10.0, 20.0, 0.0, 10.0, 30.0);
let h = doc.add_entity(donut).unwrap();
let entity = doc.get_entity(h).unwrap();
let selected = rustc_hash::FxHashSet::default();
let wires = crate::scene::convert::tess::tessellate_entity(
&doc,
&selected,
None,
[0.0, 0.0, 0.0, 1.0],
1.0,
None,
entity,
None,
None,
None,
false,
);
assert_eq!(wires.len(), 2, "donut should split into 2 analytical arc wires");
for w in &wires {
assert_eq!(w.world_width, 20.0);
assert_eq!(w.tangent_geoms.len(), 1);
assert!(matches!(w.tangent_geoms[0], crate::scene::model::wire_model::TangentGeom::Arc { .. }));
}
let depth_map = rustc_hash::FxHashMap::default();
let partitioned = crate::scene::pipeline::wire_arena::partition_wires(&wires, &depth_map);
assert_eq!(partitioned.circle_instances.len(), 2);
assert_eq!(partitioned.regular.len(), 0);
assert_eq!(partitioned.circle_instances[0].start_width, 20.0);
assert_eq!(partitioned.circle_instances[0].params[3], 20.0);
assert_eq!(partitioned.circle_instances[1].start_width, 20.0);
assert_eq!(partitioned.circle_instances[1].params[3], 20.0);
}
}
+2 -2
View File
@@ -145,7 +145,7 @@ pub fn create_pipelines(
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth24PlusStencil8,
depth_write_enabled: Some(true),
depth_write_enabled: Some(false),
depth_compare: Some(wgpu::CompareFunction::LessEqual),
stencil: content_stencil.clone(),
bias: wgpu::DepthBiasState::default(),
@@ -254,7 +254,7 @@ pub fn extract_ellipse_instances(
) -> Option<Vec<EllipseInstance>> {
if wire.tangent_geoms.is_empty()
|| !wire.fill_tris.is_empty()
|| !wire.pick_tris.is_empty()
|| wire.fill_is_3d
|| !wire.text_verts.is_empty()
|| wire.render_instance.is_some()
{
+193
View File
@@ -521,6 +521,199 @@ fn test_selected_circle_arc_ellipse_highlight_overlay() {
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
}
#[test]
#[ignore = "requires a GPU adapter"]
fn test_thick_and_tapered_arc_gpu_rendering() {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default()))
.expect("GPU adapter");
let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_limits: adapter.limits(),
..Default::default()
}))
.expect("GPU device");
let mut pipeline = Pipeline::new(&device, &queue, wgpu::TextureFormat::Bgra8UnormSrgb);
// 1. Wide circular arc with pick triangles
let mut wide_arc = WireModel::default();
wide_arc.name = "wide_arc".into();
wide_arc.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::Arc {
center: [50.0, 50.0, 0.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: 30.0,
start_angle: 0.0,
end_angle: std::f64::consts::PI,
});
wide_arc.world_width = 10.0;
wide_arc.color = [1.0, 0.2, 0.2, 1.0];
wide_arc.pick_tris = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
wide_arc.pick_tris_low = vec![[0.0; 3]; 3];
// 2. Tapered circular arc
let mut tapered_arc = WireModel::default();
tapered_arc.name = "tapered_arc".into();
tapered_arc.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::Arc {
center: [150.0, 50.0, 0.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: 40.0,
start_angle: 0.2,
end_angle: 2.8,
});
tapered_arc.world_width = 16.0;
tapered_arc.taper_widths = vec![2.0, 16.0];
tapered_arc.color = [0.2, 0.8, 1.0, 1.0];
let wires = vec![wide_arc, tapered_arc];
let depth_map = rustc_hash::FxHashMap::default();
let circles = pipeline.upload_circles(&device, &queue, &wires, &depth_map);
// Both curves must be routed to GPU analytical circle instances!
assert!(!circles.is_empty(), "analytical circles must be uploaded");
assert_eq!(circles[0].instance_count, 2);
pipeline.gpu_circles = std::sync::Arc::new(circles);
assert_eq!(pipeline.gpu_circles.len(), 1);
assert_eq!(pipeline.gpu_circles[0].instance_count, 2);
pipeline.ensure_depth_texture(&device, iced::Size::new(512, 512));
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("thick_arc_target"),
size: wgpu::Extent3d {
width: 512,
height: 512,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Bgra8UnormSrgb,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
});
let target = texture.create_view(&wgpu::TextureViewDescriptor::default());
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("thick_arc_encoder"),
});
pipeline.render(
&mut encoder,
&target,
iced::Size::new(512, 512),
iced::Rectangle { x: 0, y: 0, width: 512, height: 512 },
[0.0, 0.0, 0.0, 1.0],
false,
false,
false,
);
queue.submit(Some(encoder.finish()));
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
}
#[test]
#[ignore = "requires a GPU adapter"]
fn test_tilted_3d_donut_and_thick_arc_gpu_rendering() {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default()))
.expect("GPU adapter");
let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor {
required_limits: adapter.limits(),
..Default::default()
}))
.expect("GPU device");
let mut pipeline = Pipeline::new(&device, &queue, wgpu::TextureFormat::Bgra8UnormSrgb);
// Setup 3D perspective camera with acute tilt
let mut camera = crate::scene::view::camera::Camera::default();
camera.projection = crate::scene::view::camera::Projection::Perspective;
camera.distance = 200.0;
camera.target = glam::DVec3::new(50.0, 50.0, 0.0);
camera.rotation = glam::Quat::from_axis_angle(glam::Vec3::X, 1.1); // ~63 degree tilt (acute perspective)
let bounds = iced::Rectangle::new(iced::Point::ORIGIN, iced::Size::new(512.0, 512.0));
let uniforms = Uniforms::new(&camera, bounds, false);
pipeline.upload_uniforms(&device, &queue, &uniforms);
// Donut circular arc segments
let mut donut = WireModel::default();
donut.name = "donut".into();
donut.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::Arc {
center: [50.0, 50.0, 0.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: 30.0,
start_angle: 0.0,
end_angle: std::f64::consts::PI,
});
donut.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::Arc {
center: [50.0, 50.0, 0.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: 30.0,
start_angle: std::f64::consts::PI,
end_angle: std::f64::consts::TAU,
});
donut.world_width = 12.0;
donut.color = [0.2, 0.9, 0.3, 1.0];
let wires = vec![donut];
let depth_map = rustc_hash::FxHashMap::default();
let circles = pipeline.upload_circles(&device, &queue, &wires, &depth_map);
assert_eq!(circles.len(), 1);
assert_eq!(circles[0].instance_count, 2);
pipeline.gpu_circles = std::sync::Arc::new(circles);
// Wide straight polyline segment on the 3D plane
let mut straight_wide = WireModel::default();
straight_wide.name = "wide_straight".into();
straight_wide.points = vec![[0.0, 0.0, 0.0], [50.0, 50.0, 0.0]];
straight_wide.world_width = 10.0;
straight_wide.color = [0.9, 0.2, 0.3, 1.0];
let wide_wires = vec![straight_wide];
let gpu_wires = wire_gpu::WireGpu::from_run(
&device,
&queue,
&wide_wires,
&depth_map,
false,
pipeline.wire_const_bgl.as_ref(),
);
assert!(!gpu_wires.is_empty());
pipeline.gpu_wires = std::sync::Arc::new(gpu_wires);
pipeline.ensure_depth_texture(&device, iced::Size::new(512, 512));
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("tilted_donut_target"),
size: wgpu::Extent3d {
width: 512,
height: 512,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Bgra8UnormSrgb,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
});
let target = texture.create_view(&wgpu::TextureViewDescriptor::default());
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("tilted_donut_encoder"),
});
pipeline.render(
&mut encoder,
&target,
iced::Size::new(512, 512),
iced::Rectangle { x: 0, y: 0, width: 512, height: 512 },
[0.0, 0.0, 0.0, 1.0],
false,
false,
false,
);
queue.submit(Some(encoder.finish()));
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
}
#[test]
#[ignore = "requires a GPU adapter"]
fn test_selected_ellipse_overlay() {
+18 -11
View File
@@ -568,12 +568,15 @@ struct MeshSurfaceParams {
flags: [u32; 4],
}
/// Downscale a decoded RGBA buffer so neither dimension exceeds `limit`,
/// preserving aspect ratio (scaled by whichever dimension overshoots more).
/// `None` only for genuinely malformed input (`rgba`'s length doesn't match
/// `width * height * 4`) — the caller falls back to the 1x1 material color
/// in that case, same as it already does for a missing image entirely.
fn valid_rgba_size(width: u32, height: u32, pixels: &[u8]) -> bool {
width > 0 && height > 0
&& (width as usize).checked_mul(height as usize).and_then(|size| size.checked_mul(4)) == Some(pixels.len())
}
fn downscale_rgba_to_limit(width: u32, height: u32, rgba: &[u8], limit: u32) -> Option<(u32, u32, Vec<u8>)> {
if limit == 0 || !valid_rgba_size(width, height, rgba) {
return None;
}
let buffer = image::RgbaImage::from_raw(width, height, rgba.to_vec())?;
let scale = (limit as f64 / width.max(height) as f64).min(1.0);
let new_width = ((width as f64 * scale).round() as u32).clamp(1, limit);
@@ -590,14 +593,9 @@ fn upload_rgba_texture(
fallback: [u8; 4],
srgb: bool,
) -> wgpu::TextureView {
// A material map's decoded dimensions come straight from the source file
// (`material_model::load_map_image`), uncapped — an oversized image would
// otherwise fail `wgpu` texture-size validation and leave the material
// unusable (audit: raster-image rendering already tiles to this same
// limit in `image_gpu.rs`; a mesh material sampled by UV can't use that
// per-quad tiling trick, so downscale instead of tiling).
let limit = device.limits().max_texture_dimension_2d.max(1);
let resized;
let image = image.filter(|image| valid_rgba_size(image.width, image.height, &image.rgba));
let (width, height, pixels): (u32, u32, &[u8]) = match image {
Some(image) if image.width > limit || image.height > limit => {
match downscale_rgba_to_limit(image.width, image.height, &image.rgba, limit) {
@@ -2494,4 +2492,13 @@ mod texture_limit_tests {
let rgba = vec![0u8; 10];
assert!(downscale_rgba_to_limit(1000, 1000, &rgba, 512).is_none());
}
#[test]
fn malformed_material_images_are_rejected_even_below_the_texture_limit() {
for (width, height, pixels) in [(0, 1, vec![]), (1, 0, vec![]), (1, 1, vec![0; 3]), (1, 1, vec![0; 5])] {
assert!(!super::valid_rgba_size(width, height, &pixels));
assert!(downscale_rgba_to_limit(width, height, &pixels, 16).is_none());
}
assert!(downscale_rgba_to_limit(1, 1, &[0; 4], 0).is_none());
}
}
+205 -78
View File
@@ -2449,11 +2449,17 @@ impl Pipeline {
if instances.is_empty() {
vec![]
} else {
let mut sorted = instances.to_vec();
sorted.sort_by(|a, b| {
a.params[2]
.partial_cmp(&b.params[2])
.unwrap_or(std::cmp::Ordering::Equal)
});
vec![CircleGpu::from_instances(
device,
queue,
"viewer.circles",
instances,
&sorted,
)]
}
}
@@ -2489,11 +2495,17 @@ impl Pipeline {
if instances.is_empty() {
vec![]
} else {
let mut sorted = instances.to_vec();
sorted.sort_by(|a, b| {
a.params[2]
.partial_cmp(&b.params[2])
.unwrap_or(std::cmp::Ordering::Equal)
});
vec![EllipseGpu::from_instances(
device,
queue,
"viewer.ellipses",
instances,
&sorted,
)]
}
}
@@ -2612,6 +2624,72 @@ impl Pipeline {
/// refreshes just this overlay instead of re-tessellating the model. The
/// xray pass applies neither scissor nor mesh-edge skip, so everything
/// merges into one order-preserving run.
/// Split highlighted wires into analytic instances and the wires that need
/// real geometry, in one extraction pass per wire.
#[allow(clippy::type_complexity)]
fn classify_highlight_wires<'a>(
wires: &[&'a WireModel],
color: Option<[f32; 4]>,
depth_map: &rustc_hash::FxHashMap<u64, [f32; 2]>,
) -> (
Vec<CircleInstance>,
Vec<EllipseInstance>,
Vec<&'a WireModel>,
Vec<&'a WireModel>,
) {
use crate::par::prelude::*;
// Pure per-wire work, so it fans out; `collect` on an indexed
// parallel iterator keeps the order, and the order is what decides
// the instance layout.
let per: Vec<(Option<Vec<CircleInstance>>, Option<Vec<EllipseInstance>>, bool)> =
wires
.par_iter()
.map(|&wire| {
if wire.render_instance.is_some() {
return (None, None, true);
}
let depth = wire_gpu::wire_draw_depth(wire, depth_map);
let mut circles = circle_gpu::extract_circle_instances(wire, depth);
let mut ellipses = ellipse_gpu::extract_ellipse_instances(wire, depth);
if let Some(color) = color {
if let Some(instances) = circles.as_mut() {
for instance in instances {
instance.color = color;
}
}
if let Some(instances) = ellipses.as_mut() {
for instance in instances {
instance.color = color;
}
}
}
(circles, ellipses, false)
})
.collect();
let mut circles: Vec<CircleInstance> = Vec::new();
let mut ellipses: Vec<EllipseInstance> = Vec::new();
let mut regular: Vec<&'a WireModel> = Vec::new();
let mut blocks: Vec<&'a WireModel> = Vec::new();
for (&wire, (wire_circles, wire_ellipses, is_block)) in wires.iter().zip(per) {
if is_block {
blocks.push(wire);
continue;
}
let shaped = wire_circles.is_some() || wire_ellipses.is_some();
if let Some(instances) = wire_circles {
circles.extend(instances);
}
if let Some(instances) = wire_ellipses {
ellipses.extend(instances);
}
if !shaped {
regular.push(wire);
}
}
(circles, ellipses, regular, blocks)
}
pub fn upload_selected_wires(
&mut self,
device: &wgpu::Device,
@@ -2637,9 +2715,11 @@ impl Pipeline {
let mut hover_wires: Vec<&WireModel> = Vec::new();
for h in selected {
if let Some(idxs) = self.wire_handle_index.get(&h.value()) {
let mut slots = idxs.clone();
slots.sort_unstable();
for &i in &slots {
debug_assert!(
idxs.windows(2).all(|pair| pair[0] <= pair[1]),
"wire_handle_index slots must stay ascending",
);
for &i in idxs {
if let Some(w) = wires.get(i as usize) {
if !w.display_visible {
continue;
@@ -2651,9 +2731,7 @@ impl Pipeline {
}
for h in hovered.iter().filter(|handle| !selected.contains(handle)) {
if let Some(idxs) = self.wire_handle_index.get(&h.value()) {
let mut slots = idxs.clone();
slots.sort_unstable();
for &i in &slots {
for &i in idxs {
if let Some(w) = wires.get(i as usize) {
if !w.display_visible {
continue;
@@ -2673,26 +2751,23 @@ impl Pipeline {
hover_wires.push(wire);
}
}
let mut selected_circles: Vec<CircleInstance> = Vec::new();
let circle_sel_tint = selected_tint.unwrap_or(WireModel::SELECTED);
for &wire in &selected_wires {
let depth = wire_gpu::wire_draw_depth(wire, depth_map);
if let Some(insts) = circle_gpu::extract_circle_instances(wire, depth) {
for mut inst in insts {
inst.color = circle_sel_tint;
selected_circles.push(inst);
}
}
}
for &wire in &hover_wires {
let depth = wire_gpu::wire_draw_depth(wire, depth_map);
if let Some(insts) = circle_gpu::extract_circle_instances(wire, depth) {
for mut inst in insts {
inst.color = WireModel::HOVER;
selected_circles.push(inst);
}
}
}
let t_gather = perf_started.map(|t| t.elapsed().as_secs_f64() * 1000.0);
let (mut selected_circles, mut selected_ellipses, selected_regular, selected_blocks) =
Self::classify_highlight_wires(
&selected_wires,
Some(selected_tint.unwrap_or(WireModel::SELECTED)),
depth_map,
);
let (hover_circles, hover_ellipses, hover_regular, hover_blocks) =
Self::classify_highlight_wires(
&hover_wires,
Some(WireModel::HOVER),
depth_map,
);
selected_circles.extend(hover_circles);
selected_ellipses.extend(hover_ellipses);
let t_shapes = perf_started.map(|t| t.elapsed().as_secs_f64() * 1000.0);
self.gpu_selected_circles = if selected_circles.is_empty() {
vec![]
} else {
@@ -2703,27 +2778,6 @@ impl Pipeline {
&selected_circles,
)]
};
let mut selected_ellipses: Vec<EllipseInstance> = Vec::new();
let ellipse_sel_tint = selected_tint.unwrap_or(WireModel::SELECTED);
for &wire in &selected_wires {
let depth = wire_gpu::wire_draw_depth(wire, depth_map);
if let Some(insts) = ellipse_gpu::extract_ellipse_instances(wire, depth) {
for mut inst in insts {
inst.color = ellipse_sel_tint;
selected_ellipses.push(inst);
}
}
}
for &wire in &hover_wires {
let depth = wire_gpu::wire_draw_depth(wire, depth_map);
if let Some(insts) = ellipse_gpu::extract_ellipse_instances(wire, depth) {
for mut inst in insts {
inst.color = WireModel::HOVER;
selected_ellipses.push(inst);
}
}
}
self.gpu_selected_ellipses = if selected_ellipses.is_empty() {
vec![]
} else {
@@ -2734,35 +2788,7 @@ impl Pipeline {
&selected_ellipses,
)]
};
let selected_regular: Vec<&WireModel> = selected_wires
.iter()
.copied()
.filter(|wire| {
wire.render_instance.is_none()
&& circle_gpu::extract_circle_instances(wire, 0.0).is_none()
&& ellipse_gpu::extract_ellipse_instances(wire, 0.0).is_none()
})
.collect();
let hover_regular: Vec<&WireModel> = hover_wires
.iter()
.copied()
.filter(|wire| {
wire.render_instance.is_none()
&& circle_gpu::extract_circle_instances(wire, 0.0).is_none()
&& ellipse_gpu::extract_ellipse_instances(wire, 0.0).is_none()
})
.collect();
let selected_blocks: Vec<&WireModel> = selected_wires
.iter()
.copied()
.filter(|wire| wire.render_instance.is_some())
.collect();
let hover_blocks: Vec<&WireModel> = hover_wires
.iter()
.copied()
.filter(|wire| wire.render_instance.is_some())
.collect();
let t_split = perf_started.map(|t| t.elapsed().as_secs_f64() * 1000.0);
let mut gpu = if let Some(tint) = selected_tint {
WireGpu::from_highlight_refs(
device,
@@ -2783,6 +2809,7 @@ impl Pipeline {
depth_map,
self.wire_const_bgl.as_ref(),
));
let t_regular = perf_started.map(|t| t.elapsed().as_secs_f64() * 1000.0);
self.gpu_selected_wires = gpu;
let mut block_gpu = if let Some(tint) = selected_tint {
BlockWireGpu::from_wires(
@@ -2816,6 +2843,18 @@ impl Pipeline {
if let Some(started) = perf_started {
let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0;
if elapsed_ms >= 1.0 {
let gather = t_gather.unwrap_or(0.0);
let classify = t_shapes.unwrap_or(0.0);
let analytic = t_split.unwrap_or(0.0);
let regular = t_regular.unwrap_or(0.0);
crate::perf_record!(
"[perf] wire-highlight-detail gather={gather:.1} classify={:.1} \
analytic={:.1} regular={:.1} blocks={:.1}",
classify - gather,
analytic - classify,
regular - analytic,
elapsed_ms - regular,
);
crate::perf_record!(
"[perf] wire-highlight {:>7.1}ms handles={} wires={}",
elapsed_ms,
@@ -5627,3 +5666,91 @@ impl iced::widget::shader::Pipeline for MultiPipeline {
}
}
}
#[cfg(test)]
mod highlight_classification_tests {
use super::*;
use crate::scene::model::wire_model::TangentGeom;
fn plain(name: &str) -> WireModel {
WireModel::solid(
name.to_string(),
vec![[0.0, 0.0, 0.0], [1.0, 1.0, 0.0]],
[1.0; 4],
false,
)
}
fn circle(name: &str) -> WireModel {
let mut wire = plain(name);
wire.tangent_geoms.push(TangentGeom::PlanarCircle {
center: [0.0, 0.0, 0.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: 2.0,
});
wire
}
fn ellipse(name: &str) -> WireModel {
let mut wire = plain(name);
wire.tangent_geoms.push(TangentGeom::PlanarEllipse {
center: [0.0, 0.0, 0.0],
major_axis: [2.0, 0.0, 0.0],
normal: [0.0, 0.0, 1.0],
minor_axis_ratio: 0.5,
start_param: 0.0,
end_param: std::f64::consts::TAU,
});
wire
}
#[test]
fn each_wire_lands_in_the_bucket_the_old_predicate_chose() {
let wires = vec![plain("1"), circle("2"), ellipse("3"), plain("4")];
let refs: Vec<&WireModel> = wires.iter().collect();
let depth_map = rustc_hash::FxHashMap::default();
let (circles, ellipses, regular, blocks) =
Pipeline::classify_highlight_wires(&refs, None, &depth_map);
for wire in &refs {
let was_regular = wire.render_instance.is_none()
&& circle_gpu::extract_circle_instances(wire, 0.0).is_none()
&& ellipse_gpu::extract_ellipse_instances(wire, 0.0).is_none();
assert_eq!(
was_regular,
regular.iter().any(|kept| std::ptr::eq(*kept, *wire)),
"wire {} disagrees with the old predicate",
wire.name,
);
}
assert_eq!(regular.len(), 2, "two plain lines");
assert_eq!(circles.len(), 1, "one circle instance");
assert_eq!(ellipses.len(), 1, "one ellipse instance");
assert!(blocks.is_empty(), "no block wires in this sample");
}
// Hover recolours every instance; a selection only recolours when a tint is
// configured. Both go through the same argument, so it has to actually
// reach the instances.
#[test]
fn the_colour_override_reaches_the_instances() {
let wires = vec![circle("2"), ellipse("3")];
let refs: Vec<&WireModel> = wires.iter().collect();
let depth_map = rustc_hash::FxHashMap::default();
let (tinted_circles, tinted_ellipses, _, _) =
Pipeline::classify_highlight_wires(&refs, Some(WireModel::HOVER), &depth_map);
assert_eq!(tinted_circles[0].color, WireModel::HOVER);
assert_eq!(tinted_ellipses[0].color, WireModel::HOVER);
let (plain_circles, _, _, _) =
Pipeline::classify_highlight_wires(&refs, None, &depth_map);
assert_ne!(
plain_circles[0].color, WireModel::HOVER,
"without a tint the extractor's own colour stands",
);
}
}
+45 -2
View File
@@ -146,7 +146,7 @@ pub fn classify_wire(wire: &WireModel) -> WireKind {
}
if !wire.tangent_geoms.is_empty()
&& wire.fill_tris.is_empty()
&& wire.pick_tris.is_empty()
&& !wire.fill_is_3d
&& wire.text_verts.is_empty()
{
if super::circle_gpu::extract_circle_instances(wire, 0.0).is_some() {
@@ -219,7 +219,7 @@ pub fn partition_wires<'a>(
// Resolve draw depth only for wires eligible for analytical extraction.
if !wire.tangent_geoms.is_empty()
&& wire.fill_tris.is_empty()
&& wire.pick_tris.is_empty()
&& !wire.fill_is_3d
&& wire.text_verts.is_empty()
{
let depth = super::wire_gpu::wire_draw_depth(wire, depth_map);
@@ -1709,4 +1709,47 @@ mod tests {
assert_eq!(partitioned.ellipse_instances.len(), 1);
assert_eq!(partitioned.regular.len(), 1);
}
#[test]
fn partition_wires_partitions_thick_and_tapered_arcs() {
use crate::scene::model::wire_model::TangentGeom;
// Wide arc with pick triangles
let mut wide_arc = WireModel::default();
wide_arc.tangent_geoms.push(TangentGeom::Arc {
center: [10.0, 20.0, 0.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: 25.0,
start_angle: 0.0,
end_angle: std::f64::consts::PI,
});
wide_arc.world_width = 8.0;
wide_arc.pick_tris = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
wide_arc.pick_tris_low = vec![[0.0; 3]; 3];
// Tapered arc
let mut tapered_arc = WireModel::default();
tapered_arc.tangent_geoms.push(TangentGeom::Arc {
center: [50.0, 60.0, 0.0],
axis_x: [1.0, 0.0, 0.0],
axis_y: [0.0, 1.0, 0.0],
radius: 15.0,
start_angle: 0.5,
end_angle: 2.5,
});
tapered_arc.world_width = 10.0;
tapered_arc.taper_widths = vec![2.0, 10.0];
let wires = vec![wide_arc, tapered_arc];
let depth_map = rustc_hash::FxHashMap::default();
let partitioned = partition_wires(&wires, &depth_map);
assert_eq!(partitioned.circle_instances.len(), 2);
assert_eq!(partitioned.regular.len(), 0);
assert_eq!(partitioned.circle_instances[0].start_width, 8.0);
assert_eq!(partitioned.circle_instances[0].params[3], 8.0);
assert_eq!(partitioned.circle_instances[1].start_width, 2.0);
assert_eq!(partitioned.circle_instances[1].params[3], 10.0);
}
}
+57 -2
View File
@@ -532,7 +532,35 @@ fn finite3(p: [f32; 3]) -> bool {
fn marker_metadata(wire: &WireModel) -> ([f32; 4], [f32; 4], [f32; 4]) {
let Some(marker) = wire.point_marker else {
return ([0.0; 4], [0.0; 4], [0.0; 4]);
for tg in &wire.tangent_geoms {
match tg {
crate::scene::model::wire_model::TangentGeom::Arc { axis_x, axis_y, .. }
| crate::scene::model::wire_model::TangentGeom::PlanarCircle { axis_x, axis_y, .. } => {
let ax = glam::DVec3::from_array(*axis_x);
let ay = glam::DVec3::from_array(*axis_y);
let n = ax.cross(ay).normalize_or(glam::DVec3::Z);
return ([0.0; 4], [0.0; 4], [n.x as f32, n.y as f32, n.z as f32, 0.0]);
}
crate::scene::model::wire_model::TangentGeom::PlanarEllipse { normal, .. } => {
let n = glam::DVec3::from_array(*normal).normalize_or(glam::DVec3::Z);
return ([0.0; 4], [0.0; 4], [n.x as f32, n.y as f32, n.z as f32, 0.0]);
}
_ => {}
}
}
if wire.points.len() >= 3 {
let p0 = glam::Vec3::from_array(wire.points[0]);
for i in 1..wire.points.len() - 1 {
let v1 = glam::Vec3::from_array(wire.points[i]) - p0;
let v2 = glam::Vec3::from_array(wire.points[i + 1]) - p0;
let cross = v1.cross(v2);
if cross.length_squared() > 1e-6 {
let n = cross.normalize();
return ([0.0; 4], [0.0; 4], [n.x, n.y, n.z, 0.0]);
}
}
}
return ([0.0; 4], [0.0; 4], [0.0, 0.0, 1.0, 0.0]);
};
let origin = marker.origin;
let (hx, lx) = WireModel::split_ds(origin.x);
@@ -1148,8 +1176,9 @@ impl WireGpu {
let _ = const_bgl;
let max_packed_instances = super::gpu_budget::max_elements::<PackedWireInstance>(device);
use crate::par::prelude::*;
let per: Vec<Vec<PackedWireInstance>> = wires
.iter()
.par_iter()
.map(|wire| {
emit_wire_packed(wire, color, wire_draw_depth(wire, depth_map))
})
@@ -1463,4 +1492,30 @@ mod block_wire_storage_tests {
};
assert_eq!(names(&packed), names(&storage));
}
#[test]
fn wire_shader_validates_with_naga() {
let source = include_str!("../../shaders/wire.wgsl");
let module = naga::front::wgsl::parse_str(source).expect("wire.wgsl parses cleanly");
let mut validator = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
);
validator
.validate(&module)
.expect("wire.wgsl validates cleanly with naga");
}
#[test]
fn wire_indexed_shader_validates_with_naga() {
let source = include_str!("../../shaders/wire_indexed.wgsl");
let module = naga::front::wgsl::parse_str(source).expect("wire_indexed.wgsl parses cleanly");
let mut validator = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
);
validator
.validate(&module)
.expect("wire_indexed.wgsl validates cleanly with naga");
}
}
-27
View File
@@ -1042,15 +1042,6 @@ pub fn document_block_uses(document: &CadDocument) -> Vec<BlockUse> {
uses
}
/// A MINSERT's row/column counts are parsed `u16`s whose unchecked product
/// (`Insert::instance_count`) would otherwise drive this function's
/// allocation and a full per-instance render-graph walk after it — a corrupt
/// or adversarial 65535x65535 pair requests over four billion instances (tens
/// of GiB) long before any GPU-side chunking limit could help. The primary
/// defense is the load-time filter in `io::is_entity_corrupt` (kept in sync
/// with this constant), which drops such an insert before it ever reaches a
/// `Scene`; this is the failsafe for an insert built or edited some other way
/// (API/scripting) that skips that path.
const MAX_MINSERT_INSTANCES: usize = 20_000;
pub fn array_offsets(insert: &Insert) -> Vec<[f64; 3]> {
@@ -1093,16 +1084,6 @@ pub fn insert_instance_transform(
if offset == [0.0; 3] {
transform
} else {
// Row/column spacing is defined in the insert's rotated/OCS-oriented
// space but is NOT subject to the block's x/y/z scale — this matches
// the pinned CAD model's own `Insert::array_points`, which rotates
// the offset but never scales it. Composing the raw offset ahead of
// the full (scaled) transform, as this used to do, let the block
// scale multiply the spacing too (e.g. an x-scale-2 insert with
// column spacing 10 would render 20 units between columns). Instead,
// orient the offset by rotation + OCS only, then translate the
// already-composed (scaled) instance transform by that world-space
// vector, applied after — never scaled.
let orientation = Transform::from_matrix(Matrix4::rotation_z(insert.rotation))
.then(&Transform::from_matrix(Matrix4::from_matrix3(Matrix3::arbitrary_axis(insert.normal))));
let world_offset = orientation.apply_rotation(Vector3::new(offset[0], offset[1], offset[2]));
@@ -1201,10 +1182,6 @@ mod tests {
#[test]
fn array_offsets_clamps_pathological_minsert_counts() {
// A corrupt or adversarial row/column pair (parsed as u16, so this is
// reachable from a malformed file) must not drive a multi-billion-
// element allocation; the primary defense is the load-time filter in
// `io::is_entity_corrupt`, this is the render-graph-side failsafe.
let mut insert = Insert::new("BLOCK", Vector3::ZERO);
insert.row_count = u16::MAX;
insert.column_count = u16::MAX;
@@ -1230,10 +1207,6 @@ mod tests {
#[test]
fn insert_instance_transform_does_not_scale_the_row_column_spacing() {
// github discussion-derived report: an INSERT with x-scale 2 and
// column spacing 10 was rendering 20 drawing units between columns
// instead of 10, because the offset translation was composed ahead
// of (and thus multiplied by) the block scale.
let document = CadDocument::new();
let mut insert = Insert::new("BLOCK", Vector3::new(100.0, 0.0, 0.0));
insert.set_x_scale(2.0);
+249 -16
View File
@@ -4,32 +4,96 @@ impl Scene {
// ── Selection ─────────────────────────────────────────────────────────
/// Treat a classic LEADER and its attached annotation as one logical object.
/// Clicking/copying/deleting either side expands to the complete pair.
pub(crate) fn handles_expanded_for_leader_annotations(
/// Every LEADER that points at `annotation`, resolved once per
/// `geometry_epoch` rather than by walking the document per handle.
fn leaders_by_annotation(
&self,
handles: &[Handle],
) -> Vec<Handle> {
let mut expanded = handles.to_vec();
) -> std::cell::Ref<'_, (u64, HashMap<Handle, Vec<Handle>>)> {
{
let cache = self.leaders_by_annotation_cache.borrow();
if cache.as_ref().is_some_and(|(epoch, _)| *epoch == self.geometry_epoch) {
drop(cache);
return std::cell::Ref::map(
self.leaders_by_annotation_cache.borrow(),
|c| c.as_ref().unwrap(),
);
}
}
let mut by_annotation: HashMap<Handle, Vec<Handle>> = HashMap::default();
for entity in self.document.entities() {
if let EntityType::Leader(leader) = entity {
if !leader.annotation_handle.is_null() {
by_annotation
.entry(leader.annotation_handle)
.or_default()
.push(entity.common().handle);
}
}
}
*self.leaders_by_annotation_cache.borrow_mut() =
Some((self.geometry_epoch, by_annotation));
std::cell::Ref::map(self.leaders_by_annotation_cache.borrow(), |c| {
c.as_ref().unwrap()
})
}
fn expanded_with_leaders(&self, handles: &[Handle]) -> Vec<Handle> {
let leaders = self.leaders_by_annotation();
let mut expanded = Vec::with_capacity(handles.len());
for &handle in handles {
// LEADER -> annotation.
let start = expanded.len();
expanded.push(handle);
if let Some(EntityType::Leader(leader)) = self.document.get_entity(handle) {
if !leader.annotation_handle.is_null() {
expanded.push(leader.annotation_handle);
}
}
// Annotation -> LEADER.
expanded.extend(self.document.entities().filter_map(|entity| match entity {
EntityType::Leader(leader)
if !leader.annotation_handle.is_null()
&& leader.annotation_handle == handle =>
{
Some(entity.common().handle)
}
_ => None,
}));
if let Some(pointing) = leaders.1.get(&handle) {
expanded.extend(pointing.iter().copied());
}
expanded[start..].sort_unstable_by_key(Handle::value);
}
expanded
}
pub fn select_entities(&mut self, handles: &[Handle]) {
if handles.is_empty() {
return;
}
let expanded = self.expanded_with_leaders(handles);
let mut changed = false;
for handle in expanded {
if self.selected.insert(handle) {
self.selected_order.push(handle);
changed = true;
}
}
if changed {
self.bump_selection_set();
}
}
pub fn deselect_entities(&mut self, handles: &[Handle]) {
if handles.is_empty() {
return;
}
let doomed: HashSet<Handle> =
self.expanded_with_leaders(handles).into_iter().collect();
let mut changed = false;
for handle in &doomed {
changed |= self.selected.remove(handle);
}
if changed {
self.selected_order.retain(|handle| !doomed.contains(handle));
self.bump_selection_set();
}
}
pub(crate) fn handles_expanded_for_leader_annotations(
&self,
handles: &[Handle],
) -> Vec<Handle> {
let mut expanded = self.expanded_with_leaders(handles);
expanded.sort_unstable_by_key(|handle| handle.value());
expanded.dedup();
expanded
@@ -874,12 +938,57 @@ impl Scene {
if respect_layer_locks && self.is_layer_locked(h) {
continue;
}
let settings_handle = self.document.get_entity(h).and_then(|entity| match entity {
EntityType::Extended(acadrust::entities::ExtendedEntity {
data: acadrust::entities::ExtendedEntityData::SectionObject(data),
..
}) if !data.settings_handle.is_null() => Some(data.settings_handle),
_ => None,
});
let section_managers: Vec<Handle> = self
.document
.objects
.iter()
.filter_map(|(handle, object)| match object {
ObjectType::ClassObject(object) => match &object.data {
acadrust::objects::ClassObjectData::SectionManager(manager)
if manager.sections.contains(&h) =>
{
Some(*handle)
}
_ => None,
},
_ => None,
})
.collect();
// Delta-undo: capture the removed entity so an undo can re-insert it.
if self.is_recording_undo() {
let before = self.document.get_entity_arc(h);
self.record_undo_before(h, before);
if let Some(settings_handle) = settings_handle {
let before = self.document.objects.get(&settings_handle).cloned();
self.record_undo_object_before(settings_handle, before);
}
for &manager_handle in &section_managers {
let before = self.document.objects.get(&manager_handle).cloned();
self.record_undo_object_before(manager_handle, before);
}
}
self.delete_solid_history(h);
if let Some(settings_handle) = settings_handle {
self.document.objects.remove(&settings_handle);
}
for manager_handle in section_managers {
if let Some(ObjectType::ClassObject(object)) =
self.document.objects.get_mut(&manager_handle)
{
if let acadrust::objects::ClassObjectData::SectionManager(manager) =
&mut object.data
{
manager.sections.retain(|section| *section != h);
}
}
}
self.remember_removed_cache_categories(h);
self.document.remove_entity_arc(h);
selection_changed |= self.selected.remove(&h);
@@ -1003,6 +1112,130 @@ impl Scene {
mod tests {
use super::*;
#[test]
fn bulk_selection_matches_selecting_one_at_a_time() {
use acadrust::entities::Line;
use acadrust::types::Vector3;
let build = || {
let mut scene = Scene::new();
let handles: Vec<_> = (0..40)
.map(|k| {
let x = k as 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),
)))
})
.collect();
(scene, handles)
};
// Picked in an order that is neither creation nor handle order.
let picked = |handles: &[Handle]| -> Vec<Handle> {
let mut order: Vec<_> = handles.iter().copied().collect();
order.reverse();
order.retain(|h| h.value() % 3 != 0);
order
};
let (mut one_at_a_time, handles) = build();
let order = picked(&handles);
for &handle in &order {
one_at_a_time.select_entity(handle, false);
}
let (mut in_bulk, handles) = build();
in_bulk.select_entities(&picked(&handles));
assert_eq!(
in_bulk.selected_handles_in_order(),
one_at_a_time.selected_handles_in_order(),
"the bulk path must select the same entities in the same order",
);
// And removing a subset must agree too.
let drop: Vec<_> = order.iter().copied().take(7).collect();
for &handle in &drop {
one_at_a_time.deselect_entity(handle);
}
in_bulk.deselect_entities(&drop);
assert_eq!(
in_bulk.selected_handles_in_order(),
one_at_a_time.selected_handles_in_order(),
"the bulk removal must leave the same selection in the same order",
);
}
#[test]
fn the_leader_index_matches_a_document_walk() {
use acadrust::entities::Leader;
use acadrust::types::Vector3;
let mut scene = Scene::new();
let line = |x: f64| {
EntityType::Line(acadrust::entities::Line::from_points(
Vector3::new(x, 0.0, 0.0),
Vector3::new(x + 1.0, 0.0, 0.0),
))
};
let annotation = scene.add_entity(line(0.0));
let unrelated = scene.add_entity(line(5.0));
let mut leader_on = |target: Handle| {
let mut leader = Leader::default();
leader.annotation_handle = target;
scene.add_entity(EntityType::Leader(leader))
};
// Two leaders share one annotation; a third points nowhere.
let first = leader_on(annotation);
let second = leader_on(annotation);
let dangling = leader_on(Handle::NULL);
// The walk this replaced, written out so the index is compared against
// behaviour rather than against itself.
let by_walk = |handle: Handle| -> Vec<Handle> {
let mut out = vec![handle];
if let Some(EntityType::Leader(leader)) = scene.document.get_entity(handle) {
if !leader.annotation_handle.is_null() {
out.push(leader.annotation_handle);
}
}
out.extend(scene.document.entities().filter_map(|entity| match entity {
EntityType::Leader(leader)
if !leader.annotation_handle.is_null()
&& leader.annotation_handle == handle =>
{
Some(entity.common().handle)
}
_ => None,
}));
out.sort_unstable_by_key(Handle::value);
out.dedup();
out
};
for handle in [annotation, unrelated, first, second, dangling] {
assert_eq!(
scene.handles_expanded_for_leader_annotations(&[handle]),
by_walk(handle),
"the index must answer exactly what the walk answered",
);
}
let expanded = scene.handles_expanded_for_leader_annotations(&[annotation]);
assert!(
expanded.contains(&first) && expanded.contains(&second),
"both leaders on one annotation must come back, not just one",
);
for handle in [first, annotation, second] {
scene.deselect_all();
scene.select_entity(handle, false);
let expected = scene.selected_handles_in_order();
scene.deselect_all();
scene.select_entities(&[handle]);
assert_eq!(scene.selected_handles_in_order(), expected);
}
}
#[test]
fn adding_a_type_already_present_reuses_the_list() {
use acadrust::entities::{Circle, EntityType, Line};
+12 -15
View File
@@ -246,23 +246,16 @@ impl Camera {
/// A symmetric range gives the bias half the depth buffer of headroom on
/// each side; ortho permits a negative near.
fn ortho_depth_range(&self) -> (f32, f32) {
// Prefer a drawing-sized, zoom-independent half-range so depth-buffer
// precision stays constant as the user zooms. A distance-scaled range
// (the `else`) balloons the near/far span when zoomed out — at large
// `distance` the f32 depth buffer can no longer separate coincident
// solids / meshes / wires, so they flip draw order (issue: meshes drew
// in front of solids only when zoomed out).
// Generous headroom based on the current screen size so that rotating any
// geometry visible on screen in 3D never penetrates the near/far planes.
let view_extent = (self.ortho_size() * 3.0).max(10.0);
let r = if let Some((min, max)) = self.model_bounds {
// Depth extent along the CURRENT eye direction, recomputed each
// frame so orbiting a 3-D drawing never clips it (#473): as the
// view tilts off top, the box's width rotates onto the eye axis and
// the span grows to match. Same tight, zoom-independent precision as
// a fitted scalar, but always oriented to the live view.
self.depth_extent_in_view(min, max)
self.depth_extent_in_view(min, max).max(view_extent)
} else if self.depth_half_range > 0.0 {
self.depth_half_range
self.depth_half_range.max(view_extent)
} else {
(self.distance * 1000.0).max(1.0)
(self.distance * 1000.0).max(view_extent).max(10.0)
};
(self.distance - r, self.distance + r)
}
@@ -590,7 +583,11 @@ impl Camera {
.bounds_in_view(min, max)
.iter()
.fold(0.0_f32, |m, c| m.max(c.z.abs()));
(depth_r * 1.05).max(1.0)
let diag = (max - min).length() as f32;
// Generous headroom (1.5x depth extent + 25% of model diagonal + minimum clearance)
// so wide strokes, lineweights, camera orbit dynamics, and draw-order depth bias
// never penetrate the near clipping plane in orthographic 3D view.
(depth_r * 1.5 + diag * 0.25).max(10.0)
}
/// Fit the camera to `min..max` — pose and depth both.
+79 -36
View File
@@ -121,35 +121,83 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32, in: VertexIn) -> VertexOut
let rel_a = marker_relative(in.pos_a, in.pos_a_low, in.translation, in.translation_low);
let rel_b = marker_relative(in.pos_b, in.pos_b_low, in.translation, in.translation_low);
let clip_a = u.view_rot * vec4<f32>(rel_a, 1.0);
let clip_b = u.view_rot * vec4<f32>(rel_b, 1.0);
let screen_a = clip_a.xy / clip_a.w * u.viewport_size * 0.5;
let screen_b = clip_b.xy / clip_b.w * u.viewport_size * 0.5;
let segment = screen_b - screen_a;
let segment_length = length(segment);
var direction = vec2<f32>(1.0, 0.0);
if segment_length > 1e-4 {
direction = segment / segment_length;
let is_world = wire_const.world_half_width > 0.0 || in.taper_ratio.x > 0.0 || in.taper_ratio.y > 0.0;
var final_clip: vec4<f32>;
var out_dist: f32;
var out_cap: vec2<f32>;
var out_cap_ends: vec3<f32>;
if is_world {
// Wide polylines have physical world-unit width (world_half_width or taper > 0).
// Expand the quad in 3D world space on the entity's plane (perpendicular to
// the segment and the plane normal), so the rectangle remains hosted rigidly
// on the 3D plane when the camera rotates, orbits, or tilts, matching CAD
// behavior and circle.wgsl planar arcs.
let world_hw_a = select(wire_const.world_half_width, in.taper_ratio.x * wire_const.world_half_width, in.taper_ratio.x > 0.0);
let world_hw_b = select(wire_const.world_half_width, in.taper_ratio.y * wire_const.world_half_width, in.taper_ratio.y > 0.0);
let cur_world_hw = mix(world_hw_a, world_hw_b, which_end);
let eff_hw = max(cur_world_hw, 0.5 * u.world_per_pixel);
let world_delta = rel_b - rel_a;
let world_len = length(world_delta);
var world_dir = vec3<f32>(1.0, 0.0, 0.0);
if world_len > 1e-6 {
world_dir = world_delta / world_len;
}
var norm = vec3<f32>(0.0, 0.0, 1.0);
if length(wire_const.marker_normal_scale.xyz) > 1e-4 {
norm = normalize(wire_const.marker_normal_scale.xyz);
}
var perp_world = cross(norm, world_dir);
if length(perp_world) < 1e-4 {
perp_world = cross(vec3<f32>(0.0, 1.0, 0.0), world_dir);
if length(perp_world) < 1e-4 {
perp_world = cross(vec3<f32>(1.0, 0.0, 0.0), world_dir);
}
}
perp_world = normalize(perp_world);
let pos_rel = mix(rel_a, rel_b, which_end);
let world_pos = pos_rel + perp_world * (eff_hw * side);
var clip_pos = u.view_rot * vec4<f32>(world_pos, 1.0);
clip_pos.z = clip_pos.z - in.depth.x * DRAW_ORDER_BIAS * clip_pos.w;
final_clip = clip_pos;
out_dist = mix(in.distances.x, in.distances.y, which_end);
out_cap = vec2<f32>(which_end * world_len, eff_hw * side);
out_cap_ends = vec3<f32>(world_len, world_hw_a, world_hw_b);
} else {
// Thin wires expand in screen pixels facing the camera with rounded end caps / joints.
let clip_a = u.view_rot * vec4<f32>(rel_a, 1.0);
let clip_b = u.view_rot * vec4<f32>(rel_b, 1.0);
let screen_a = clip_a.xy / clip_a.w * u.viewport_size * 0.5;
let screen_b = clip_b.xy / clip_b.w * u.viewport_size * 0.5;
let segment = screen_b - screen_a;
let segment_length = length(segment);
var direction = vec2<f32>(1.0, 0.0);
if segment_length > 1e-4 {
direction = segment / segment_length;
}
let perpendicular = vec2<f32>(-direction.y, direction.x);
let clip_position = mix(clip_a, clip_b, which_end);
let half_width = resolve_hw(0.0, 0.0, wire_const.half_width);
let extension = which_end * 2.0 - 1.0;
let offset_px = perpendicular * half_width * side
+ direction * half_width * extension;
let ndc_offset = offset_px / (u.viewport_size * 0.5);
var clip_pos_out = clip_position + vec4<f32>(ndc_offset * clip_position.w, 0.0, 0.0);
clip_pos_out.z = clip_pos_out.z - in.depth.x * DRAW_ORDER_BIAS * clip_pos_out.w;
final_clip = clip_pos_out;
out_dist = mix(in.distances.x, in.distances.y, which_end)
+ extension * half_width * u.world_per_pixel;
out_cap = vec2<f32>(which_end * segment_length + extension * half_width, half_width * side);
out_cap_ends = vec3<f32>(segment_length, half_width, half_width);
}
let perpendicular = vec2<f32>(-direction.y, direction.x);
let clip_position = mix(clip_a, clip_b, which_end);
let half_width_a = resolve_hw(
in.taper_ratio.x,
wire_const.world_half_width,
wire_const.half_width,
);
let half_width_b = resolve_hw(
in.taper_ratio.y,
wire_const.world_half_width,
wire_const.half_width,
);
let half_width = mix(half_width_a, half_width_b, which_end);
let extension = which_end * 2.0 - 1.0;
let offset_px = perpendicular * half_width * side
+ direction * half_width * extension;
let ndc_offset = offset_px / (u.viewport_size * 0.5);
let final_clip = clip_position
+ vec4<f32>(ndc_offset * clip_position.w, 0.0, 0.0);
let scale = u.linetype_scale;
var min_element = wire_const.pattern_length * scale;
@@ -172,21 +220,16 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32, in: VertexIn) -> VertexOut
var out: VertexOut;
out.clip_pos = final_clip;
out.clip_pos.z -= in.depth.x * DRAW_ORDER_BIAS * out.clip_pos.w;
out.color = wire_const.color;
out.distance = mix(in.distances.x, in.distances.y, which_end)
+ extension * half_width * u.world_per_pixel;
out.distance = out_dist;
out.cap = out_cap;
out.cap_ends = out_cap_ends;
out.pattern_length = wire_const.pattern_length * scale;
out.pat0 = wire_const.pat0 * scale;
out.pat1 = wire_const.pat1 * scale;
out.min_elem = min_element;
out.align_end = wire_const.align_end * scale;
out.align_total = wire_const.align_total;
out.cap = vec2<f32>(
which_end * segment_length + extension * half_width,
half_width * side,
);
out.cap_ends = vec3<f32>(segment_length, half_width_a, half_width_b);
return out;
}
+79 -36
View File
@@ -152,35 +152,83 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32, in: VertexIn) -> VertexOut
let rel_a = marker_relative(seg_pos_a, seg_pos_a_low, in.translation, in.translation_low);
let rel_b = marker_relative(seg_pos_b, seg_pos_b_low, in.translation, in.translation_low);
let clip_a = u.view_rot * vec4<f32>(rel_a, 1.0);
let clip_b = u.view_rot * vec4<f32>(rel_b, 1.0);
let screen_a = clip_a.xy / clip_a.w * u.viewport_size * 0.5;
let screen_b = clip_b.xy / clip_b.w * u.viewport_size * 0.5;
let segment = screen_b - screen_a;
let segment_length = length(segment);
var direction = vec2<f32>(1.0, 0.0);
if segment_length > 1e-4 {
direction = segment / segment_length;
let is_world = wire_const.world_half_width > 0.0 || seg_taper_ratio.x > 0.0 || seg_taper_ratio.y > 0.0;
var final_clip: vec4<f32>;
var out_dist: f32;
var out_cap: vec2<f32>;
var out_cap_ends: vec3<f32>;
if is_world {
// Wide polylines have physical world-unit width (world_half_width or taper > 0).
// Expand the quad in 3D world space on the entity's plane (perpendicular to
// the segment and the plane normal), so the rectangle remains hosted rigidly
// on the 3D plane when the camera rotates, orbits, or tilts, matching CAD
// behavior and circle.wgsl planar arcs.
let world_hw_a = select(wire_const.world_half_width, seg_taper_ratio.x * wire_const.world_half_width, seg_taper_ratio.x > 0.0);
let world_hw_b = select(wire_const.world_half_width, seg_taper_ratio.y * wire_const.world_half_width, seg_taper_ratio.y > 0.0);
let cur_world_hw = mix(world_hw_a, world_hw_b, which_end);
let eff_hw = max(cur_world_hw, 0.5 * u.world_per_pixel);
let world_delta = rel_b - rel_a;
let world_len = length(world_delta);
var world_dir = vec3<f32>(1.0, 0.0, 0.0);
if world_len > 1e-6 {
world_dir = world_delta / world_len;
}
var norm = vec3<f32>(0.0, 0.0, 1.0);
if length(wire_const.marker_normal_scale.xyz) > 1e-4 {
norm = normalize(wire_const.marker_normal_scale.xyz);
}
var perp_world = cross(norm, world_dir);
if length(perp_world) < 1e-4 {
perp_world = cross(vec3<f32>(0.0, 1.0, 0.0), world_dir);
if length(perp_world) < 1e-4 {
perp_world = cross(vec3<f32>(1.0, 0.0, 0.0), world_dir);
}
}
perp_world = normalize(perp_world);
let pos_rel = mix(rel_a, rel_b, which_end);
let world_pos = pos_rel + perp_world * (eff_hw * side);
var clip_pos = u.view_rot * vec4<f32>(world_pos, 1.0);
clip_pos.z = clip_pos.z - in.depth.x * DRAW_ORDER_BIAS * clip_pos.w;
final_clip = clip_pos;
out_dist = mix(seg_distances.x, seg_distances.y, which_end);
out_cap = vec2<f32>(which_end * world_len, eff_hw * side);
out_cap_ends = vec3<f32>(world_len, world_hw_a, world_hw_b);
} else {
// Thin wires expand in screen pixels facing the camera with rounded end caps / joints.
let clip_a = u.view_rot * vec4<f32>(rel_a, 1.0);
let clip_b = u.view_rot * vec4<f32>(rel_b, 1.0);
let screen_a = clip_a.xy / clip_a.w * u.viewport_size * 0.5;
let screen_b = clip_b.xy / clip_b.w * u.viewport_size * 0.5;
let segment = screen_b - screen_a;
let segment_length = length(segment);
var direction = vec2<f32>(1.0, 0.0);
if segment_length > 1e-4 {
direction = segment / segment_length;
}
let perpendicular = vec2<f32>(-direction.y, direction.x);
let clip_position = mix(clip_a, clip_b, which_end);
let half_width = resolve_hw(0.0, 0.0, wire_const.half_width);
let extension = which_end * 2.0 - 1.0;
let offset_px = perpendicular * half_width * side
+ direction * half_width * extension;
let ndc_offset = offset_px / (u.viewport_size * 0.5);
var clip_pos_out = clip_position + vec4<f32>(ndc_offset * clip_position.w, 0.0, 0.0);
clip_pos_out.z = clip_pos_out.z - in.depth.x * DRAW_ORDER_BIAS * clip_pos_out.w;
final_clip = clip_pos_out;
out_dist = mix(seg_distances.x, seg_distances.y, which_end)
+ extension * half_width * u.world_per_pixel;
out_cap = vec2<f32>(which_end * segment_length + extension * half_width, half_width * side);
out_cap_ends = vec3<f32>(segment_length, half_width, half_width);
}
let perpendicular = vec2<f32>(-direction.y, direction.x);
let clip_position = mix(clip_a, clip_b, which_end);
let half_width_a = resolve_hw(
seg_taper_ratio.x,
wire_const.world_half_width,
wire_const.half_width,
);
let half_width_b = resolve_hw(
seg_taper_ratio.y,
wire_const.world_half_width,
wire_const.half_width,
);
let half_width = mix(half_width_a, half_width_b, which_end);
let extension = which_end * 2.0 - 1.0;
let offset_px = perpendicular * half_width * side
+ direction * half_width * extension;
let ndc_offset = offset_px / (u.viewport_size * 0.5);
let final_clip = clip_position
+ vec4<f32>(ndc_offset * clip_position.w, 0.0, 0.0);
let scale = u.linetype_scale;
var min_element = wire_const.pattern_length * scale;
@@ -203,21 +251,16 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32, in: VertexIn) -> VertexOut
var out: VertexOut;
out.clip_pos = final_clip;
out.clip_pos.z -= in.depth.x * DRAW_ORDER_BIAS * out.clip_pos.w;
out.color = wire_const.color;
out.distance = mix(seg_distances.x, seg_distances.y, which_end)
+ extension * half_width * u.world_per_pixel;
out.distance = out_dist;
out.cap = out_cap;
out.cap_ends = out_cap_ends;
out.pattern_length = wire_const.pattern_length * scale;
out.pat0 = wire_const.pat0 * scale;
out.pat1 = wire_const.pat1 * scale;
out.min_elem = min_element;
out.align_end = wire_const.align_end * scale;
out.align_total = wire_const.align_total;
out.cap = vec2<f32>(
which_end * segment_length + extension * half_width,
half_width * side,
);
out.cap_ends = vec3<f32>(segment_length, half_width_a, half_width_b);
return out;
}
+79 -29
View File
@@ -32,12 +32,12 @@ const MODEL_LINEWEIGHT_MAX_PX: f32 = 10.0;
const TAU: f32 = 6.283185307179586;
struct InstanceIn {
@location(0) center_high: vec4<f32>, // xyz = center_high, w = unused
@location(0) center_high: vec4<f32>, // xyz = center_high, w = start_width
@location(1) center_low: vec4<f32>, // xyz = center_low, w = radius
@location(2) axis_x: vec4<f32>, // xyz = axis_x, w = start_angle
@location(3) axis_y: vec4<f32>, // xyz = axis_y, w = end_angle
@location(4) color: vec4<f32>, // rgba
@location(5) params: vec4<f32>, // half_width_px, pattern_length, draw_depth, unused
@location(5) params: vec4<f32>, // half_width_px, pattern_length, draw_depth, end_width
@location(6) pat0: vec4<f32>,
@location(7) pat1: vec4<f32>,
}
@@ -46,7 +46,7 @@ struct VertexOut {
@builtin(position) clip_pos: vec4<f32>,
@location(0) local_pos: vec2<f32>,
@location(1) radius: f32,
@location(2) hw_px: f32,
@location(2) @interpolate(flat) hw_ends: vec2<f32>,
@location(3) color: vec4<f32>,
@location(4) pattern_length: f32,
@location(5) pat0: vec4<f32>,
@@ -54,9 +54,10 @@ struct VertexOut {
@location(7) @interpolate(flat) min_elem: f32,
@location(8) @interpolate(flat) start_angle: f32,
@location(9) @interpolate(flat) end_angle: f32,
@location(10) @interpolate(flat) is_world: f32,
}
fn resolve_hw(px_hw: f32) -> f32 {
fn resolve_px_hw(px_hw: f32) -> f32 {
var display_hw = max(px_hw * u.lineweight_scale, 0.5);
if u.lineweight_scale < 0.0 {
let scale = -u.lineweight_scale;
@@ -85,14 +86,32 @@ fn resolve_hw(px_hw: f32) -> f32 {
let axis_x = in.axis_x.xyz;
let axis_y = in.axis_y.xyz;
let color = in.color;
let hw_px = resolve_hw(in.params.x);
let world_w_a = in.center_high.w;
let world_w_b = in.params.w;
let is_world = select(0.0, 1.0, world_w_a > 0.0 || world_w_b > 0.0);
var hw_a: f32;
var hw_b: f32;
var ext: f32;
if is_world > 0.5 {
hw_a = world_w_a * 0.5;
hw_b = world_w_b * 0.5;
let max_hw = max(hw_a, hw_b);
// Expand bounding quad so anti-aliasing margin and physical width never clip under 3D tilt
let margin_world = max_hw + 16.0 * max(u.world_per_pixel, 1e-6);
ext = radius + margin_world;
} else {
let px_hw = resolve_px_hw(in.params.x);
hw_a = px_hw;
hw_b = px_hw;
let margin_world = (px_hw + 2.0) * max(u.world_per_pixel, 1e-6);
ext = radius + margin_world;
}
let pattern_length = in.params.y;
let draw_depth = in.params.z;
// Expand bounding quad so anti-aliasing margin and lineweight never clip
let margin_world = (hw_px + 2.0) * max(u.world_per_pixel, 1e-6);
let ext = radius + margin_world;
let center_rel = (center_high - u.eye_high) + (center_low - u.eye_low);
let world_pos_rel = center_rel + (u_val * axis_x + v_val * axis_y) * ext;
@@ -103,7 +122,7 @@ fn resolve_hw(px_hw: f32) -> f32 {
out.clip_pos = clip_pos;
out.local_pos = vec2<f32>(u_val * ext, v_val * ext);
out.radius = radius;
out.hw_px = hw_px;
out.hw_ends = vec2<f32>(hw_a, hw_b);
out.color = color;
out.pattern_length = pattern_length;
out.pat0 = in.pat0;
@@ -122,6 +141,7 @@ fn resolve_hw(px_hw: f32) -> f32 {
out.start_angle = in.axis_x.w;
out.end_angle = in.axis_y.w;
out.is_world = is_world;
return out;
}
@@ -181,30 +201,60 @@ fn in_dash(dist: f32, pat_len: f32, p0: vec4<f32>, p1: vec4<f32>) -> bool {
let theta = select(angle, angle + TAU, angle < 0.0);
let d_theta = mod_tau(theta - sa);
var d_world: f32;
var arc_dist: f32;
var alpha_cov: f32 = 0.0;
var arc_dist: f32 = 0.0;
if sweep >= TAU - 1e-5 || d_theta <= sweep {
// Inside arc sweep or full circle
d_world = abs(r - in.radius);
arc_dist = in.radius * d_theta;
} else {
// Outside sweep: measure distance to start and end endpoints for round end caps
let p_start = in.radius * vec2<f32>(cos(sa), sin(sa));
let p_end = in.radius * vec2<f32>(cos(ea), sin(ea));
let d_start = length(in.local_pos - p_start);
let d_end = length(in.local_pos - p_end);
if d_start < d_end {
d_world = d_start;
arc_dist = 0.0;
let is_full_or_in_sweep = (sweep >= TAU - 1e-5) || (d_theta <= sweep);
if in.is_world > 0.5 {
// Physical world-unit width (thick analytical circular arcs, donuts, tapered segments).
// Distance-to-edge calculations are strictly performed in the circle plane's world units
// so that 3D perspective projection yields exact concentric ellipses with invariant
// physical stroke widths across any camera tilt or acute perspective angle.
if is_full_or_in_sweep {
let t = select(0.0, clamp(d_theta / sweep, 0.0, 1.0), sweep > 1e-5);
let world_hw = mix(in.hw_ends.x, in.hw_ends.y, t);
let eff_hw = max(world_hw, 0.5 * fw);
let d_world = abs(r - in.radius);
let delta_px = (eff_hw - d_world) / fw;
let cov = clamp(0.5 + delta_px, 0.0, 1.0);
let subpixel_fade = min(1.0, (2.0 * world_hw) / fw);
alpha_cov = cov * subpixel_fade;
arc_dist = in.radius * d_theta;
} else {
d_world = d_end;
arc_dist = in.radius * sweep;
// Outside sweep: evaluate distance to start and end endpoints for smooth round end caps
let p_start = in.radius * vec2<f32>(cos(sa), sin(sa));
let p_end = in.radius * vec2<f32>(cos(ea), sin(ea));
let d_start = length(in.local_pos - p_start);
let d_end = length(in.local_pos - p_end);
let eff_hw_a = max(in.hw_ends.x, 0.5 * fw);
let eff_hw_b = max(in.hw_ends.y, 0.5 * fw);
let cov_start = clamp(0.5 + (eff_hw_a - d_start) / fw, 0.0, 1.0) * min(1.0, (2.0 * in.hw_ends.x) / fw);
let cov_end = clamp(0.5 + (eff_hw_b - d_end) / fw, 0.0, 1.0) * min(1.0, (2.0 * in.hw_ends.y) / fw);
alpha_cov = max(cov_start, cov_end);
arc_dist = select(in.radius * sweep, 0.0, cov_start >= cov_end);
}
} else {
// Lineweight stroke (screen-pixel width)
if is_full_or_in_sweep {
let t = select(0.0, clamp(d_theta / sweep, 0.0, 1.0), sweep > 1e-5);
let cur_hw = mix(in.hw_ends.x, in.hw_ends.y, t);
let d_world = abs(r - in.radius);
let d_px = d_world / fw;
alpha_cov = clamp(0.5 + cur_hw - d_px, 0.0, 1.0);
arc_dist = in.radius * d_theta;
} else {
let p_start = in.radius * vec2<f32>(cos(sa), sin(sa));
let p_end = in.radius * vec2<f32>(cos(ea), sin(ea));
let d_start_px = length(in.local_pos - p_start) / fw;
let d_end_px = length(in.local_pos - p_end) / fw;
let cov_start = clamp(0.5 + in.hw_ends.x - d_start_px, 0.0, 1.0);
let cov_end = clamp(0.5 + in.hw_ends.y - d_end_px, 0.0, 1.0);
alpha_cov = max(cov_start, cov_end);
arc_dist = select(in.radius * sweep, 0.0, cov_start >= cov_end);
}
}
let d_px = d_world / fw;
let alpha_cov = clamp(0.5 + in.hw_px - d_px, 0.0, 1.0);
if alpha_cov <= 0.0 {
discard;
}
+81 -46
View File
@@ -156,50 +156,89 @@ fn marker_relative(position_high: vec3<f32>, position_low: vec3<f32>, instance:
// UTM-scale coordinates and after a cross-drawing paste, with no jitter.
let rel_a = marker_relative(in.pos_a, in.pos_a_low, in);
let rel_b = marker_relative(in.pos_b, in.pos_b_low, in);
let clip_a = u.view_rot * vec4<f32>(rel_a, 1.0);
let clip_b = u.view_rot * vec4<f32>(rel_b, 1.0);
let is_world = in.misc.w > 0.0 || in.taper.x > 0.0 || in.taper.y > 0.0;
// NDC of both endpoints.
let ndc_a = clip_a.xy / clip_a.w;
let ndc_b = clip_b.xy / clip_b.w;
var final_clip: vec4<f32>;
var out_dist: f32;
var out_cap: vec2<f32>;
var out_cap_ends: vec3<f32>;
// Screen-space pixel positions.
let screen_a = ndc_a * u.viewport_size * 0.5;
let screen_b = ndc_b * u.viewport_size * 0.5;
if is_world {
// Wide polylines have physical world-unit width (in.misc.w or in.taper > 0).
// Expand the quad in 3D world space on the entity's plane (perpendicular to
// the segment and the plane normal), so the rectangle remains hosted rigidly
// on the 3D plane when the camera rotates, orbits, or tilts, matching CAD
// behavior and circle.wgsl planar arcs.
let world_hw_a = select(in.misc.w, in.taper.x, in.taper.x > 0.0);
let world_hw_b = select(in.misc.w, in.taper.y, in.taper.y > 0.0);
let cur_world_hw = mix(world_hw_a, world_hw_b, which_end);
let eff_hw = max(cur_world_hw, 0.5 * u.world_per_pixel);
// Screen-space direction / perpendicular of the segment.
let seg = screen_b - screen_a;
let seg_len = length(seg);
var dir: vec2<f32>;
if seg_len > 1e-4 {
dir = seg / seg_len;
let world_delta = rel_b - rel_a;
let world_len = length(world_delta);
var world_dir = vec3<f32>(1.0, 0.0, 0.0);
if world_len > 1e-6 {
world_dir = world_delta / world_len;
}
var norm = vec3<f32>(0.0, 0.0, 1.0);
if length(in.marker_normal_scale.xyz) > 1e-4 {
norm = normalize(in.marker_normal_scale.xyz);
}
var perp_world = cross(norm, world_dir);
if length(perp_world) < 1e-4 {
perp_world = cross(vec3<f32>(0.0, 1.0, 0.0), world_dir);
if length(perp_world) < 1e-4 {
perp_world = cross(vec3<f32>(1.0, 0.0, 0.0), world_dir);
}
}
perp_world = normalize(perp_world);
let pos_rel = mix(rel_a, rel_b, which_end);
let world_pos = pos_rel + perp_world * (eff_hw * side);
var clip_pos = u.view_rot * vec4<f32>(world_pos, 1.0);
clip_pos.z = clip_pos.z - in.misc.x * DRAW_ORDER_BIAS * clip_pos.w;
final_clip = clip_pos;
out_dist = mix(in.dists.x, in.dists.y, which_end);
out_cap = vec2<f32>(which_end * world_len, eff_hw * side);
out_cap_ends = vec3<f32>(world_len, world_hw_a, world_hw_b);
} else {
dir = vec2<f32>(1.0, 0.0);
// Thin wires expand in screen pixels facing the camera with rounded end caps / joints.
let clip_a = u.view_rot * vec4<f32>(rel_a, 1.0);
let clip_b = u.view_rot * vec4<f32>(rel_b, 1.0);
let ndc_a = clip_a.xy / clip_a.w;
let ndc_b = clip_b.xy / clip_b.w;
let screen_a = ndc_a * u.viewport_size * 0.5;
let screen_b = ndc_b * u.viewport_size * 0.5;
let seg = screen_b - screen_a;
let seg_len = length(seg);
var dir: vec2<f32>;
if seg_len > 1e-4 {
dir = seg / seg_len;
} else {
dir = vec2<f32>(1.0, 0.0);
}
let perp = vec2<f32>(-dir.y, dir.x);
let clip_pos = mix(clip_a, clip_b, which_end);
let hw = resolve_hw(0.0, 0.0, in.dists.z);
let ext = which_end * 2.0 - 1.0;
let offset_px = perp * hw * side + dir * hw * ext;
let ndc_offset = offset_px / (u.viewport_size * 0.5);
var clip_pos_out = clip_pos + vec4<f32>(ndc_offset * clip_pos.w, 0.0, 0.0);
clip_pos_out.z = clip_pos_out.z - in.misc.x * DRAW_ORDER_BIAS * clip_pos_out.w;
final_clip = clip_pos_out;
out_dist = mix(in.dists.x, in.dists.y, which_end) + ext * hw * u.world_per_pixel;
out_cap = vec2<f32>(which_end * seg_len + ext * hw, hw * side);
out_cap_ends = vec3<f32>(seg_len, hw, hw);
}
let perp = vec2<f32>(-dir.y, dir.x);
// Select the clip-space position for this vertex's endpoint.
let clip_pos = mix(clip_a, clip_b, which_end);
// A wide polyline carries its band width in world units: expand the quad by
// `world_half_width / world_per_pixel` (pixels) so the band tracks zoom. A
// normal wire (world_half_width == 0) uses the screen-pixel half-width,
// honouring the LWDISPLAY toggle (off → collapse to a 1-pixel line).
// A tapered band interpolates a per-endpoint world half-width across the
// segment; a constant band uses `world_half_width`. Both clamp to a
// half-pixel so a zoomed-out band stays a hairline instead of vanishing.
let hw_a = resolve_hw(in.taper.x, in.misc.w, in.dists.z);
let hw_b = resolve_hw(in.taper.y, in.misc.w, in.dists.z);
let hw = mix(hw_a, hw_b, which_end);
// Extend the quad longitudinally by the end half-width and let the
// fragment stage round the overhang off: adjoining segments then meet in
// overlapping round joints, closing the wedge gaps a perpendicular-only
// expansion leaves on the outside of corners and along tessellated arcs.
let ext = which_end * 2.0 - 1.0; // -1 at the A end, +1 at the B end
let offset_px = perp * hw * side + dir * hw * ext;
let ndc_offset = offset_px / (u.viewport_size * 0.5);
let final_clip = clip_pos + vec4<f32>(ndc_offset * clip_pos.w, 0.0, 0.0);
// Smallest non-zero dash / gap element, in world units. Used by
// the fragment stage to decide when the pattern's finest feature
@@ -219,14 +258,10 @@ fn marker_relative(position_high: vec3<f32>, position_low: vec3<f32>, instance:
var out: VertexOut;
out.clip_pos = final_clip;
out.clip_pos.z = out.clip_pos.z - in.misc.x * DRAW_ORDER_BIAS * out.clip_pos.w;
out.color = in.color;
// Dash arc-length, extrapolated over the cap overhang so the pattern
// stays continuous through a joint.
out.distance = mix(in.dists.x, in.dists.y, which_end)
+ ext * hw * u.world_per_pixel;
out.cap = vec2<f32>(which_end * seg_len + ext * hw, hw * side);
out.cap_ends = vec3<f32>(seg_len, hw_a, hw_b);
out.distance = out_dist;
out.cap = out_cap;
out.cap_ends = out_cap_ends;
out.pattern_length = in.dists.w * lt_scale;
out.pat0 = in.pat0 * lt_scale;
out.pat1 = in.pat1 * lt_scale;
+81 -43
View File
@@ -128,47 +128,89 @@ fn marker_relative(position_high: vec3<f32>, position_low: vec3<f32>, c: WireCon
let rel_a = marker_relative(in.pos_a, in.pos_a_low, c);
let rel_b = marker_relative(in.pos_b, in.pos_b_low, c);
let clip_a = u.view_rot * vec4<f32>(rel_a, 1.0);
let clip_b = u.view_rot * vec4<f32>(rel_b, 1.0);
let is_world = c.world_half_width > 0.0 || in.taper_ratio.x > 0.0 || in.taper_ratio.y > 0.0;
let ndc_a = clip_a.xy / clip_a.w;
let ndc_b = clip_b.xy / clip_b.w;
var final_clip: vec4<f32>;
var out_dist: f32;
var out_cap: vec2<f32>;
var out_cap_ends: vec3<f32>;
let screen_a = ndc_a * u.viewport_size * 0.5;
let screen_b = ndc_b * u.viewport_size * 0.5;
if is_world {
// Wide polylines have physical world-unit width (world_half_width or taper > 0).
// Expand the quad in 3D world space on the entity's plane (perpendicular to
// the segment and the plane normal), so the rectangle remains hosted rigidly
// on the 3D plane when the camera rotates, orbits, or tilts, matching CAD
// behavior and circle.wgsl planar arcs.
let world_hw_a = select(c.world_half_width, in.taper_ratio.x * c.world_half_width, in.taper_ratio.x > 0.0);
let world_hw_b = select(c.world_half_width, in.taper_ratio.y * c.world_half_width, in.taper_ratio.y > 0.0);
let cur_world_hw = mix(world_hw_a, world_hw_b, which_end);
let eff_hw = max(cur_world_hw, 0.5 * u.world_per_pixel);
let seg = screen_b - screen_a;
let seg_len = length(seg);
var dir: vec2<f32>;
if seg_len > 1e-4 {
dir = seg / seg_len;
let world_delta = rel_b - rel_a;
let world_len = length(world_delta);
var world_dir = vec3<f32>(1.0, 0.0, 0.0);
if world_len > 1e-6 {
world_dir = world_delta / world_len;
}
var norm = vec3<f32>(0.0, 0.0, 1.0);
if length(c.marker_normal_scale.xyz) > 1e-4 {
norm = normalize(c.marker_normal_scale.xyz);
}
var perp_world = cross(norm, world_dir);
if length(perp_world) < 1e-4 {
perp_world = cross(vec3<f32>(0.0, 1.0, 0.0), world_dir);
if length(perp_world) < 1e-4 {
perp_world = cross(vec3<f32>(1.0, 0.0, 0.0), world_dir);
}
}
perp_world = normalize(perp_world);
let pos_rel = mix(rel_a, rel_b, which_end);
let world_pos = pos_rel + perp_world * (eff_hw * side);
var clip_pos = u.view_rot * vec4<f32>(world_pos, 1.0);
clip_pos.z = clip_pos.z - c.draw_depth * DRAW_ORDER_BIAS * clip_pos.w;
final_clip = clip_pos;
out_dist = mix(in.distance_a, in.distance_b, which_end);
out_cap = vec2<f32>(which_end * world_len, eff_hw * side);
out_cap_ends = vec3<f32>(world_len, world_hw_a, world_hw_b);
} else {
dir = vec2<f32>(1.0, 0.0);
// Thin wires expand in screen pixels facing the camera with rounded end caps / joints.
let clip_a = u.view_rot * vec4<f32>(rel_a, 1.0);
let clip_b = u.view_rot * vec4<f32>(rel_b, 1.0);
let ndc_a = clip_a.xy / clip_a.w;
let ndc_b = clip_b.xy / clip_b.w;
let screen_a = ndc_a * u.viewport_size * 0.5;
let screen_b = ndc_b * u.viewport_size * 0.5;
let seg = screen_b - screen_a;
let seg_len = length(seg);
var dir: vec2<f32>;
if seg_len > 1e-4 {
dir = seg / seg_len;
} else {
dir = vec2<f32>(1.0, 0.0);
}
let perp = vec2<f32>(-dir.y, dir.x);
let clip_pos = mix(clip_a, clip_b, which_end);
let hw = resolve_hw(0.0, 0.0, c.half_width);
let ext = which_end * 2.0 - 1.0;
let offset_px = perp * hw * side + dir * hw * ext;
let ndc_offset = offset_px / (u.viewport_size * 0.5);
var clip_pos_out = clip_pos + vec4<f32>(ndc_offset * clip_pos.w, 0.0, 0.0);
clip_pos_out.z = clip_pos_out.z - c.draw_depth * DRAW_ORDER_BIAS * clip_pos_out.w;
final_clip = clip_pos_out;
out_dist = mix(in.distance_a, in.distance_b, which_end) + ext * hw * u.world_per_pixel;
out_cap = vec2<f32>(which_end * seg_len + ext * hw, hw * side);
out_cap_ends = vec3<f32>(seg_len, hw, hw);
}
let perp = vec2<f32>(-dir.y, dir.x);
let clip_pos = mix(clip_a, clip_b, which_end);
// A wide polyline carries its band width in world units: expand the quad
// by `world_half_width / world_per_pixel` (screen pixels) so the band grows
// and shrinks with zoom. A normal wire (world_half_width == 0) uses the
// screen-pixel half-width, honouring the LWDISPLAY toggle.
// A tapered band carries normalized endpoint widths on the instance:
// interpolate across the segment so the band narrows/widens smoothly. A
// constant band uses the per-wire `world_half_width`. Both clamp to a
// half-pixel so a zoomed-out band stays a hairline instead of vanishing.
let hw_a = resolve_hw(in.taper_ratio.x, c.world_half_width, c.half_width);
let hw_b = resolve_hw(in.taper_ratio.y, c.world_half_width, c.half_width);
let hw = mix(hw_a, hw_b, which_end);
// Extend the quad longitudinally by the end half-width and let the
// fragment stage round the overhang off: adjoining segments then meet in
// overlapping round joints, closing the wedge gaps a perpendicular-only
// expansion leaves on the outside of corners and along tessellated arcs.
let ext = which_end * 2.0 - 1.0; // -1 at the A end, +1 at the B end
let offset_px = perp * hw * side + dir * hw * ext;
let ndc_offset = offset_px / (u.viewport_size * 0.5);
let final_clip = clip_pos + vec4<f32>(ndc_offset * clip_pos.w, 0.0, 0.0);
let lt_scale = u.linetype_scale;
var min_elem: f32 = c.pattern_length * lt_scale;
@@ -185,14 +227,10 @@ fn marker_relative(position_high: vec3<f32>, position_low: vec3<f32>, c: WireCon
var out: VertexOut;
out.clip_pos = final_clip;
out.clip_pos.z = out.clip_pos.z - c.draw_depth * DRAW_ORDER_BIAS * out.clip_pos.w;
out.color = c.color;
// Dash arc-length, extrapolated over the cap overhang so the pattern
// stays continuous through a joint.
out.distance = mix(in.distance_a, in.distance_b, which_end)
+ ext * hw * u.world_per_pixel;
out.cap = vec2<f32>(which_end * seg_len + ext * hw, hw * side);
out.cap_ends = vec3<f32>(seg_len, hw_a, hw_b);
out.distance = out_dist;
out.cap = out_cap;
out.cap_ends = out_cap_ends;
out.pattern_length = c.pattern_length * lt_scale;
out.pat0 = c.pat0 * lt_scale;
out.pat1 = c.pat1 * lt_scale;
+107 -383
View File
@@ -1369,31 +1369,20 @@ impl Snapper {
&& (local_segments.is_some() || allow_unindexed_pairwise)
{
if let Some(segments) = &local_segments {
// Curved pairs (circle/arc/ellipse, and a line paired with
// one) are solved exactly up front (bug #1052 — see
// `exact_curve_intersections`'s doc comment); the segment
// sweep below then skips any pair already resolved this
// way, so the stale tessellated approximation never
// competes with the true point as a candidate. Every
// distinct wire pair touching the cursor-local segment set
// is tried — cheap even for a plain line-vs-line pair,
// which `exact_curve_intersections` rejects immediately —
// rather than pre-filtering to "known curved" wires, so a
// line paired with a circle/arc/ellipse is covered too.
let mut local_wires: Vec<u32> = segments.iter().map(|s| s.wire).collect();
local_wires.sort_unstable();
local_wires.dedup();
let mut resolved_pairs: Vec<(u32, u32)> = Vec::new();
for (idx, &wi) in local_wires.iter().enumerate() {
for &wj in &local_wires[idx + 1..] {
let (Some(wire_i), Some(wire_j)) = (wires.source_wire(wi), wires.source_wire(wj)) else {
continue;
};
if let Some(pts) = exact_curve_intersections(wire_i, wire_j) {
for pt in pts {
try_pt(pt, SnapType::Intersection);
let local_wires: Vec<_> = wires.iter().filter(|wire| wire_in_range(wire)).collect();
let mut resolved_pairs = rustc_hash::FxHashSet::default();
let pair_key = |a: &WireModel, b: &WireModel| {
let a = a as *const WireModel as usize;
let b = b as *const WireModel as usize;
(a.min(b), a.max(b))
};
for (idx, &wire_i) in local_wires.iter().enumerate() {
for &wire_j in &local_wires[idx + 1..] {
if let Some(points) = exact_curve_intersections(wire_i, wire_j) {
for point in points {
try_pt(point, SnapType::Intersection);
}
resolved_pairs.push((wi, wj));
resolved_pairs.insert(pair_key(wire_i, wire_j));
}
}
}
@@ -1413,7 +1402,8 @@ impl Snapper {
if a.wire == b.wire || a.max_y() < b.min_y() || a.min_y() > b.max_y() {
continue;
}
if resolved_pairs.contains(&(a.wire.min(b.wire), a.wire.max(b.wire))) {
if wires.source_wire(a.wire).zip(wires.source_wire(b.wire))
.is_some_and(|(a, b)| resolved_pairs.contains(&pair_key(a, b))) {
continue;
}
if let Some(pt) = seg_intersect_3d(a.a, a.b, b.a, b.b) {
@@ -2343,164 +2333,7 @@ fn ray_segment_intersect_3d(
))
}
/// A full circle recovered from a wire's `tangent_geoms` — the entity's real
/// definition, in world space — used to solve Intersection snap exactly for
/// circle/arc pairs instead of falling back to `seg_intersect_3d` against
/// their tessellated approximation.
///
/// Why this exists (GitHub discussion #1052): a chord of *any* tessellated
/// circle sits strictly inside the true circle, so the "intersection" of two
/// tessellated circles is never quite where the two real circles actually
/// cross — the error is small but real, and does not shrink with zoom: the
/// resident wire set OSNAP searches is built once per geometry edit with a
/// fixed 48-segment tessellation for every circle regardless of radius, by
/// design (`Scene::resident_wires_for`'s own doc comment: zoom-independent,
/// so hit-testing/snap don't re-tessellate on every camera move). A r=100
/// circle's chord sagitta at 48 segments is already ≈0.21 world units.
struct WireCircle {
center: DVec3,
radius: f64,
/// Unit normal of the plane the circle lies in.
normal: DVec3,
/// `Some((axis_x, axis_y, start_angle, end_angle))` for an arc — used to
/// keep only the full circle's crossing points that actually fall
/// within the arc's own sweep. `None` for a full circle: every point
/// the circle-circle solve finds is valid. Angles follow this
/// codebase's own convention (`src/entities/arc.rs`): measured from
/// `axis_x` toward `axis_y`, equal start/end meaning a full turn.
arc_span: Option<(DVec3, DVec3, f64, f64)>,
}
/// The wire's underlying circle, if it's simple enough to have one: a single
/// Circle or Arc entity as a whole, not a compound chain. Ellipses and lines
/// return `None` here — they're handled by the more general
/// `exact_curve_intersections` below, which this function's caller falls
/// through to. A polyline with a bulge segment still goes through the
/// tessellated sweep: it isn't a single recognized curve, and splitting it
/// into per-segment exact geometry is a real follow-up, not a known-and-
/// skipped case (there's no single closed form for "the rest of a compound
/// chain" the way there is for one circle, arc, ellipse, or line).
fn wire_circle(wire: &WireModel) -> Option<WireCircle> {
let [geom] = wire.tangent_geoms.as_slice() else {
return None;
};
match geom {
TangentGeom::PlanarCircle { center, axis_x, axis_y, radius } => {
let (axis_x, axis_y) = (DVec3::new(axis_x[0], axis_x[1], axis_x[2]), DVec3::new(axis_y[0], axis_y[1], axis_y[2]));
Some(WireCircle {
center: DVec3::new(center[0], center[1], center[2]),
radius: *radius,
normal: axis_x.cross(axis_y).normalize(),
arc_span: None,
})
}
TangentGeom::Circle { center, radius } => Some(WireCircle {
center: DVec3::new(center[0] as f64, center[1] as f64, center[2] as f64),
radius: *radius as f64,
normal: DVec3::Z,
arc_span: None,
}),
TangentGeom::Arc { center, axis_x, axis_y, radius, start_angle, end_angle } => {
let (axis_x, axis_y) = (DVec3::new(axis_x[0], axis_x[1], axis_x[2]), DVec3::new(axis_y[0], axis_y[1], axis_y[2]));
Some(WireCircle {
center: DVec3::new(center[0], center[1], center[2]),
radius: *radius,
normal: axis_x.cross(axis_y).normalize(),
arc_span: Some((axis_x, axis_y, *start_angle, *end_angle)),
})
}
TangentGeom::Line { .. } | TangentGeom::PlanarEllipse { .. } => None,
}
}
impl WireCircle {
/// Whether world point `p` — assumed already on this circle — falls
/// within the arc's actual angular sweep. Always true for a full circle.
fn contains_point(&self, p: DVec3) -> bool {
let Some((axis_x, axis_y, start, end)) = self.arc_span else {
return true;
};
let rel = p - self.center;
let tau = std::f64::consts::TAU;
let angle = rel.dot(axis_y).atan2(rel.dot(axis_x)).rem_euclid(tau);
let span = (end - start).rem_euclid(tau);
let from_start = (angle - start).rem_euclid(tau);
// Equal start/end means a full turn (this codebase's own Arc
// convention), not zero sweep.
span == 0.0 || from_start <= span + 1e-9
}
}
/// Exact circle/arc intersection for one wire pair, bypassing their
/// tessellated approximation entirely. `None` when neither side is a circle
/// or arc (the segment sweep is already exact for pure lines/polylines), the
/// two aren't coplanar (a genuinely-3D case the sweep already handles no
/// worse than before), or they don't actually meet — in every `None` case
/// the caller falls back to the ordinary segment sweep unchanged.
///
/// Correct for any circle/arc-vs-circle/arc pair regardless of whether the
/// two sides' own local frames agree in orientation: the candidate points
/// come from a frame-agnostic 3D construction (`a.normal`/`dir`/`perp`
/// below), and each side's `WireCircle::contains_point` then checks a
/// candidate against *that side's own* axis_x/axis_y independently — there
/// is no step that re-expresses one arc's angle range in the other's frame,
/// so this handles arc-vs-arc (even two arcs with differently-rotated
/// frames — see the test below) exactly as directly as circle-vs-circle.
///
/// Deliberately scoped to circle/arc pairs, not every curved type: a
/// line-vs-circle/arc pair and anything involving an ellipse are left to
/// the existing sweep for now — line-circle needs its own (simple, but
/// separate) closed form, and an ellipse's parametrisation is genuinely
/// orientation-sensitive in a way a circle's isn't, so it doesn't fall out
/// of this same construction for free. Not silently dropped — a natural
/// follow-up once this lands.
fn exact_circle_intersections(wire_a: &WireModel, wire_b: &WireModel) -> Option<Vec<DVec3>> {
let a = wire_circle(wire_a)?;
let b = wire_circle(wire_b)?;
let d_vec = b.center - a.center;
let d = d_vec.length();
if d < 1e-9 {
return None; // concentric: coincident or no isolated crossing either way
}
// Coplanarity: both circles' planes must agree (normals parallel, up to
// sign), and the vector between centers must actually lie in that plane
// — otherwise this is a genuinely-3D case the tessellated sweep already
// handles (via `seg_intersect_3d`'s own height-match check) no worse
// than an exact-but-wrong-plane answer would.
const PLANE_TOL: f64 = 1e-7;
if a.normal.cross(b.normal).length() > PLANE_TOL {
return None;
}
if d_vec.dot(a.normal).abs() > PLANE_TOL * d.max(1.0) {
return None;
}
let (r1, r2) = (a.radius, b.radius);
if d > r1 + r2 + PLANE_TOL || d < (r1 - r2).abs() - PLANE_TOL {
return None; // circles don't meet
}
let dir = d_vec / d;
let a_dist = ((d * d + r1 * r1 - r2 * r2) / (2.0 * d)).clamp(-r1, r1);
let h = (r1 * r1 - a_dist * a_dist).max(0.0).sqrt();
let mid = a.center + dir * a_dist;
let perp = a.normal.cross(dir).normalize();
let candidates = if h < 1e-9 { vec![mid] } else { vec![mid + perp * h, mid - perp * h] };
let points: Vec<DVec3> = candidates.into_iter().filter(|&p| a.contains_point(p) && b.contains_point(p)).collect();
(!points.is_empty()).then_some(points)
}
/// A world-space working plane for embedding curves into
/// `cadkernel::geom2d`'s 2D representation — the general-case fix for
/// GitHub discussion #1052, covering every pairing `exact_circle_
/// intersections`'s fast path doesn't: a line paired with a circle/arc/
/// ellipse, and anything involving an ellipse (an ellipse's parametrisation
/// is orientation-sensitive in a way a circle's isn't, so it doesn't fall
/// out of that fast path's frame-independent construction for free).
// Coordinates for adapting wire geometry to the kernel intersection solver.
struct WirePlane {
origin: DVec3,
axis_x: DVec3,
@@ -2529,63 +2362,6 @@ impl WirePlane {
}
}
/// A stable orthonormal in-plane basis for a given normal, used only to
/// seed a shared working plane when neither wire's own native frame is
/// preferred over the other's (both curved, or the "primary" role needs a
/// deterministic pick) — the specific in-plane rotation chosen doesn't
/// matter for correctness, only that both curves embed into the *same* one.
fn stable_plane_basis(normal: DVec3) -> (DVec3, DVec3) {
let seed = if normal.x.abs() <= normal.y.abs() && normal.x.abs() <= normal.z.abs() {
DVec3::X
} else if normal.y.abs() <= normal.z.abs() {
DVec3::Y
} else {
DVec3::Z
};
let axis_x = (seed - normal * normal.dot(seed)).normalize();
let axis_y = normal.cross(axis_x);
(axis_x, axis_y)
}
/// A world point on an ellipse at native parameter `t`, using the ellipse's
/// *own* normal for the minor-axis direction (not any externally-chosen
/// plane's) — the correct, orientation-independent reference for computing
/// where a boundary parameter actually sits in space, mirroring how
/// `src/entities/curve.rs::ellipse_curve` already treats this convention as
/// established (`normal` determines the minor-axis rotation sense).
fn ellipse_world_point(center: DVec3, major_axis_world: DVec3, ell_normal: DVec3, minor_radius: f64, t: f64) -> DVec3 {
let major_radius = major_axis_world.length();
let major_unit = major_axis_world / major_radius;
let minor_unit = ell_normal.cross(major_unit);
center + major_unit * (major_radius * t.cos()) + minor_unit * (minor_radius * t.sin())
}
/// The parameter, in `frame`'s own 2D ellipse embedding (`major_axis_2d`
/// already resolved there, `cadkernel`'s own "minor is major rotated 90°
/// CCW in 2D" convention supplying the rest), that a world point already
/// known to lie on the ellipse corresponds to. Inverting through the actual
/// point — rather than assuming a native parameter value carries over
/// unchanged — is what makes this correct even when the ellipse's own
/// normal and `frame`'s normal are coplanar but *anti*-parallel (a plain
/// "reuse start/end parameter" would silently pick the complementary arc in
/// that case: 2D-CCW-in-`frame` and 2D-CCW-in-the-ellipse's-own-frame would
/// disagree on which way is "forward").
fn ellipse_param_in_frame(world_point: DVec3, centre: DVec3, major_axis_2d: [f64; 2], major_radius: f64, minor_radius: f64, frame: &WirePlane) -> f64 {
let rel = frame.to_2d(world_point);
let c = frame.to_2d(centre);
let d = [rel[0] - c[0], rel[1] - c[1]];
let minor_axis_2d = [-major_axis_2d[1], major_axis_2d[0]];
let cos_t = (d[0] * major_axis_2d[0] + d[1] * major_axis_2d[1]) / major_radius;
let sin_t = (d[0] * minor_axis_2d[0] + d[1] * minor_axis_2d[1]) / minor_radius;
sin_t.atan2(cos_t)
}
/// This wire's own natural working plane — only a Circle/PlanarCircle/Arc/
/// PlanarEllipse has one; a line's two points don't define a unique plane,
/// and a compound chain (a polyline with a bulge) isn't a single curve to
/// begin with (out of scope for this pass, not because it's hard, but
/// because a *chain* needs its own per-segment bookkeeping this pass
/// doesn't add).
fn wire_plane(wire: &WireModel) -> Option<WirePlane> {
let [geom] = wire.tangent_geoms.as_slice() else {
return None;
@@ -2611,13 +2387,6 @@ fn wire_plane(wire: &WireModel) -> Option<WirePlane> {
Some(WirePlane { origin, axis_x, axis_y, normal: axis_x.cross(axis_y).normalize() })
}
/// `wire`'s curve, expressed in `frame`'s 2D coordinates — `None` if `wire`
/// isn't a single recognized curve (see `wire_plane`'s doc comment for what
/// that excludes) or doesn't actually lie in `frame`'s plane. Correct
/// regardless of whether `wire`'s own native frame agrees in orientation
/// with `frame`: an Arc's/Ellipse's boundary parameters are re-derived from
/// their actual world points (`ellipse_param_in_frame`, the closure below
/// for Arc), never assumed to carry over unchanged.
fn curve_in_frame(wire: &WireModel, frame: &WirePlane, tol: f64) -> Option<Curve> {
use cadkernel::geom2d::{Arc as KArc, Circle as KCircle, Ellipse as KEllipse, EllipseArc as KEllipseArc, Line as KLine};
@@ -2639,13 +2408,16 @@ fn curve_in_frame(wire: &WireModel, frame: &WirePlane, tol: f64) -> Option<Curve
match geom {
TangentGeom::Line { p1, p2 } => {
let p1 = DVec3::new(p1[0] as f64, p1[1] as f64, p1[2] as f64);
let p2 = DVec3::new(p2[0] as f64, p2[1] as f64, p2[2] as f64);
let (p1, p2) = if wire.points.len() == 2 {
(wp_f64(wire, 0), wp_f64(wire, 1))
} else {
(Vec3::from_array(*p1).as_dvec3(), Vec3::from_array(*p2).as_dvec3())
};
(frame.contains(p1, tol) && frame.contains(p2, tol)).then(|| Curve::Line(KLine { start: frame.to_2d(p1), end: frame.to_2d(p2) }))
}
TangentGeom::Circle { center, radius } => {
let c = DVec3::new(center[0] as f64, center[1] as f64, center[2] as f64);
frame.contains(c, tol).then(|| Curve::Circle(KCircle { centre: frame.to_2d(c), radius: *radius as f64 }))
(DVec3::Z.cross(frame.normal).length() <= tol && frame.contains(c, tol)).then(|| Curve::Circle(KCircle { centre: frame.to_2d(c), radius: *radius as f64 }))
}
TangentGeom::PlanarCircle { center, axis_x, axis_y, radius } => {
let c = DVec3::new(center[0], center[1], center[2]);
@@ -2659,16 +2431,16 @@ fn curve_in_frame(wire: &WireModel, frame: &WirePlane, tol: f64) -> Option<Curve
if n.cross(frame.normal).length() > tol || !frame.contains(c, tol) {
return None;
}
let boundary = |angle: f64| c + *radius * (angle.cos() * ax + angle.sin() * ay);
let (raw_start, raw_end) = (angle_in_frame(boundary(*start_angle), c), angle_in_frame(boundary(*end_angle), c));
// The arc's own (ax, ay, n) frame and the target frame's (axis_x, axis_y, normal)
// agree on the plane but may disagree on which way is "up": when n is anti-parallel
// to frame.normal, mapping into the frame's basis is a reflection, which reverses
// the CCW sweep direction. Swap start/end so the swept range still traces the same
// physical arc instead of its complement.
let (start_angle, end_angle) = if n.dot(frame.normal) < 0.0 { (raw_end, raw_start) } else { (raw_start, raw_end) };
Some(Curve::Arc(KArc { centre: frame.to_2d(c), radius: *radius, start_angle, end_angle }))
let arc = KArc { centre: frame.to_2d(c), radius: *radius, start_angle: *start_angle, end_angle: *end_angle };
let sweep = arc.sweep();
if (sweep - std::f64::consts::TAU).abs() <= 1e-12 {
return Some(Curve::Circle(KCircle { centre: arc.centre, radius: *radius }));
}
let boundary = if n.dot(frame.normal) < 0.0 { *end_angle } else { *start_angle };
let start_angle = angle_in_frame(c + *radius * (boundary.cos() * ax + boundary.sin() * ay), c);
Some(Curve::Arc(KArc { start_angle, end_angle: start_angle + sweep, ..arc }))
}
TangentGeom::PlanarEllipse { center, major_axis, normal, minor_axis_ratio, start_param, end_param } => {
let c = DVec3::new(center[0], center[1], center[2]);
let ell_normal = DVec3::new(normal[0], normal[1], normal[2]).normalize();
@@ -2687,13 +2459,11 @@ fn curve_in_frame(wire: &WireModel, frame: &WirePlane, tol: f64) -> Option<Curve
return None;
}
let major_axis_2d = [dir2d[0] / len, dir2d[1] / len];
let world_at = |t: f64| ellipse_world_point(c, major, ell_normal, minor_radius, t);
let raw_start = ellipse_param_in_frame(world_at(*start_param), c, major_axis_2d, major_radius, minor_radius, frame);
let raw_end = ellipse_param_in_frame(world_at(*end_param), c, major_axis_2d, major_radius, minor_radius, frame);
// Same reflection issue as the Arc case above: the ellipse's own normal may be
// anti-parallel to the embedding frame's normal, which mirrors the parametrization
// and reverses the sweep direction — swap start/end to keep the same physical arc.
let (start_parameter, end_parameter) = if ell_normal.dot(frame.normal) < 0.0 { (raw_end, raw_start) } else { (raw_start, raw_end) };
let (start_parameter, end_parameter) = if ell_normal.dot(frame.normal) < 0.0 {
(-*end_param, -*start_param)
} else {
(*start_param, *end_param)
};
Some(Curve::Ellipse(KEllipseArc {
ellipse: KEllipse { centre: frame.to_2d(c), major_radius, minor_radius, major_axis: major_axis_2d },
start_parameter,
@@ -2703,33 +2473,8 @@ fn curve_in_frame(wire: &WireModel, frame: &WirePlane, tol: f64) -> Option<Curve
}
}
/// Exact (or, where no closed form exists — an ellipse against another
/// curved shape — properly tolerance-converged, via `cadkernel::geom2d::
/// intersect`'s own subdivision-and-refinement fallback) intersection for
/// one wire pair, bypassing their tessellated approximation entirely.
/// Tries the fast, frame-independent circle/arc path first; the general
/// embedding below only runs for a pair that path can't answer — a line
/// paired with a circle/arc/ellipse, or anything involving an ellipse.
/// `None` when neither side is a single recognized curve (both lines: the
/// segment sweep is already exact), the two aren't coplanar, or they don't
/// actually meet — in every `None` case the caller falls back to the
/// ordinary segment sweep unchanged.
fn exact_curve_intersections(wire_a: &WireModel, wire_b: &WireModel) -> Option<Vec<DVec3>> {
if let Some(pts) = exact_circle_intersections(wire_a, wire_b) {
return Some(pts);
}
// Prefer whichever side already has a natural working plane so a
// circle/arc paired with a line needs no reprojection of its own shape
// at all; when both are curved (at least one an ellipse, since a pure
// circle/arc pair would already have returned above) or neither is,
// fall back to a plane seeded from whichever side offers one.
let frame = wire_plane(wire_a).or_else(|| wire_plane(wire_b)).or_else(|| {
let normal = curve_normal_only(wire_a).or_else(|| curve_normal_only(wire_b))?;
let origin = wp_f64_first(wire_a).or_else(|| wp_f64_first(wire_b))?;
let (axis_x, axis_y) = stable_plane_basis(normal);
Some(WirePlane { origin, axis_x, axis_y, normal })
})?;
let frame = wire_plane(wire_a).or_else(|| wire_plane(wire_b))?;
const PLANE_TOL: f64 = 1e-7;
let curve_a = curve_in_frame(wire_a, &frame, PLANE_TOL)?;
@@ -2743,20 +2488,7 @@ fn exact_curve_intersections(wire_a: &WireModel, wire_b: &WireModel) -> Option<V
let tolerance = cadkernel::geom2d::Tolerance::new(1e-9_f64.max(PLANE_TOL));
let crossings = cadkernel::geom2d::intersect(&curve_a, &curve_b, tolerance);
let points: Vec<DVec3> = crossings.into_iter().map(|c| frame.to_3d(c.point)).collect();
(!points.is_empty()).then_some(points)
}
/// Fallback plane-seeding helpers for `exact_curve_intersections`' rare
/// "neither side has its own natural plane" branch (in practice: unreached
/// today, since that only happens when both sides are lines, which returns
/// `None` a step earlier — kept for robustness against a future caller that
/// invokes `exact_curve_intersections` with weaker preconditions).
fn curve_normal_only(wire: &WireModel) -> Option<DVec3> {
wire_plane(wire).map(|p| p.normal)
}
fn wp_f64_first(wire: &WireModel) -> Option<DVec3> {
(!wire.points.is_empty()).then(|| wp_f64(wire, 0))
Some(points)
}
/// XY-plane segment-segment intersection. Returns `None` if parallel or outside.
@@ -3339,14 +3071,8 @@ mod ext_tests {
);
}
/// GitHub discussion #1052's own reproduction: a 3-unit base line, a
/// radius-4 circle at one end, a radius-5 circle at the other. The true
/// intersection is exactly (0, 4, 0) (a 3-4-5 right triangle) — the
/// tessellated segment sweep misses this by a small but real amount
/// (confirmed separately against `seg_intersect_3d` on the actual
/// tessellated wire); the exact solver must hit it exactly.
#[test]
fn exact_circle_intersections_matches_the_3_4_5_report() {
fn exact_curve_intersections_matches_the_3_4_5_report() {
let c1 = WireModel {
tangent_geoms: vec![TangentGeom::PlanarCircle {
center: [0.0, 0.0, 0.0],
@@ -3365,7 +3091,7 @@ mod ext_tests {
}],
..Default::default()
};
let pts = exact_circle_intersections(&c1, &c2).expect("two overlapping circles must intersect");
let pts = exact_curve_intersections(&c1, &c2).expect("two overlapping circles must intersect");
assert_eq!(pts.len(), 2, "two distinct circles crossing at two points");
let upper = pts.iter().copied().find(|p| p.y > 0.0).expect("an upper intersection");
assert!((upper - DVec3::new(0.0, 4.0, 0.0)).length() < 1e-9, "expected exactly (0,4,0), got {upper:?}");
@@ -3376,11 +3102,7 @@ mod ext_tests {
}
#[test]
fn exact_circle_intersections_respects_an_arcs_own_sweep() {
// A quarter-circle arc from 0° to 90° (so it only covers the upper-
// right quadrant) against a full circle whose two true intersection
// points straddle that boundary: only the one actually on the arc's
// sweep should come back.
fn exact_curve_intersections_respects_an_arcs_own_sweep() {
let arc = WireModel {
tangent_geoms: vec![TangentGeom::Arc {
center: [0.0, 0.0, 0.0],
@@ -3403,13 +3125,13 @@ mod ext_tests {
};
// Full-circle math gives (0,4,0) [on the 0..90° arc] and (0,-4,0)
// [not on it].
let pts = exact_circle_intersections(&arc, &circle).expect("the circles still cross");
let pts = exact_curve_intersections(&arc, &circle).expect("the circles still cross");
assert_eq!(pts.len(), 1, "only the point on the arc's own sweep");
assert!((pts[0] - DVec3::new(0.0, 4.0, 0.0)).length() < 1e-9);
}
#[test]
fn exact_circle_intersections_is_none_for_non_coplanar_circles() {
fn exact_curve_intersections_is_none_for_non_coplanar_circles() {
let flat = WireModel {
tangent_geoms: vec![TangentGeom::PlanarCircle {
center: [0.0, 0.0, 0.0],
@@ -3429,13 +3151,13 @@ mod ext_tests {
..Default::default()
};
assert!(
exact_circle_intersections(&flat, &tilted).is_none(),
exact_curve_intersections(&flat, &tilted).is_none(),
"a genuinely-3D pair must fall back to the ordinary sweep, not guess a plane"
);
}
#[test]
fn exact_circle_intersections_is_none_when_circles_dont_meet() {
fn exact_curve_intersections_resolves_disjoint_circles() {
let near = WireModel {
tangent_geoms: vec![TangentGeom::PlanarCircle { center: [0.0, 0.0, 0.0], axis_x: [1.0, 0.0, 0.0], axis_y: [0.0, 1.0, 0.0], radius: 1.0 }],
..Default::default()
@@ -3444,37 +3166,11 @@ mod ext_tests {
tangent_geoms: vec![TangentGeom::PlanarCircle { center: [100.0, 0.0, 0.0], axis_x: [1.0, 0.0, 0.0], axis_y: [0.0, 1.0, 0.0], radius: 1.0 }],
..Default::default()
};
assert!(exact_circle_intersections(&near, &far).is_none());
assert!(exact_curve_intersections(&near, &far).unwrap().is_empty());
}
#[test]
fn wire_circle_is_none_for_a_plain_line_wire() {
// A line wire carries no `TangentGeom::Circle`/`Arc` entry, so
// `wire_circle` (and the fast `exact_circle_intersections` path built
// on it) must not panic or, worse, silently misidentify a line as a
// circle — it should just decline, leaving the line-vs-circle case to
// the more general `exact_curve_intersections`, covered separately by
// `exact_curve_intersections_handles_a_line_against_a_circle`.
let line = WireModel {
points: vec![[0.0, 0.0, 0.0], [10.0, 0.0, 0.0]],
..Default::default()
};
assert!(wire_circle(&line).is_none());
let circle = WireModel {
tangent_geoms: vec![TangentGeom::PlanarCircle { center: [0.0, 0.0, 0.0], axis_x: [1.0, 0.0, 0.0], axis_y: [0.0, 1.0, 0.0], radius: 4.0 }],
..Default::default()
};
assert!(exact_circle_intersections(&line, &circle).is_none(), "line-vs-circle isn't this fast path's job — it's handled by exact_curve_intersections");
}
/// Each side's `contains_point` checks the candidate world point against
/// *that side's own* native axis_x/axis_y independently — there is no
/// step that re-expresses one arc's angle range in the other's frame —
/// so two arcs whose local frames are rotated relative to each other
/// must still resolve correctly, not just the common case where both
/// happen to share the same axis_x.
#[test]
fn exact_circle_intersections_handles_two_arcs_in_differently_rotated_frames() {
fn exact_curve_intersections_handles_two_arcs_in_differently_rotated_frames() {
// Arc A: quarter circle 0..90°, axis_x along world +X.
let arc_a = WireModel {
tangent_geoms: vec![TangentGeom::Arc {
@@ -3487,12 +3183,6 @@ mod ext_tests {
}],
..Default::default()
};
// Arc B: same true geometry as the earlier "full circle" partner,
// but described in a frame rotated 90° from world axes (axis_x
// along world +Y, axis_y along world -X) — its own start/end angles
// are re-expressed accordingly so the arc still covers the same
// true half of the circle (the lower half, i.e. world angle
// 180..360°, which in this rotated frame is local angle 90..270°).
let arc_b = WireModel {
tangent_geoms: vec![TangentGeom::Arc {
center: [3.0, 0.0, 0.0],
@@ -3504,14 +3194,7 @@ mod ext_tests {
}],
..Default::default()
};
// True full-circle crossings are (0,4,0) [world angle 90° on B, so
// NOT on B's lower-half sweep] and (0,-4,0) [world angle 270°, IS on
// B's sweep, and also not on A's 0..90° sweep]. Independently
// filtering each side's own frame should therefore find nothing —
// proving the two arcs' angle checks aren't accidentally sharing or
// confusing each other's frame (a bug here would likely either
// wrongly accept one of the two points or wrongly accept both).
assert!(exact_circle_intersections(&arc_a, &arc_b).is_none());
assert!(exact_curve_intersections(&arc_a, &arc_b).unwrap().is_empty());
// Flip A to cover the lower-right quadrant (270..360°) instead: now
// (0,-4,0) is on both A's and B's own sweep, independently checked
@@ -3527,7 +3210,7 @@ mod ext_tests {
}],
..Default::default()
};
let pts = exact_circle_intersections(&arc_a_lower, &arc_b).expect("both sweeps cover (0,-4,0)");
let pts = exact_curve_intersections(&arc_a_lower, &arc_b).expect("both sweeps cover (0,-4,0)");
assert_eq!(pts.len(), 1);
assert!((pts[0] - DVec3::new(0.0, -4.0, 0.0)).length() < 1e-9, "got {:?}", pts[0]);
}
@@ -3555,10 +3238,6 @@ mod ext_tests {
#[test]
fn exact_curve_intersections_handles_a_line_against_an_arcs_own_sweep() {
// Same arc as `exact_circle_intersections_respects_an_arcs_own_sweep`
// (0..90° of a radius-4 circle at the origin), crossed by a vertical
// line at x=0: the full circle would cross it at (0,4,0) and
// (0,-4,0), but only the first is on the arc's own sweep.
let arc = WireModel {
tangent_geoms: vec![TangentGeom::Arc {
center: [0.0, 0.0, 0.0],
@@ -3629,13 +3308,6 @@ mod ext_tests {
}
}
/// The critical case for `ellipse_param_in_frame`: two elliptical arcs
/// (here degenerate to circles via `minor_axis_ratio: 1.0`, so the
/// *expected* crossing angles are plain circle trigonometry, independently
/// computed) sharing the SAME numeric start/end parameter range but with
/// ANTI-PARALLEL normals. Reusing a native parameter value unchanged
/// across a normal flip would silently pick the mirror-image point
/// instead — this proves it doesn't.
#[test]
fn exact_curve_intersections_ellipse_arc_respects_normal_direction() {
// Full ellipse x^2/25 + y^2/9 = 1, and a radius-3 "ellipse" (ratio
@@ -3681,12 +3353,6 @@ mod ext_tests {
assert!((minus[0] - DVec3::new(3.75, -(63.0_f64).sqrt() / 4.0, 0.0)).length() < 1e-6);
}
/// Same reflection hazard as the ellipse test above, but for `curve_in_frame`'s
/// `TangentGeom::Arc` branch: this only runs when the *other* side of the pair
/// isn't itself a circle/arc (so `exact_circle_intersections`'s own, already
/// normal-agnostic `WireCircle` path doesn't intercept it first) — paired here
/// against the same full ellipse so the frame comes from the ellipse's plane
/// (normal = +Z) while the arc's own normal is anti-parallel to it.
#[test]
fn exact_curve_intersections_arc_respects_normal_direction_against_an_ellipse_frame() {
let ellipse = WireModel {
@@ -3719,4 +3385,62 @@ mod ext_tests {
assert!(pts[0].y < 0.0, "expected the lower point (normal flip must change which physical arc this is), got {:?}", pts[0]);
assert!((pts[0] - DVec3::new(3.75, -(63.0_f64).sqrt() / 4.0, 0.0)).length() < 1e-6);
}
#[test]
fn indexed_intersection_uses_true_curves_even_when_chords_miss_the_aperture() {
use crate::scene::pick::interaction_index::InteractionIndex;
use std::sync::Arc;
let circle = |name: &str, x: f64, radius: f64| {
let points = (0..=48).map(|i| {
let angle = i as f64 * std::f64::consts::TAU / 48.0 + if x == 0.0 { 0.03 } else { 0.0 };
[(x + radius * angle.cos()) as f32, (radius * angle.sin()) as f32, 0.0]
}).collect();
let mut wire = WireModel::solid(name.to_owned(), points, [1.0; 4], false);
wire.aabb = [(x - radius) as f32, -radius as f32, (x + radius) as f32, radius as f32];
wire.tangent_geoms = vec![TangentGeom::PlanarCircle {
center: [x, 0.0, 0.0], axis_x: [1.0, 0.0, 0.0], axis_y: [0.0, 1.0, 0.0], radius,
}];
wire
};
let wires = Arc::new(vec![circle("1", 0.0, 4.0), circle("2", 3.0, 5.0)]);
let index = InteractionIndex::build(&wires);
let candidates = index.query_xy(Arc::clone(&wires), [-0.0001, 3.9999, 0.0001, 4.0001]);
assert_eq!(candidates.len(), 2);
assert!(candidates.segments().unwrap().iter().all(|segment| segment.wire == 1));
let point = DVec3::new(0.0, 4.0, 0.0);
let mut snapper = Snapper::default();
snapper.snap_enabled = true;
snapper.enabled = [SnapType::Intersection].into_iter().collect();
let result = snapper.snap(
point, Point::new(500.0, 500.0), &candidates,
Mat4::from_scale(Vec3::splat(100.0)), point,
Rectangle { x: 0.0, y: 0.0, width: 1000.0, height: 1000.0 },
Vec3::ZERO, (Vec3::X, Vec3::Y, Vec3::Z), None,
).expect("the true intersection remains available at high zoom");
assert!((result.world - point).length() < 1e-9);
}
#[test]
fn line_circle_intersection_retains_low_coordinate_bits() {
let origin = 1_000_000_000.0;
let a = [origin - 10.0, origin, 0.0];
let b = [origin + 10.0, origin, 0.0];
let points = [a, b].map(|point| point.map(|value| value as f32));
let low = [a, b].into_iter().zip(points).map(|(point, high)| {
std::array::from_fn(|axis| (point[axis] - high[axis] as f64) as f32)
}).collect();
let line = WireModel {
points: points.to_vec(), points_low: low,
tangent_geoms: vec![TangentGeom::Line { p1: points[0], p2: points[1] }],
..Default::default()
};
let circle = WireModel {
tangent_geoms: vec![TangentGeom::PlanarCircle {
center: [origin, origin, 0.0], axis_x: [1.0, 0.0, 0.0], axis_y: [0.0, 1.0, 0.0], radius: 3.0,
}], ..Default::default()
};
let points = exact_curve_intersections(&line, &circle).unwrap();
assert_eq!(points.len(), 2);
assert!(points.iter().all(|point| (point.x - origin).abs() == 3.0 && point.y == origin));
}
}
+7
View File
@@ -226,6 +226,13 @@ pub async fn read_clipboard_text() -> Option<String> {
value.as_string()
}
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen::prelude::wasm_bindgen(module = "/web/clipboard.js")]
extern "C" {
#[wasm_bindgen::prelude::wasm_bindgen(js_name = copyHistory)]
pub fn copy_history_text(text: &str, fallback_label: &str, close_label: &str) -> js_sys::Promise;
}
/// Web: write text to the system clipboard (fire-and-forget). Backs Ctrl+C in
/// plain text_input fields, whose iced-internal copy is a no-op on the web
/// (#346). The triggering keypress is a user gesture, so the write is allowed.
+2 -2
View File
@@ -849,10 +849,10 @@ impl CommandLine {
.align_y(iced::Center),
)
.width(Length::Fill)
.padding([2, 6]);
.padding([2, 8]);
let panel = container(column![header, log])
.width(Length::Fill)
.padding([4, 8]);
.padding(Padding { top: 2.0, right: 8.0, bottom: 4.0, left: 8.0 });
let resize = iced::widget::mouse_area(
container(crate::ui::icons::themed_primary(
crate::ui::icons::RESIZE,
+36 -4
View File
@@ -447,6 +447,10 @@ pub struct PropertiesPanel {
/// carried across panel rebuilds so the state survives edits and selection
/// changes.
pub expanded_groups: HashSet<String>,
/// Property sections the user collapsed. This is a rendered copy of the
/// app-wide preference, allowing every open document panel to share it.
/// Empty by default, meaning every section is initially shown.
pub collapsed_sections: HashSet<String>,
/// Whether the editable-dropdown (block Name) option list is open.
pub edit_choice_open: bool,
/// Field key of the value row currently being edited, or `None`. Marked when
@@ -485,6 +489,7 @@ impl Default for PropertiesPanel {
prop_vertex: 0,
prop_vertex_indicator_active: false,
expanded_groups: HashSet::default(),
collapsed_sections: HashSet::default(),
edit_choice_open: false,
active_field: None,
field_key_by_id: HashMap::default(),
@@ -710,8 +715,32 @@ impl PropertiesPanel {
// ── Section renderer ──────────────────────────────────────────────────
fn render_section<'a>(&'a self, section: &'a PropSection) -> Element<'a, Message> {
// Section header
let hdr = container(text(&section.title).size(10))
let collapsed = self.collapsed_sections.contains(&section.title);
let toggle = button(if collapsed {
crate::ui::icons::themed_arrow_right(10.0)
} else {
crate::ui::icons::themed_arrow_down(10.0)
})
.on_press(Message::PropSectionToggle(section.title.clone()))
.style(button::text)
.padding([0, 3]);
// The arrow intentionally sits at the far end of the header. The rest
// of the header responds to a double-click, leaving a single click on
// the arrow as the precise, discoverable toggle target.
let title = mouse_area(
container(text(&section.title).size(10))
.width(Length::Fill)
.padding([3, 8]),
)
.on_double_click(Message::PropSectionToggle(section.title.clone()));
let hdr = container(
row![
title,
toggle,
]
.align_y(iced::Center),
)
.style(|theme: &Theme| {
let palette = theme.palette();
container::Style {
@@ -724,10 +753,12 @@ impl PropertiesPanel {
..Default::default()
}
})
.width(Length::Fill)
.padding([3, 8]);
.width(Length::Fill);
let mut col = column![hdr].spacing(0);
if collapsed {
return col.into();
}
// Consecutive "<Base> X / <Base> Y [/ <Base> Z]" text rows collapse
// into one clickable summary row; clicking expands the components.
@@ -890,6 +921,7 @@ impl PropertiesPanel {
| "text_color"
| "block_content_color"
| "background_fill_color"
| "indicator_fill_color"
) {
let open = self.open_color_field.as_deref() == Some(field);
let fsel = field.to_string();
+143 -5
View File
@@ -13,6 +13,25 @@ pub enum OptionsTab {
General,
Display,
Selection,
Drawing,
}
/// The Selection-card settings that live on `UserSettings` rather than on the
/// model-space theme.
///
/// Gathered into one value instead of four more positional parameters: the
/// function already takes seventeen, and `pick_add` / `pick_drag_rect` are two
/// adjacent `bool`s that would swap silently at the call site.
#[derive(Clone, Copy)]
pub struct SelectionPrefs {
/// PICKBOX, 0..=50.
pub pick_box: i32,
/// PICKADD: true = a click adds to the selection.
pub pick_add: bool,
/// PICKDRAG: true = press-drag draws a rectangle instead of a lasso.
pub pick_drag_rect: bool,
/// GRIPOBJLIMIT, 0..=32767; 0 = no limit.
pub grip_object_limit: i32,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -37,7 +56,9 @@ pub fn view_window<'a>(
language: crate::i18n::Language,
active_tab: OptionsTab,
cursor_size: i32,
pick_box: i32,
selection: SelectionPrefs,
double_click_block_refedit: bool,
double_click_block_attedit: bool,
cursor_type: CursorType,
crosshair_color: Option<[u8; 3]>,
crosshair_color_input: &'a str,
@@ -634,10 +655,12 @@ pub fn view_window<'a>(
Space::new().height(10),
row![
text(crate::t!("Pick box size")).size(12).width(140),
slider(0..=50, pick_box.clamp(0, 50), Message::PickBoxChanged)
slider(0..=50, selection.pick_box.clamp(0, 50), Message::PickBoxChanged)
.step(1)
.width(Fill),
text(pick_box.clamp(0, 50).to_string()).size(11).width(44),
text(selection.pick_box.clamp(0, 50).to_string())
.size(11)
.width(44),
]
.spacing(10)
.align_y(iced::Center),
@@ -648,6 +671,30 @@ pub fn view_window<'a>(
.size(11)
.width(sizing.width),
Space::new().height(24),
text(crate::t!("Selection Modes")).size(15),
Space::new().height(10),
row![
// Checked is the inverse of PICKADD: plain clicks replace and Shift adds.
iced::widget::checkbox(!selection.pick_add)
.on_toggle(Message::ShiftToAddToggled)
.size(15),
text(crate::t!("Use Shift to add to selection (PICKADD)")).size(12),
]
.spacing(8)
.align_y(iced::Center),
Space::new().height(10),
row![
iced::widget::checkbox(selection.pick_drag_rect)
.on_toggle(Message::PickDragRectToggled)
.size(15),
text(crate::t!(
"Press and drag draws a rectangle instead of a lasso (PICKDRAG)"
))
.size(12),
]
.spacing(8)
.align_y(iced::Center),
Space::new().height(24),
row![
text(crate::t!("Visual Effect Settings")).size(15),
Space::new().width(Fill),
@@ -716,6 +763,15 @@ pub fn view_window<'a>(
.spacing(10)
.align_y(iced::Center),
Space::new().height(10),
row![
iced::widget::checkbox(model_space.selection_effect)
.on_toggle(Message::SelectionEffectToggled)
.size(15),
text(crate::t!("Show selection effect (SELECTIONEFFECT)")).size(12),
]
.spacing(8)
.align_y(iced::Center),
Space::new().height(10),
row![
text(crate::t!("Selection highlight color")).size(12).width(140),
iced::widget::pick_list(
@@ -729,6 +785,34 @@ pub fn view_window<'a>(
.spacing(10)
.align_y(iced::Center),
Space::new().height(24),
text(crate::t!("Preview")).size(15),
Space::new().height(10),
row![
// SELECTIONPREVIEW is a bitmask; bit 1 is the idle rollover and
// bit 2 the one that runs while a command is gathering objects.
iced::widget::checkbox(model_space.selection_preview & 1 != 0)
.on_toggle(Message::SelectionPreviewIdleToggled)
.size(15),
text(crate::t!("Preview selection when no command is active")).size(12),
]
.spacing(8)
.align_y(iced::Center),
Space::new().height(10),
row![
iced::widget::checkbox(model_space.selection_preview & 2 != 0)
.on_toggle(Message::SelectionPreviewCommandToggled)
.size(15),
text(crate::t!("Preview selection during a command")).size(12),
]
.spacing(8)
.align_y(iced::Center),
Space::new().height(6),
text(crate::t!(
"Highlights the object under the cursor before it is picked (SELECTIONPREVIEW)."
))
.size(11)
.width(sizing.width),
Space::new().height(24),
text(crate::t!("Grip Settings")).size(15),
Space::new().height(10),
row![
@@ -747,6 +831,33 @@ pub fn view_window<'a>(
.spacing(10)
.align_y(iced::Center),
Space::new().height(10),
row![
text(crate::t!("Object limit for grips")).size(12).width(140),
// Values above the slider range remain available through SETVAR.
slider(
0..=1000,
selection.grip_object_limit.clamp(0, 1000),
Message::GripObjectLimitChanged,
)
.step(1)
.width(Fill),
text(if selection.grip_object_limit == 0 {
crate::t!("Unlimited").into_owned()
} else {
selection.grip_object_limit.to_string()
})
.size(11)
.width(44),
]
.spacing(10)
.align_y(iced::Center),
Space::new().height(6),
text(crate::t!(
"Past this many selected objects no grips are drawn; 0 removes the limit (GRIPOBJLIMIT)."
))
.size(11)
.width(sizing.width),
Space::new().height(10),
row![
text(crate::t!("Unselected grip color")).size(12).width(140),
iced::widget::pick_list(
@@ -789,10 +900,35 @@ pub fn view_window<'a>(
.spacing(0)
.width(sizing.width);
let drawing = column![
text(crate::t!("Block Edit")).size(15),
Space::new().height(10),
row![
iced::widget::checkbox(double_click_block_refedit)
.on_toggle(Message::DoubleClickBlockRefeditChanged)
.size(15),
text(crate::t!("Double-click to edit block in-place (REFEDIT)")).size(12),
]
.spacing(8)
.align_y(iced::Center),
Space::new().height(18),
row![
iced::widget::checkbox(double_click_block_attedit)
.on_toggle(Message::DoubleClickBlockAtteditChanged)
.size(15),
text(crate::t!("Double-click attributed blocks to edit attributes (ATTEDIT)")).size(12),
]
.spacing(8)
.align_y(iced::Center),
]
.spacing(0)
.width(sizing.width);
let content: Element<'a, Message> = match active_tab {
OptionsTab::General => general.into(),
OptionsTab::Display => display_element.into(),
OptionsTab::Selection => selection.into(),
OptionsTab::Drawing => drawing.into(),
};
let tab_button = |label, tab| {
@@ -806,6 +942,7 @@ pub fn view_window<'a>(
tab_button(crate::t!("General"), OptionsTab::General),
tab_button(crate::t!("Display"), OptionsTab::Display),
tab_button(crate::t!("Selection"), OptionsTab::Selection),
tab_button(crate::t!("Drawing"), OptionsTab::Drawing),
]
.spacing(6);
@@ -821,10 +958,11 @@ pub fn view_window<'a>(
.width(sizing.width)
.height(sizing.height);
let intrinsic = sizing.width == crate::ui::modal::ModalSizing::INTRINSIC.width;
container(body)
.style(container::rounded_box)
.padding([16, 18])
.width(sizing.width)
.height(sizing.height)
.width(if intrinsic { iced::Length::Fixed(540.0) } else { sizing.width })
.height(if intrinsic { iced::Length::Fixed(560.0) } else { sizing.height })
.into()
}