forked from 1b-agent/opencadstudio-upstream
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 (commit973af87d, 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 (commit4c264b8c) 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:
commit
d675244fb8
89 files changed
+5700
-1101
No files matched your search
+18
-20
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -717,6 +717,10 @@ impl OpenCADStudio {
|
||||
entity,
|
||||
EntityType::Block(_)
|
||||
| EntityType::BlockEnd(_)
|
||||
| EntityType::Extended(acadrust::entities::ExtendedEntity {
|
||||
data: acadrust::entities::ExtendedEntityData::SectionObject(_),
|
||||
..
|
||||
})
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 != < => 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, §ions);
|
||||
}
|
||||
// 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(§ions, "layer").expect("a layer row");
|
||||
assert!(
|
||||
format!("{:?}", layer.value).contains("WALLS"),
|
||||
"one layer across all three: {:?}",
|
||||
layer.value,
|
||||
);
|
||||
let color = row(§ions, "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(§ions, "layer").expect("a layer row");
|
||||
assert!(format!("{:?}", layer.value).contains("WALLS"));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user