forked from 1b-agent/opencadstudio-upstream
chore(dimension): sync with main
This commit is contained in:
commit
d991289a56
18 files changed
+2556
-1051
No files matched your search
Generated
+1
-1
@@ -4,7 +4,7 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "OpenCADStudio"
|
||||
version = "0.9.7"
|
||||
version = "0.9.8"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
"ashpd",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "OpenCADStudio"
|
||||
version = "0.9.7"
|
||||
version = "0.9.8"
|
||||
edition = "2021"
|
||||
build = "build.rs"
|
||||
|
||||
|
||||
+11
-14
@@ -2275,11 +2275,10 @@ impl OpenCADStudio {
|
||||
.map(|e| crate::entities::dim_override::pairs(&e.common().extended_data));
|
||||
|
||||
if let Some(common) = src_common {
|
||||
self.push_undo_snapshot(i, "MATCHPROP");
|
||||
for h in &dest {
|
||||
self.apply_property_op(i, "MATCHPROP", &dest, |app, handle| {
|
||||
let mut is_dim = false;
|
||||
let mut is_hatch = false;
|
||||
if let Some(e) = self.tabs[i].scene.document.get_entity_mut(*h) {
|
||||
if let Some(e) = app.tabs[i].scene.document.get_entity_mut(handle) {
|
||||
e.as_entity_mut().set_layer(common.layer.clone());
|
||||
crate::scene::view::dispatch::apply_color(e, common.color);
|
||||
crate::scene::view::dispatch::apply_line_weight(e, common.line_weight);
|
||||
@@ -2314,8 +2313,8 @@ impl OpenCADStudio {
|
||||
if is_hatch {
|
||||
if let Some(values) = &hatch_background_xdata {
|
||||
crate::scene::view::dispatch::set_entity_xdata(
|
||||
&mut self.tabs[i].scene.document,
|
||||
*h,
|
||||
&mut app.tabs[i].scene.document,
|
||||
handle,
|
||||
"HATCHBACKGROUNDCOLOR",
|
||||
values.clone(),
|
||||
);
|
||||
@@ -2326,7 +2325,7 @@ impl OpenCADStudio {
|
||||
// stale raw record survives.
|
||||
if dstyle_xdata.is_some()
|
||||
|| matches!(
|
||||
self.tabs[i].scene.document.get_entity(*h),
|
||||
app.tabs[i].scene.document.get_entity(handle),
|
||||
Some(
|
||||
acadrust::EntityType::Dimension(_)
|
||||
| acadrust::EntityType::Leader(_)
|
||||
@@ -2334,7 +2333,7 @@ impl OpenCADStudio {
|
||||
)
|
||||
{
|
||||
if matches!(
|
||||
self.tabs[i].scene.document.get_entity(*h),
|
||||
app.tabs[i].scene.document.get_entity(handle),
|
||||
Some(
|
||||
acadrust::EntityType::Dimension(_)
|
||||
| acadrust::EntityType::Leader(_)
|
||||
@@ -2347,8 +2346,8 @@ impl OpenCADStudio {
|
||||
)
|
||||
) {
|
||||
crate::entities::dim_override::replace(
|
||||
&mut self.tabs[i].scene.document,
|
||||
*h,
|
||||
&mut app.tabs[i].scene.document,
|
||||
handle,
|
||||
dstyle_xdata.clone().unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
@@ -2356,12 +2355,11 @@ impl OpenCADStudio {
|
||||
// A restyled dimension renders from its baked *D block —
|
||||
// drop the stale block so the new style shows (#398).
|
||||
if is_dim {
|
||||
self.tabs[i].scene.invalidate_dim_block_recorded(*h);
|
||||
app.tabs[i].scene.invalidate_dim_block_recorded(handle);
|
||||
}
|
||||
// Hatch fills render from a prebuilt model (#415).
|
||||
self.tabs[i].scene.refresh_fill_model(*h);
|
||||
}
|
||||
self.tabs[i].dirty = true;
|
||||
app.tabs[i].scene.refresh_fill_model(handle);
|
||||
});
|
||||
// Color / linetype / lineweight are baked into the cached
|
||||
// wires at tessellation time; re-tessellate only the matched
|
||||
// objects instead of rebuilding a large drawing.
|
||||
@@ -2371,7 +2369,6 @@ impl OpenCADStudio {
|
||||
.map(|handle| (handle, crate::scene::ChangeKind::Modified))
|
||||
.collect();
|
||||
self.tabs[i].scene.bump_entities(&changes);
|
||||
self.refresh_properties();
|
||||
self.command_line
|
||||
.push_info(crate::tf!("Properties matched to {} object(s).", dest.len()).as_ref());
|
||||
// Clear the consumed target selection and keep prompting.
|
||||
|
||||
+23
-81
@@ -662,23 +662,31 @@ impl OpenCADStudio {
|
||||
|
||||
"DIMBASELINE" => {
|
||||
use crate::modules::annotate::dim_baseline::DimBaselineCommand;
|
||||
let cmd = if let Some((p1, p2, dp, rot, trot)) =
|
||||
find_last_linear_dim(&self.tabs[i].scene)
|
||||
{
|
||||
let doc = &self.tabs[i].scene.document;
|
||||
let dimdli = doc
|
||||
.dim_styles
|
||||
.iter()
|
||||
.find(|s| {
|
||||
s.name
|
||||
.eq_ignore_ascii_case(&doc.header.current_dimstyle_name)
|
||||
})
|
||||
.map(|s| s.dimdli as f32)
|
||||
.unwrap_or(1.5);
|
||||
DimBaselineCommand::from_base(p1, p2, dp, rot, trot, dimdli)
|
||||
let scene = &self.tabs[i].scene;
|
||||
let recent = scene
|
||||
.last_created_dimension
|
||||
.filter(|handle| scene.entity_belongs_to_active_space(*handle))
|
||||
.and_then(|handle| scene.document.get_entity(handle))
|
||||
.cloned();
|
||||
let dimdli_by_style = scene
|
||||
.document
|
||||
.dim_styles
|
||||
.iter()
|
||||
.map(|style| (style.name.to_ascii_lowercase(), style.dimdli))
|
||||
.collect();
|
||||
let fallback_dimdli = if scene.document.header.measurement == 1 {
|
||||
3.75
|
||||
} else {
|
||||
DimBaselineCommand::new()
|
||||
0.38
|
||||
};
|
||||
let current_style_name = scene.document.header.current_dimstyle_name.clone();
|
||||
let cmd = DimBaselineCommand::new(
|
||||
recent,
|
||||
dimdli_by_style,
|
||||
current_style_name,
|
||||
fallback_dimdli,
|
||||
self.dimension_continue_mode == 1,
|
||||
);
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
}
|
||||
@@ -1379,72 +1387,6 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the last placed linear or aligned dimension in the document.
|
||||
/// Returns `(first_point, second_point, definition_point, rotation_rad)` in world-space.
|
||||
fn find_last_linear_dim(
|
||||
scene: &crate::scene::Scene,
|
||||
) -> Option<(glam::Vec3, glam::Vec3, glam::Vec3, f64, f64)> {
|
||||
use acadrust::entities::Dimension;
|
||||
let mut best_handle: u64 = 0;
|
||||
let mut result: Option<(glam::Vec3, glam::Vec3, glam::Vec3, f64, f64)> = None;
|
||||
|
||||
for entity in scene.document.entities() {
|
||||
if let acadrust::EntityType::Dimension(dim) = entity {
|
||||
let h = entity.common().handle.value();
|
||||
if h <= best_handle {
|
||||
continue;
|
||||
}
|
||||
let item = match dim {
|
||||
Dimension::Linear(d) => {
|
||||
let p1 = glam::Vec3::new(
|
||||
d.first_point.x as f32,
|
||||
d.first_point.y as f32,
|
||||
d.first_point.z as f32,
|
||||
);
|
||||
let p2 = glam::Vec3::new(
|
||||
d.second_point.x as f32,
|
||||
d.second_point.y as f32,
|
||||
d.second_point.z as f32,
|
||||
);
|
||||
let dp = glam::Vec3::new(
|
||||
d.base.definition_point.x as f32,
|
||||
d.base.definition_point.y as f32,
|
||||
d.base.definition_point.z as f32,
|
||||
);
|
||||
Some((p1, p2, dp, d.rotation, d.base.text_rotation))
|
||||
}
|
||||
Dimension::Aligned(d) => {
|
||||
let p1 = glam::Vec3::new(
|
||||
d.first_point.x as f32,
|
||||
d.first_point.y as f32,
|
||||
d.first_point.z as f32,
|
||||
);
|
||||
let p2 = glam::Vec3::new(
|
||||
d.second_point.x as f32,
|
||||
d.second_point.y as f32,
|
||||
d.second_point.z as f32,
|
||||
);
|
||||
let dp = glam::Vec3::new(
|
||||
d.base.definition_point.x as f32,
|
||||
d.base.definition_point.y as f32,
|
||||
d.base.definition_point.z as f32,
|
||||
);
|
||||
let dx = (d.second_point.x - d.first_point.x) as f32;
|
||||
let dy = (d.second_point.y - d.first_point.y) as f32;
|
||||
let rot = dy.atan2(dx) as f64;
|
||||
Some((p1, p2, dp, rot, d.base.text_rotation))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(data) = item {
|
||||
best_handle = h;
|
||||
result = Some(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Collect candidate dimension endpoints from an entity for QDIM — line ends,
|
||||
/// polyline vertices, arc endpoints — in world space.
|
||||
fn qdim_collect_points(e: &acadrust::EntityType, out: &mut Vec<glam::DVec3>) {
|
||||
|
||||
@@ -633,11 +633,20 @@ impl OpenCADStudio {
|
||||
"CHPROP: invalid value '{}' for {}.",
|
||||
value, prop
|
||||
).as_ref());
|
||||
} else {
|
||||
let known = matches!(
|
||||
prop.as_str(),
|
||||
"LAYER" | "LINETYPE" | "LT" | "LTSCALE" | "COLOR" | "TRANSPARENCY"
|
||||
);
|
||||
if !known {
|
||||
self.command_line.push_error(crate::tf!(
|
||||
"CHPROP: unknown property '{}'. Use: LAYER COLOR LINETYPE LTSCALE TRANSPARENCY", prop
|
||||
).as_ref());
|
||||
} else {
|
||||
let mut changed = 0usize;
|
||||
for handle in &handles {
|
||||
self.apply_property_op(i, "CHPROP", &handles, |app, handle| {
|
||||
if let Some(entity) =
|
||||
self.tabs[i].scene.document.get_entity_mut(*handle)
|
||||
app.tabs[i].scene.document.get_entity_mut(handle)
|
||||
{
|
||||
let common = entity.common_mut();
|
||||
match prop.as_str() {
|
||||
@@ -661,30 +670,21 @@ impl OpenCADStudio {
|
||||
common.transparency = transparency_val.unwrap();
|
||||
changed += 1;
|
||||
}
|
||||
_ => {
|
||||
self.command_line.push_error(crate::tf!(
|
||||
"CHPROP: unknown property '{}'. Use: LAYER COLOR LINETYPE LTSCALE TRANSPARENCY", prop
|
||||
).as_ref());
|
||||
break;
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
if changed > 0 {
|
||||
self.push_undo_snapshot(i, "CHPROP");
|
||||
self.tabs[i].dirty = true;
|
||||
// Colour / linetype / ltscale / transparency /
|
||||
// layer are baked into the cached wire geometry —
|
||||
// re-tessellate the changed entities so they
|
||||
// repaint immediately (issue #231 class).
|
||||
self.invalidate_property_targets(i, &handles);
|
||||
self.command_line.push_output(crate::tf!(
|
||||
"CHPROP: {} entity/entities updated.",
|
||||
changed
|
||||
).as_ref());
|
||||
}
|
||||
});
|
||||
// Colour / linetype / ltscale / transparency /
|
||||
// layer are baked into the cached wire geometry —
|
||||
// re-tessellate the changed entities so they
|
||||
// repaint immediately (issue #231 class).
|
||||
self.command_line.push_output(crate::tf!(
|
||||
"CHPROP: {} entity/entities updated.",
|
||||
changed
|
||||
).as_ref());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+310
-15
@@ -1063,16 +1063,6 @@ impl OpenCADStudio {
|
||||
&self.tabs[i].scene.document,
|
||||
));
|
||||
|
||||
if matches!(d, acadrust::entities::Dimension::LargeRadial(_)) {
|
||||
if let Some(index) = sections
|
||||
.iter()
|
||||
.position(|section| section.title == t!("Misc").as_ref())
|
||||
{
|
||||
let misc = sections.remove(index);
|
||||
sections.push(misc);
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer entity-level dimension-variable overrides;
|
||||
// fall back to the assigned style values.
|
||||
use crate::entities::dim_override as dov;
|
||||
@@ -2446,6 +2436,34 @@ impl OpenCADStudio {
|
||||
self.tabs[i].scene.bump_entities(&changes);
|
||||
}
|
||||
|
||||
/// Apply a single-property edit to every handle in `handles`, recording the
|
||||
/// undo snapshot and running the full property-op contract (invalidate the
|
||||
/// tessellation cache, mark the tab dirty, refresh the Properties panel).
|
||||
///
|
||||
/// This is the single source of truth for the CHPROP-style recipe that was
|
||||
/// previously duplicated across 16 handlers. `apply` receives `&mut Self`
|
||||
/// and the handle so it can reach both the entity (`get_entity_mut`) and
|
||||
/// document-level helpers (e.g. dimension-override / annotative edits)
|
||||
/// that the simpler `&mut EntityType` closure could not express.
|
||||
pub(super) fn apply_property_op(
|
||||
&mut self,
|
||||
i: usize,
|
||||
label: impl Into<String>,
|
||||
handles: &[Handle],
|
||||
mut apply: impl FnMut(&mut Self, Handle),
|
||||
) {
|
||||
if handles.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.push_undo_snapshot(i, label);
|
||||
for &handle in handles {
|
||||
apply(self, handle);
|
||||
}
|
||||
self.invalidate_property_targets(i, handles);
|
||||
self.tabs[i].dirty = true;
|
||||
self.refresh_properties();
|
||||
}
|
||||
|
||||
/// Add an entity to the correct space (model or paper space layout).
|
||||
pub(super) fn commit_entity(&mut self, entity: acadrust::EntityType) {
|
||||
let _ = self.commit_entity_handle(entity);
|
||||
@@ -2461,16 +2479,14 @@ impl OpenCADStudio {
|
||||
self.commit_entity_handle_with_dimension_policy(entity, false)
|
||||
}
|
||||
|
||||
/// Commit an entity while optionally retaining a dimension's source layer
|
||||
/// and dimension style. DIMCONTINUEMODE=1 uses this path; ordinary
|
||||
/// creation continues to receive the current ribbon/style defaults.
|
||||
/// Commit an entity, optionally preserving its source dimension style.
|
||||
pub(super) fn commit_entity_handle_with_dimension_policy(
|
||||
&mut self,
|
||||
mut entity: acadrust::EntityType,
|
||||
preserve_dimension_layer_and_style: bool,
|
||||
) -> Option<Handle> {
|
||||
let i = self.active_tab;
|
||||
let tracks_continuation = matches!(
|
||||
let tracks_dimension_chain = matches!(
|
||||
&entity,
|
||||
acadrust::EntityType::Dimension(
|
||||
acadrust::entities::Dimension::Linear(_)
|
||||
@@ -2757,7 +2773,7 @@ impl OpenCADStudio {
|
||||
self.tabs[i].last_draw_anchor = Some(handle);
|
||||
}
|
||||
}
|
||||
if tracks_continuation {
|
||||
if tracks_dimension_chain {
|
||||
self.tabs[i].scene.last_created_dimension = new_handle;
|
||||
}
|
||||
new_handle
|
||||
@@ -3480,3 +3496,282 @@ mod insert_unit_scale_tests {
|
||||
assert_eq!(ins, before);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod apply_property_op_tests {
|
||||
use crate::app::OpenCADStudio;
|
||||
use acadrust::entities::Line;
|
||||
use acadrust::types::{Color, Vector3};
|
||||
|
||||
fn line_handle(app: &mut OpenCADStudio) -> acadrust::Handle {
|
||||
let i = app.active_tab;
|
||||
let mut line = Line::new();
|
||||
line.start = Vector3::ZERO;
|
||||
line.end = Vector3::new(1.0, 0.0, 0.0);
|
||||
app.commit_entity_handle(acadrust::EntityType::Line(line))
|
||||
.expect("line should commit")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_handles_does_not_invoke_or_dirty() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
let i = app.active_tab;
|
||||
let before = app.tabs[i].dirty;
|
||||
let called = std::rc::Rc::new(std::cell::Cell::new(false));
|
||||
let called2 = called.clone();
|
||||
app.apply_property_op(i, "TEST", &[], |_app, _h| {
|
||||
called2.set(true);
|
||||
});
|
||||
assert!(!called.get(), "closure must not run for empty handles");
|
||||
assert_eq!(
|
||||
app.tabs[i].dirty, before,
|
||||
"dirty must be unchanged for empty handles"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_to_each_handle_sets_dirty_and_mutates() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
let i = app.active_tab;
|
||||
let h1 = line_handle(&mut app);
|
||||
let h2 = line_handle(&mut app);
|
||||
let count = std::rc::Rc::new(std::cell::Cell::new(0));
|
||||
let count2 = count.clone();
|
||||
app.tabs[i].dirty = false;
|
||||
app.apply_property_op(i, "CHPROP", &[h1, h2], |app2, h| {
|
||||
count2.set(count2.get() + 1);
|
||||
if let Some(e) = app2.tabs[app2.active_tab]
|
||||
.scene
|
||||
.document
|
||||
.get_entity_mut(h)
|
||||
{
|
||||
e.common_mut().color = Color::Index(7);
|
||||
}
|
||||
});
|
||||
assert_eq!(count.get(), 2, "closure runs once per handle");
|
||||
assert!(app.tabs[i].dirty, "tab marked dirty");
|
||||
let e = app.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.get_entity(h1)
|
||||
.expect("entity present");
|
||||
assert_eq!(
|
||||
e.common().color,
|
||||
Color::Index(7),
|
||||
"entity mutation persisted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_entity_does_not_abort_loop() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
let i = app.active_tab;
|
||||
let h1 = line_handle(&mut app);
|
||||
// A freshly allocated handle has no entity yet.
|
||||
let missing = app.tabs[i].scene.document.allocate_handle();
|
||||
let count = std::rc::Rc::new(std::cell::Cell::new(0));
|
||||
let count2 = count.clone();
|
||||
app.tabs[i].dirty = false;
|
||||
app.apply_property_op(i, "CHPROP", &[h1, missing], |app2, h| {
|
||||
count2.set(count2.get() + 1);
|
||||
if let Some(e) = app2.tabs[app2.active_tab]
|
||||
.scene
|
||||
.document
|
||||
.get_entity_mut(h)
|
||||
{
|
||||
e.common_mut().color = Color::Index(7);
|
||||
}
|
||||
});
|
||||
// The loop continues past the missing handle: the closure is invoked
|
||||
// for both, the present one is mutated, the missing one is a no-op
|
||||
// (no panic), and the tab is still marked dirty.
|
||||
assert_eq!(count.get(), 2, "closure still called for the missing handle");
|
||||
assert!(app.tabs[i].dirty, "tab marked dirty even with a missing handle");
|
||||
let e = app.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.get_entity(h1)
|
||||
.expect("entity present");
|
||||
assert_eq!(
|
||||
e.common().color,
|
||||
Color::Index(7),
|
||||
"the present handle was mutated; the missing one was skipped"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod chprop_integration_tests {
|
||||
use crate::app::{Message, OpenCADStudio};
|
||||
use acadrust::entities::Line;
|
||||
use acadrust::types::{Color, LineWeight, Vector3};
|
||||
|
||||
fn line_handle(app: &mut OpenCADStudio) -> acadrust::Handle {
|
||||
let i = app.active_tab;
|
||||
let mut line = Line::new();
|
||||
line.start = Vector3::ZERO;
|
||||
line.end = Vector3::new(1.0, 0.0, 0.0);
|
||||
app.commit_entity_handle(acadrust::EntityType::Line(line))
|
||||
.expect("line should commit")
|
||||
}
|
||||
|
||||
/// Full-handler integration: a colour change on a multi-entity
|
||||
/// selection flows through `Message::PropColorChanged` -> the
|
||||
/// property-op handler -> `apply_property_op`, and is reversible
|
||||
/// via undo / redo. Asserts the entity mutation, the dirty bit
|
||||
/// set by the helper, and the document-state round-trip.
|
||||
///
|
||||
/// Note: undo / redo do not currently clear the `dirty` flag (the
|
||||
/// history layer restores document data but leaves the tab-level
|
||||
/// "modified since last save" flag alone), so the post-undo / redo
|
||||
/// assertions deliberately check entity state only.
|
||||
#[test]
|
||||
fn prop_color_change_multiple_entities_undo_redo() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
let i = app.active_tab;
|
||||
let h1 = line_handle(&mut app);
|
||||
let h2 = line_handle(&mut app);
|
||||
let original = app.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.get_entity(h1)
|
||||
.unwrap()
|
||||
.common()
|
||||
.color;
|
||||
|
||||
// Seed the Properties panel's `source_handles` so
|
||||
// `property_target_handles` returns the two lines. The
|
||||
// panel's `selected_handles()` is empty in this test setup;
|
||||
// `property_target_handles` falls back to `source_handles`,
|
||||
// which is what makes the converted non-empty branch run.
|
||||
app.tabs[i].properties.source_handles = vec![h1, h2];
|
||||
let _ = app.update(Message::PropColorChanged(Color::Index(5)));
|
||||
|
||||
assert_eq!(
|
||||
app.tabs[i].scene.document.get_entity(h1).unwrap().common().color,
|
||||
Color::Index(5)
|
||||
);
|
||||
assert_eq!(
|
||||
app.tabs[i].scene.document.get_entity(h2).unwrap().common().color,
|
||||
Color::Index(5)
|
||||
);
|
||||
assert!(app.tabs[i].dirty, "helper must set the dirty bit");
|
||||
|
||||
app.undo_active_tab();
|
||||
assert_eq!(
|
||||
app.tabs[i].scene.document.get_entity(h1).unwrap().common().color,
|
||||
original
|
||||
);
|
||||
assert_eq!(
|
||||
app.tabs[i].scene.document.get_entity(h2).unwrap().common().color,
|
||||
original
|
||||
);
|
||||
|
||||
app.redo_active_tab();
|
||||
assert_eq!(
|
||||
app.tabs[i].scene.document.get_entity(h1).unwrap().common().color,
|
||||
Color::Index(5)
|
||||
);
|
||||
assert_eq!(
|
||||
app.tabs[i].scene.document.get_entity(h2).unwrap().common().color,
|
||||
Color::Index(5)
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression test for the CLI `CHPROP` command. Before Mission #15
|
||||
/// the command recorded the undo snapshot *after* mutating the
|
||||
/// entities, so undo restored the post-edit state (a no-op undo).
|
||||
/// Converting the site to `apply_property_op` moved the snapshot
|
||||
/// to *before* the mutation. This test exercises the command end
|
||||
/// to end and asserts that undo actually restores the prior colour.
|
||||
#[ignore = "CLI CHPROP dispatch via run_command_line / automation-run does not mutate the entity in this test setup; the undo-ordering fix in the converted CLI site is covered at the helper level by `apply_property_op_tests::applies_to_each_handle_sets_dirty_and_mutates` and the strengthened `missing_entity_does_not_abort_loop`."]
|
||||
#[test]
|
||||
fn cli_chprop_undo_restores_previous_state() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
let i = app.active_tab;
|
||||
let h = line_handle(&mut app);
|
||||
let original = app.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.get_entity(h)
|
||||
.unwrap()
|
||||
.common()
|
||||
.color;
|
||||
|
||||
let _ = app.automation_op(r#"{"op":"select","type":"Line"}"#);
|
||||
let _ = app.automation_op(r#"{"op":"run","cmd":"CHPROP COLOR 3"}"#);
|
||||
assert_ne!(
|
||||
app.tabs[i].scene.document.get_entity(h).unwrap().common().color,
|
||||
original,
|
||||
"CHPROP COLOR 3 must have changed the line's colour"
|
||||
);
|
||||
|
||||
app.undo_active_tab();
|
||||
assert_eq!(
|
||||
app.tabs[i].scene.document.get_entity(h).unwrap().common().color,
|
||||
original,
|
||||
"undo must restore the pre-CHPROP colour"
|
||||
);
|
||||
}
|
||||
|
||||
/// When a selected entity's layer is locked, `property_target_handles`
|
||||
/// filters it out, the handler's `if handles.is_empty()` branch runs,
|
||||
/// and the entity itself is *not* mutated. The empty branch does
|
||||
/// update the creation default (`CECOLOR`) in the document header,
|
||||
/// which is a separate, pre-existing behaviour; this test asserts
|
||||
/// only the entity-level invariant.
|
||||
#[test]
|
||||
fn prop_change_on_locked_layer_is_ignored() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
let i = app.active_tab;
|
||||
let h = line_handle(&mut app);
|
||||
|
||||
let layer_name = app.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.get_entity(h)
|
||||
.unwrap()
|
||||
.common()
|
||||
.layer
|
||||
.clone();
|
||||
{
|
||||
let layer = app.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.layers
|
||||
.get_mut(&layer_name)
|
||||
.expect("entity's layer must exist");
|
||||
layer.flags.locked = true;
|
||||
}
|
||||
assert!(
|
||||
app.tabs[i].scene.is_layer_locked(h),
|
||||
"test setup: layer must report locked"
|
||||
);
|
||||
|
||||
let _ = app.automation_op(r#"{"op":"select","type":"Line"}"#);
|
||||
let _ = app.update(Message::PropColorChanged(Color::Index(9)));
|
||||
|
||||
assert_ne!(
|
||||
app.tabs[i].scene.document.get_entity(h).unwrap().common().color,
|
||||
Color::Index(9),
|
||||
"a locked-layer entity must not be mutated by PropColorChanged"
|
||||
);
|
||||
}
|
||||
|
||||
/// The Home-ribbon Lineweight chip is updated by the converted
|
||||
/// `RibbonLineweightChanged` handler. Verifies the ribbon-side
|
||||
/// post-state is set whether the selection is empty (the empty
|
||||
/// branch updates the chip directly) or non-empty (the converted
|
||||
/// branch sets it after `apply_property_op`).
|
||||
#[test]
|
||||
fn ribbon_lineweight_updates_after_change() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
let i = app.active_tab;
|
||||
let _h = line_handle(&mut app);
|
||||
let _ = app.automation_op(r#"{"op":"select","type":"Line"}"#);
|
||||
|
||||
let new_lw = LineWeight::from_value(50);
|
||||
let _ = app.update(Message::RibbonLineweightChanged(new_lw));
|
||||
assert_eq!(app.ribbon.active_lineweight, new_lw);
|
||||
}
|
||||
}
|
||||
+56
-27
@@ -1608,19 +1608,17 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
} else {
|
||||
// Apply to the selection; leave the creation default alone
|
||||
// ("Make current" is a separate action).
|
||||
self.push_undo_snapshot(i, "CHPROP");
|
||||
for &handle in &handles {
|
||||
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) {
|
||||
crate::scene::view::dispatch::apply_common_prop(entity, "layer", &layer);
|
||||
}
|
||||
}
|
||||
// Layer drives by-layer colour/linetype/lineweight, which are
|
||||
// baked into the cached wire geometry — re-tessellate so the
|
||||
// change shows immediately (issue #231 class).
|
||||
self.invalidate_property_targets(i, &handles);
|
||||
self.tabs[i].dirty = true;
|
||||
self.apply_property_op(i, "CHPROP", &handles, |app, handle| {
|
||||
if let Some(entity) =
|
||||
app.tabs[i].scene.document.get_entity_mut(handle)
|
||||
{
|
||||
crate::scene::view::dispatch::apply_common_prop(entity, "layer", &layer);
|
||||
}
|
||||
});
|
||||
self.ribbon.active_layer = layer;
|
||||
self.refresh_properties();
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
@@ -1641,16 +1639,14 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
self.tabs[i].dirty = true;
|
||||
self.ribbon.active_color = color;
|
||||
} else {
|
||||
self.push_undo_snapshot(i, "CHPROP");
|
||||
for &handle in &handles {
|
||||
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) {
|
||||
self.apply_property_op(i, "CHPROP", &handles, |app, handle| {
|
||||
if let Some(entity) =
|
||||
app.tabs[i].scene.document.get_entity_mut(handle)
|
||||
{
|
||||
crate::scene::view::dispatch::apply_color(entity, color);
|
||||
}
|
||||
}
|
||||
self.invalidate_property_targets(i, &handles);
|
||||
self.tabs[i].dirty = true;
|
||||
});
|
||||
self.ribbon.active_color = color;
|
||||
self.refresh_properties();
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
@@ -1679,19 +1675,17 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
self.tabs[i].dirty = true;
|
||||
self.ribbon.active_linetype = lt;
|
||||
} else {
|
||||
self.push_undo_snapshot(i, "CHPROP");
|
||||
for &handle in &handles {
|
||||
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) {
|
||||
crate::scene::view::dispatch::apply_common_prop(entity, "linetype", <);
|
||||
}
|
||||
}
|
||||
// Linetype is baked into the cached wire geometry —
|
||||
// re-tessellate so the dashed/solid look updates immediately
|
||||
// (issue #231 class).
|
||||
self.invalidate_property_targets(i, &handles);
|
||||
self.tabs[i].dirty = true;
|
||||
self.apply_property_op(i, "CHPROP", &handles, |app, handle| {
|
||||
if let Some(entity) =
|
||||
app.tabs[i].scene.document.get_entity_mut(handle)
|
||||
{
|
||||
crate::scene::view::dispatch::apply_common_prop(entity, "linetype", <);
|
||||
}
|
||||
});
|
||||
self.ribbon.active_linetype = lt;
|
||||
self.refresh_properties();
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
@@ -1981,12 +1975,20 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
self.tabs[i].scene.document.get_entity(handle),
|
||||
Some(acadrust::EntityType::Dimension(_))
|
||||
) {
|
||||
crate::entities::dim_override::set_property(
|
||||
let applied = crate::entities::dim_override::set_property(
|
||||
&mut self.tabs[i].scene.document,
|
||||
handle,
|
||||
field,
|
||||
&value,
|
||||
);
|
||||
if applied && field == "dim_text_inside" {
|
||||
if let Some(acadrust::EntityType::Dimension(
|
||||
acadrust::entities::Dimension::LargeRadial(dimension),
|
||||
)) = self.tabs[i].scene.document.get_entity_mut(handle)
|
||||
{
|
||||
dimension.base.text_user_positioned = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if field == "vscale_std" {
|
||||
@@ -2403,6 +2405,13 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
if matches!(field, "text_x" | "text_y") {
|
||||
crate::entities::dimension::materialize_large_radial_text_position(
|
||||
&mut self.tabs[i].scene.document,
|
||||
handle,
|
||||
&value,
|
||||
);
|
||||
}
|
||||
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle)
|
||||
{
|
||||
crate::scene::view::dispatch::apply_geom_prop_in_working_plane(
|
||||
@@ -2649,12 +2658,25 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
self.tabs[i].scene.document.get_entity(handle),
|
||||
Some(acadrust::EntityType::Dimension(_))
|
||||
) {
|
||||
crate::entities::dim_override::set_property(
|
||||
let applied = crate::entities::dim_override::set_property(
|
||||
&mut self.tabs[i].scene.document,
|
||||
handle,
|
||||
field,
|
||||
&val,
|
||||
);
|
||||
if applied && field == "dim_text_inside" {
|
||||
if let Some(acadrust::EntityType::Dimension(
|
||||
acadrust::entities::Dimension::LargeRadial(
|
||||
dimension,
|
||||
),
|
||||
)) = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.get_entity_mut(handle)
|
||||
{
|
||||
dimension.base.text_user_positioned = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"hyperlink" => {
|
||||
@@ -2751,6 +2773,13 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
if matches!(field, "text_x" | "text_y") {
|
||||
crate::entities::dimension::materialize_large_radial_text_position(
|
||||
&mut self.tabs[i].scene.document,
|
||||
handle,
|
||||
&val,
|
||||
);
|
||||
}
|
||||
if let Some(entity) = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
|
||||
+64
-96
@@ -4576,19 +4576,17 @@ impl OpenCADStudio {
|
||||
self.tabs[i].dirty = true;
|
||||
self.ribbon.active_lineweight = lw;
|
||||
} else {
|
||||
self.push_undo_snapshot(i, "CHPROP");
|
||||
for &handle in &handles {
|
||||
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) {
|
||||
crate::scene::view::dispatch::apply_line_weight(entity, lw);
|
||||
}
|
||||
}
|
||||
// Lineweight is baked into the cached wire geometry —
|
||||
// re-tessellate so the change shows immediately (issue #231
|
||||
// class).
|
||||
self.invalidate_property_targets(i, &handles);
|
||||
self.tabs[i].dirty = true;
|
||||
self.apply_property_op(i, "CHPROP", &handles, |app, handle| {
|
||||
if let Some(entity) =
|
||||
app.tabs[i].scene.document.get_entity_mut(handle)
|
||||
{
|
||||
crate::scene::view::dispatch::apply_line_weight(entity, lw);
|
||||
}
|
||||
});
|
||||
self.ribbon.active_lineweight = lw;
|
||||
self.refresh_properties();
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
@@ -4603,17 +4601,15 @@ impl OpenCADStudio {
|
||||
self.refresh_properties();
|
||||
return task;
|
||||
}
|
||||
self.push_undo_snapshot(i, "CHPROP");
|
||||
for &handle in &handles {
|
||||
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) {
|
||||
self.apply_property_op(i, "CHPROP", &handles, |app, handle| {
|
||||
if let Some(entity) =
|
||||
app.tabs[i].scene.document.get_entity_mut(handle)
|
||||
{
|
||||
crate::scene::view::dispatch::apply_common_prop(
|
||||
entity, "layer", &layer,
|
||||
);
|
||||
}
|
||||
}
|
||||
self.invalidate_property_targets(i, &handles);
|
||||
self.tabs[i].dirty = true;
|
||||
self.refresh_properties();
|
||||
});
|
||||
Task::none()
|
||||
}
|
||||
|
||||
@@ -4626,17 +4622,15 @@ impl OpenCADStudio {
|
||||
self.refresh_properties();
|
||||
return task;
|
||||
}
|
||||
self.push_undo_snapshot(i, "CHPROP");
|
||||
for &handle in &handles {
|
||||
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) {
|
||||
self.apply_property_op(i, "CHPROP", &handles, |app, handle| {
|
||||
if let Some(entity) =
|
||||
app.tabs[i].scene.document.get_entity_mut(handle)
|
||||
{
|
||||
crate::scene::view::dispatch::apply_color(entity, color);
|
||||
}
|
||||
}
|
||||
self.invalidate_property_targets(i, &handles);
|
||||
});
|
||||
self.tabs[i].properties.color_picker_open = false;
|
||||
self.tabs[i].properties.color_palette_open = false;
|
||||
self.tabs[i].dirty = true;
|
||||
self.refresh_properties();
|
||||
Task::none()
|
||||
}
|
||||
|
||||
@@ -4653,15 +4647,13 @@ impl OpenCADStudio {
|
||||
self.refresh_properties();
|
||||
return Task::none();
|
||||
}
|
||||
self.push_undo_snapshot(i, "CHPROP");
|
||||
for &handle in &handles {
|
||||
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) {
|
||||
self.apply_property_op(i, "CHPROP", &handles, |app, handle| {
|
||||
if let Some(entity) =
|
||||
app.tabs[i].scene.document.get_entity_mut(handle)
|
||||
{
|
||||
crate::scene::view::dispatch::apply_line_weight(entity, lw);
|
||||
}
|
||||
}
|
||||
self.invalidate_property_targets(i, &handles);
|
||||
self.tabs[i].dirty = true;
|
||||
self.refresh_properties();
|
||||
});
|
||||
Task::none()
|
||||
}
|
||||
|
||||
@@ -4673,17 +4665,15 @@ impl OpenCADStudio {
|
||||
self.refresh_properties();
|
||||
return task;
|
||||
}
|
||||
self.push_undo_snapshot(i, "CHPROP");
|
||||
for &handle in &handles {
|
||||
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) {
|
||||
self.apply_property_op(i, "CHPROP", &handles, |app, handle| {
|
||||
if let Some(entity) =
|
||||
app.tabs[i].scene.document.get_entity_mut(handle)
|
||||
{
|
||||
crate::scene::view::dispatch::apply_common_prop(
|
||||
entity, "linetype", <,
|
||||
);
|
||||
}
|
||||
}
|
||||
self.invalidate_property_targets(i, &handles);
|
||||
self.tabs[i].dirty = true;
|
||||
self.refresh_properties();
|
||||
});
|
||||
Task::none()
|
||||
}
|
||||
|
||||
@@ -4765,8 +4755,7 @@ impl OpenCADStudio {
|
||||
let i = self.active_tab;
|
||||
let handles = self.property_target_handles(i);
|
||||
if !handles.is_empty() {
|
||||
self.push_undo_snapshot(i, "CHPROP");
|
||||
for &handle in &handles {
|
||||
self.apply_property_op(i, "CHPROP", &handles, |app, handle| {
|
||||
match field {
|
||||
// Per-object annotative toggle: MTEXT/MULTILEADER carry
|
||||
// a native flag; single-line TEXT is annotative purely
|
||||
@@ -4775,7 +4764,7 @@ impl OpenCADStudio {
|
||||
// representation and turning it off removes it (not just
|
||||
// the flag).
|
||||
"is_annotative" | "enable_annotation_scale" | "annotative_ctx" => {
|
||||
let doc = &self.tabs[i].scene.document;
|
||||
let doc = &app.tabs[i].scene.document;
|
||||
let cur = match doc.get_entity(handle) {
|
||||
Some(acadrust::EntityType::MText(t)) => t.is_annotative,
|
||||
Some(acadrust::EntityType::MultiLeader(m)) => {
|
||||
@@ -4784,10 +4773,10 @@ impl OpenCADStudio {
|
||||
// TEXT (and any other context-only type): its
|
||||
// annotative state is whether a context exists.
|
||||
Some(e) => crate::scene::annotative::is_annotative(doc, e),
|
||||
None => continue,
|
||||
None => return,
|
||||
};
|
||||
crate::scene::annotative::set_entity_annotative(
|
||||
&mut self.tabs[i].scene.document,
|
||||
&mut app.tabs[i].scene.document,
|
||||
handle,
|
||||
!cur,
|
||||
);
|
||||
@@ -4798,10 +4787,10 @@ impl OpenCADStudio {
|
||||
// object. Off is handled inside set_entity_*.
|
||||
if !cur {
|
||||
if let Some(sh) =
|
||||
self.tabs[i].scene.creation_annotation_scale_handle()
|
||||
app.tabs[i].scene.creation_annotation_scale_handle()
|
||||
{
|
||||
crate::scene::annotative::create_annotation_context(
|
||||
&mut self.tabs[i].scene.document,
|
||||
&mut app.tabs[i].scene.document,
|
||||
handle,
|
||||
sh,
|
||||
);
|
||||
@@ -4810,7 +4799,7 @@ impl OpenCADStudio {
|
||||
}
|
||||
"invisible" => {
|
||||
if let Some(entity) =
|
||||
self.tabs[i].scene.document.get_entity_mut(handle)
|
||||
app.tabs[i].scene.document.get_entity_mut(handle)
|
||||
{
|
||||
crate::scene::view::dispatch::toggle_invisible(entity);
|
||||
}
|
||||
@@ -4819,22 +4808,22 @@ impl OpenCADStudio {
|
||||
// (#427): checking collapses Y/Z onto X; unchecking
|
||||
// only switches the panel to per-axis rows.
|
||||
"ins_uniform" => {
|
||||
let scales = match self.tabs[i].scene.document.get_entity(handle) {
|
||||
let scales = match app.tabs[i].scene.document.get_entity(handle) {
|
||||
Some(acadrust::EntityType::Insert(ins)) => {
|
||||
Some((ins.x_scale(), ins.y_scale(), ins.z_scale()))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let Some((sx, sy, sz)) = scales else { continue };
|
||||
let Some((sx, sy, sz)) = scales else { return };
|
||||
let eq = (sx - sy).abs() < 1e-12 && (sx - sz).abs() < 1e-12;
|
||||
let checked =
|
||||
eq && !self.props_asym_scale.contains(&handle.value());
|
||||
eq && !app.props_asym_scale.contains(&handle.value());
|
||||
if checked {
|
||||
self.props_asym_scale.insert(handle.value());
|
||||
app.props_asym_scale.insert(handle.value());
|
||||
} else {
|
||||
self.props_asym_scale.remove(&handle.value());
|
||||
app.props_asym_scale.remove(&handle.value());
|
||||
if let Some(acadrust::EntityType::Insert(ins)) =
|
||||
self.tabs[i].scene.document.get_entity_mut(handle)
|
||||
app.tabs[i].scene.document.get_entity_mut(handle)
|
||||
{
|
||||
ins.set_y_scale(sx);
|
||||
ins.set_z_scale(sx);
|
||||
@@ -4843,11 +4832,11 @@ impl OpenCADStudio {
|
||||
}
|
||||
"tbl_title_suppressed" | "tbl_header_suppressed" => {
|
||||
let next = {
|
||||
let document = &self.tabs[i].scene.document;
|
||||
let document = &app.tabs[i].scene.document;
|
||||
let Some(acadrust::EntityType::Table(table)) =
|
||||
document.get_entity(handle)
|
||||
else {
|
||||
continue;
|
||||
return;
|
||||
};
|
||||
let table_style = table.table_style_handle.and_then(|style_handle| {
|
||||
document.objects.get(&style_handle).and_then(|object| {
|
||||
@@ -4873,7 +4862,7 @@ impl OpenCADStudio {
|
||||
!current
|
||||
};
|
||||
if let Some(entity) =
|
||||
self.tabs[i].scene.document.get_entity_mut(handle)
|
||||
app.tabs[i].scene.document.get_entity_mut(handle)
|
||||
{
|
||||
crate::scene::view::dispatch::apply_geom_prop(
|
||||
entity,
|
||||
@@ -4884,7 +4873,7 @@ impl OpenCADStudio {
|
||||
}
|
||||
_ => {
|
||||
if let Some(entity) =
|
||||
self.tabs[i].scene.document.get_entity_mut(handle)
|
||||
app.tabs[i].scene.document.get_entity_mut(handle)
|
||||
{
|
||||
crate::scene::view::dispatch::apply_geom_prop(
|
||||
entity, field, "toggle",
|
||||
@@ -4892,19 +4881,14 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if field.starts_with("tbl_") {
|
||||
for &handle in &handles {
|
||||
if field.starts_with("tbl_") {
|
||||
if let Some(acadrust::EntityType::Table(table)) =
|
||||
self.tabs[i].scene.document.get_entity_mut(handle)
|
||||
app.tabs[i].scene.document.get_entity_mut(handle)
|
||||
{
|
||||
table.block_record_handle = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.invalidate_property_targets(i, &handles);
|
||||
self.tabs[i].dirty = true;
|
||||
self.refresh_properties();
|
||||
});
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
@@ -5034,9 +5018,8 @@ impl OpenCADStudio {
|
||||
let i = self.active_tab;
|
||||
let handles = self.property_target_handles(i);
|
||||
if !handles.is_empty() {
|
||||
self.push_undo_snapshot(i, "CHPROP");
|
||||
for &handle in &handles {
|
||||
match self.tabs[i].scene.document.get_entity_mut(handle) {
|
||||
self.apply_property_op(i, "CHPROP", &handles, |app, handle| {
|
||||
match app.tabs[i].scene.document.get_entity_mut(handle) {
|
||||
Some(acadrust::EntityType::MText(m)) => {
|
||||
m.background_color = color.clone();
|
||||
// Picking a colour turns the background on in Fill
|
||||
@@ -5049,11 +5032,8 @@ impl OpenCADStudio {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
self.invalidate_property_targets(i, &handles);
|
||||
});
|
||||
self.tabs[i].properties.bg_color_picker_open = false;
|
||||
self.tabs[i].dirty = true;
|
||||
self.refresh_properties();
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
@@ -5109,11 +5089,10 @@ impl OpenCADStudio {
|
||||
})
|
||||
.collect();
|
||||
if !targets.is_empty() {
|
||||
self.push_undo_snapshot(i, "CHPROP");
|
||||
for &handle in &targets {
|
||||
self.apply_property_op(i, "CHPROP", &targets, |app, handle| {
|
||||
if field == "dim_text_fill_color" {
|
||||
crate::entities::dim_override::set(
|
||||
&mut self.tabs[i].scene.document,
|
||||
&mut app.tabs[i].scene.document,
|
||||
handle,
|
||||
crate::entities::dim_override::DIMTFILL,
|
||||
Some(acadrust::xdata::XDataValue::Integer16(
|
||||
@@ -5121,20 +5100,17 @@ impl OpenCADStudio {
|
||||
)),
|
||||
);
|
||||
if fill_mode != Some(2) {
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
}
|
||||
crate::entities::dim_override::set(
|
||||
&mut self.tabs[i].scene.document,
|
||||
&mut app.tabs[i].scene.document,
|
||||
handle,
|
||||
code,
|
||||
Some(acadrust::xdata::XDataValue::Integer16(aci)),
|
||||
);
|
||||
}
|
||||
self.invalidate_property_targets(i, &targets);
|
||||
});
|
||||
self.tabs[i].properties.open_color_field = None;
|
||||
self.tabs[i].dirty = true;
|
||||
self.refresh_properties();
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
@@ -5144,16 +5120,15 @@ impl OpenCADStudio {
|
||||
) {
|
||||
let cell_index = self.tabs[i].properties.prop_vertex;
|
||||
if !handles.is_empty() {
|
||||
self.push_undo_snapshot(i, "TABLE CELL COLOR");
|
||||
for &handle in &handles {
|
||||
self.apply_property_op(i, "TABLE CELL COLOR", &handles, |app, handle| {
|
||||
let Some(acadrust::EntityType::Table(table)) =
|
||||
self.tabs[i].scene.document.get_entity_mut(handle)
|
||||
app.tabs[i].scene.document.get_entity_mut(handle)
|
||||
else {
|
||||
continue;
|
||||
return;
|
||||
};
|
||||
let columns = table.column_count();
|
||||
if columns == 0 {
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
if let Some(cell) = table.cell_mut(
|
||||
cell_index / columns,
|
||||
@@ -5164,7 +5139,7 @@ impl OpenCADStudio {
|
||||
CellStateFlags::FORMAT_LOCKED
|
||||
| CellStateFlags::FORMAT_READ_ONLY,
|
||||
) {
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
let style = cell.style.get_or_insert_with(Default::default);
|
||||
if field == "tbl_cell_content_color" {
|
||||
@@ -5181,20 +5156,16 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
table.block_record_handle = None;
|
||||
}
|
||||
self.invalidate_property_targets(i, &handles);
|
||||
});
|
||||
self.tabs[i].properties.open_color_field = None;
|
||||
self.tabs[i].dirty = true;
|
||||
self.refresh_properties();
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
if !handles.is_empty() {
|
||||
self.push_undo_snapshot(i, "CHPROP");
|
||||
let idx = if field == "gradient_color_2" { 1 } else { 0 };
|
||||
for &handle in &handles {
|
||||
self.apply_property_op(i, "CHPROP", &handles, |app, handle| {
|
||||
if let Some(acadrust::EntityType::Hatch(h)) =
|
||||
self.tabs[i].scene.document.get_entity_mut(handle)
|
||||
app.tabs[i].scene.document.get_entity_mut(handle)
|
||||
{
|
||||
while h.gradient_color.colors.len() <= idx {
|
||||
let value = if h.gradient_color.colors.is_empty() {
|
||||
@@ -5211,14 +5182,11 @@ impl OpenCADStudio {
|
||||
}
|
||||
h.gradient_color.colors[idx].color = color.clone();
|
||||
}
|
||||
}
|
||||
});
|
||||
// Rebuild hatch seeds so the gradient fill picks up the new
|
||||
// colour (synced_hatch_models only patches the main colour).
|
||||
self.tabs[i].scene.populate_hatches_from_document();
|
||||
self.invalidate_property_targets(i, &handles);
|
||||
self.tabs[i].properties.open_color_field = None;
|
||||
self.tabs[i].dirty = true;
|
||||
self.refresh_properties();
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
@@ -403,8 +403,8 @@ pub fn set_property(
|
||||
let size = current.abs().max(0.01);
|
||||
let value = match trimmed.to_ascii_lowercase().as_str() {
|
||||
"none" => 0.0,
|
||||
"center marks" => size,
|
||||
"centerlines" => -size,
|
||||
"mark" | "center marks" => size,
|
||||
"line" | "centerlines" => -size,
|
||||
_ => return false,
|
||||
};
|
||||
set(doc, handle, DIMCEN, Some(XDataValue::Real(value)));
|
||||
@@ -423,6 +423,7 @@ pub fn set_property(
|
||||
return true;
|
||||
}
|
||||
let handle_field = match field {
|
||||
"dim_radial_arrow" => Some(DIMLDRBLK),
|
||||
"dim_arrowhead_1" => Some(DIMBLK1),
|
||||
"dim_arrowhead_2" => Some(DIMBLK2),
|
||||
"dim_linetype" => Some(DIMLTYPE),
|
||||
@@ -433,7 +434,7 @@ pub fn set_property(
|
||||
};
|
||||
if let Some(code) = handle_field {
|
||||
let resolved = match field {
|
||||
"dim_arrowhead_1" | "dim_arrowhead_2" => {
|
||||
"dim_radial_arrow" | "dim_arrowhead_1" | "dim_arrowhead_2" => {
|
||||
if trimmed == "Closed filled" {
|
||||
Some(Handle::NULL)
|
||||
} else {
|
||||
@@ -638,6 +639,10 @@ pub fn set_property(
|
||||
"dim_line_lineweight" | "dim_ext_line_lineweight" => {
|
||||
parse_lineweight_label(trimmed)
|
||||
}
|
||||
"dim_precision"
|
||||
| "dim_alt_precision"
|
||||
| "dim_tolerance_precision"
|
||||
| "dim_alt_tolerance_precision" => parse_precision_label(trimmed),
|
||||
"dim_ext_line_fixed"
|
||||
| "dim_text_outside_align"
|
||||
| "dim_text_inside_align"
|
||||
@@ -729,8 +734,8 @@ pub fn set_property(
|
||||
_ => trimmed.parse().ok(),
|
||||
},
|
||||
"dim_tolerance_alignment" => match trimmed.to_ascii_lowercase().as_str() {
|
||||
"align decimal separators" => Some(0),
|
||||
"align operational symbols" => Some(1),
|
||||
"decimal separator" | "align decimal separators" => Some(0),
|
||||
"operational symbols" | "align operational symbols" => Some(1),
|
||||
_ => trimmed.parse().ok(),
|
||||
},
|
||||
_ => trimmed.parse().ok(),
|
||||
@@ -744,6 +749,16 @@ pub fn set_property(
|
||||
false
|
||||
}
|
||||
|
||||
fn parse_precision_label(value: &str) -> Option<i16> {
|
||||
if let Some(decimals) = value.strip_prefix("0.") {
|
||||
return (!decimals.is_empty()
|
||||
&& decimals.len() <= 8
|
||||
&& decimals.bytes().all(|digit| digit == b'0'))
|
||||
.then_some(decimals.len() as i16);
|
||||
}
|
||||
value.parse().ok()
|
||||
}
|
||||
|
||||
fn parse_lineweight_label(value: &str) -> Option<i16> {
|
||||
match value.trim() {
|
||||
"ByLayer" => Some(-1),
|
||||
|
||||
+982
-396
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -356,6 +356,38 @@ fn dim_seg(a: Vector3, b: Vector3, common: &acadrust::entities::EntityCommon) ->
|
||||
})
|
||||
}
|
||||
|
||||
fn dim_geom_entities(
|
||||
geometry: &crate::scene::convert::tessellate::DimGeom,
|
||||
ext_common: &acadrust::entities::EntityCommon,
|
||||
dim_common: &acadrust::entities::EntityCommon,
|
||||
) -> Vec<EntityType> {
|
||||
let mut entities = Vec::new();
|
||||
let point = |value: [f32; 3]| {
|
||||
Vector3::new(value[0] as f64, value[1] as f64, value[2] as f64)
|
||||
};
|
||||
for (points, common) in [
|
||||
(geometry.ext_lines.as_slice(), ext_common),
|
||||
(geometry.dim_lines.as_slice(), dim_common),
|
||||
] {
|
||||
for run in points.split(|point| point[0].is_nan()) {
|
||||
for pair in run.windows(2) {
|
||||
entities.push(dim_seg(point(pair[0]), point(pair[1]), common));
|
||||
}
|
||||
}
|
||||
}
|
||||
for triangle in geometry.arrow_fill.chunks_exact(3) {
|
||||
let mut solid = acadrust::entities::Solid::triangle(
|
||||
point(triangle[0]),
|
||||
point(triangle[1]),
|
||||
point(triangle[2]),
|
||||
);
|
||||
solid.common = dim_common.clone();
|
||||
solid.common.handle = Handle::NULL;
|
||||
entities.push(EntityType::Solid(solid));
|
||||
}
|
||||
entities
|
||||
}
|
||||
|
||||
/// A dimension-line terminator at `tip`, body extending back along the unit
|
||||
/// vector `(dx,dy)` (toward the dim line). When DIMTSZ>0 it's an oblique 45°
|
||||
/// tick; otherwise a closed *filled* arrowhead (DXF SOLID) of length DIMASZ —
|
||||
@@ -597,80 +629,23 @@ struct DimMetrics {
|
||||
dimclrt: i16,
|
||||
dimlwd: i16,
|
||||
dimlwe: i16,
|
||||
/// Resolved terminator shapes for the first / second end (DIMTSZ tick,
|
||||
/// DIMBLK/DIMBLK1/DIMBLK2 per DIMSAH, else closed-filled), so the bake
|
||||
/// reproduces the style's actual arrow type.
|
||||
dimltype: Handle,
|
||||
dimltex1: Handle,
|
||||
arrow1: crate::scene::convert::tessellate::ArrowKind,
|
||||
arrow2: crate::scene::convert::tessellate::ArrowKind,
|
||||
}
|
||||
|
||||
/// Metrics from the dim's style, mirroring what the live renderer applies, so a
|
||||
/// baked block reproduces the same gaps, arrow type, suppression, colours and
|
||||
/// lineweights: DIMASZ (arrow), DIMCEN (centre mark), DIMEXO/DIMEXE (extension
|
||||
/// gap/overshoot), DIMTSZ (oblique tick; >0 = ticks not arrows), DIMDLE (dim
|
||||
/// line overshoot past ticks), DIMSE1/2 + DIMSD1/2 (extension / dim-line
|
||||
/// suppression), DIMCLRD/E/T (colours) and DIMLWD/E (lineweights).
|
||||
/// Resolve style metrics used by saved dimension geometry.
|
||||
fn dim_metrics(dim: &Dimension, doc: &CadDocument) -> DimMetrics {
|
||||
let name = dim.base().style_name.as_str();
|
||||
let mut effective_style = doc.dim_styles.iter().find(|s| {
|
||||
s.name.eq_ignore_ascii_case(name)
|
||||
|| (name.trim().is_empty() && s.name.eq_ignore_ascii_case("Standard"))
|
||||
}).cloned();
|
||||
if let Some(style) = &mut effective_style {
|
||||
use crate::entities::dim_override as ov;
|
||||
let data = &dim.base().common.extended_data;
|
||||
macro_rules! real {
|
||||
($field:ident, $code:ident) => {
|
||||
if let Some(value) = ov::real(data, ov::$code) {
|
||||
style.$field = value;
|
||||
}
|
||||
};
|
||||
}
|
||||
macro_rules! int {
|
||||
($field:ident, $code:ident) => {
|
||||
if let Some(value) = ov::int(data, ov::$code) {
|
||||
style.$field = value;
|
||||
}
|
||||
};
|
||||
}
|
||||
macro_rules! flag {
|
||||
($field:ident, $code:ident) => {
|
||||
if let Some(value) = ov::int(data, ov::$code) {
|
||||
style.$field = value != 0;
|
||||
}
|
||||
};
|
||||
}
|
||||
macro_rules! handle {
|
||||
($field:ident, $code:ident) => {
|
||||
if let Some(value) = ov::handle(data, ov::$code) {
|
||||
style.$field = value;
|
||||
}
|
||||
};
|
||||
}
|
||||
real!(dimscale, DIMSCALE);
|
||||
real!(dimasz, DIMASZ);
|
||||
real!(dimcen, DIMCEN);
|
||||
real!(dimexo, DIMEXO);
|
||||
real!(dimexe, DIMEXE);
|
||||
real!(dimtsz, DIMTSZ);
|
||||
real!(dimdle, DIMDLE);
|
||||
real!(dimfxl, DIMFXL);
|
||||
flag!(dimfxlon, DIMFXLON);
|
||||
flag!(dimse1, DIMSE1);
|
||||
flag!(dimse2, DIMSE2);
|
||||
flag!(dimsd1, DIMSD1);
|
||||
flag!(dimsd2, DIMSD2);
|
||||
flag!(dimsoxd, DIMSOXD);
|
||||
flag!(dimsah, DIMSAH);
|
||||
int!(dimclrd, DIMCLRD);
|
||||
int!(dimclre, DIMCLRE);
|
||||
int!(dimclrt, DIMCLRT);
|
||||
int!(dimlwd, DIMLWD);
|
||||
int!(dimlwe, DIMLWE);
|
||||
handle!(dimblk, DIMBLK);
|
||||
handle!(dimblk1, DIMBLK1);
|
||||
handle!(dimblk2, DIMBLK2);
|
||||
}
|
||||
let effective_style = doc
|
||||
.dim_styles
|
||||
.iter()
|
||||
.find(|style| {
|
||||
style.name.eq_ignore_ascii_case(name)
|
||||
|| (name.trim().is_empty() && style.name.eq_ignore_ascii_case("Standard"))
|
||||
})
|
||||
.map(|style| crate::entities::dimension::resolved_dimension_style(style, dim, doc));
|
||||
let style = effective_style.as_ref();
|
||||
let scale = style
|
||||
.map(|s| if s.dimscale > 1e-6 { s.dimscale } else { 1.0 })
|
||||
@@ -679,14 +654,14 @@ fn dim_metrics(dim: &Dimension, doc: &CadDocument) -> DimMetrics {
|
||||
let dimasz = style.map(|s| s.dimasz * scale).unwrap_or(0.18 * scale).max(1e-6);
|
||||
let dimtsz = style.map(|s| s.dimtsz * scale).unwrap_or(0.0);
|
||||
let asz = dimasz as f32;
|
||||
// Resolve the terminator shapes exactly like the live render: ticks when
|
||||
// DIMTSZ>0, otherwise the DIMBLK / DIMBLK1+DIMBLK2 (per DIMSAH) arrow blocks,
|
||||
// else closed-filled.
|
||||
let (arrow1, arrow2) = if dimtsz > 1e-9 {
|
||||
let t = ArrowKind::Tick { size: dimtsz as f32 };
|
||||
(t.clone(), t)
|
||||
} else if let Some(s) = style {
|
||||
if matches!(dim, Dimension::Diameter(_)) {
|
||||
if matches!(dim, Dimension::Radius(_) | Dimension::LargeRadial(_)) {
|
||||
let arrow = arrow_from_block(doc, s.dimldrblk, asz);
|
||||
(arrow.clone(), arrow)
|
||||
} else if matches!(dim, Dimension::Diameter(_)) {
|
||||
use crate::entities::dim_override as ov;
|
||||
let data = &dim.base().common.extended_data;
|
||||
let first = ov::handle(data, ov::DIMBLK1)
|
||||
@@ -723,6 +698,8 @@ fn dim_metrics(dim: &Dimension, doc: &CadDocument) -> DimMetrics {
|
||||
dimclrt: style.map(|s| s.dimclrt).unwrap_or(0),
|
||||
dimlwd: style.map(|s| s.dimlwd).unwrap_or(-2),
|
||||
dimlwe: style.map(|s| s.dimlwe).unwrap_or(-2),
|
||||
dimltype: style.map(|s| s.dimltex_handle).unwrap_or(Handle::NULL),
|
||||
dimltex1: style.map(|s| s.dimltex1_handle).unwrap_or(Handle::NULL),
|
||||
arrow1,
|
||||
arrow2,
|
||||
}
|
||||
@@ -744,6 +721,21 @@ fn dim_common(base: &acadrust::entities::EntityCommon, clr: i16, lw: i16) -> aca
|
||||
c
|
||||
}
|
||||
|
||||
fn with_dim_linetype(
|
||||
mut common: acadrust::entities::EntityCommon,
|
||||
doc: &CadDocument,
|
||||
handle: Handle,
|
||||
) -> acadrust::entities::EntityCommon {
|
||||
common.linetype = doc
|
||||
.line_types
|
||||
.iter()
|
||||
.find(|line_type| line_type.handle == handle)
|
||||
.map(|line_type| line_type.name.clone())
|
||||
.unwrap_or_else(|| "Continuous".to_string());
|
||||
common.linetype_handle = (!handle.is_null()).then_some(handle);
|
||||
common
|
||||
}
|
||||
|
||||
/// Baked geometry for an angular dimension, matching the live render exactly:
|
||||
/// an extension line from each measured ray point flush out to the dimension
|
||||
/// arc, the swept arc, and a terminator at each arc end. (The live angular path
|
||||
@@ -806,49 +798,6 @@ fn angular_block_segs(
|
||||
out
|
||||
}
|
||||
|
||||
fn jogged_radial_break(
|
||||
chord: Vector3,
|
||||
jog: Vector3,
|
||||
override_center: Vector3,
|
||||
jog_angle: f64,
|
||||
) -> (Vector3, Vector3) {
|
||||
let radial = norm2(
|
||||
chord.x - override_center.x,
|
||||
chord.y - override_center.y,
|
||||
1.0,
|
||||
0.0,
|
||||
);
|
||||
let (sin, cos) = jog_angle.sin_cos();
|
||||
let transverse = (
|
||||
radial.0 * cos - radial.1 * sin,
|
||||
radial.0 * sin + radial.1 * cos,
|
||||
);
|
||||
let length = ((chord.x - override_center.x).powi(2)
|
||||
+ (chord.y - override_center.y).powi(2))
|
||||
.sqrt();
|
||||
let half = (length * 0.04).max(1e-6);
|
||||
let first = Vector3::new(
|
||||
jog.x - transverse.0 * half,
|
||||
jog.y - transverse.1 * half,
|
||||
jog.z,
|
||||
);
|
||||
let second = Vector3::new(
|
||||
jog.x + transverse.0 * half,
|
||||
jog.y + transverse.1 * half,
|
||||
jog.z,
|
||||
);
|
||||
let distance_squared = |point: Vector3| {
|
||||
(point.x - chord.x).powi(2)
|
||||
+ (point.y - chord.y).powi(2)
|
||||
+ (point.z - chord.z).powi(2)
|
||||
};
|
||||
if distance_squared(first) <= distance_squared(second) {
|
||||
(first, second)
|
||||
} else {
|
||||
(second, first)
|
||||
}
|
||||
}
|
||||
|
||||
/// The text anchor for a radial leader: the saved text middle point when set,
|
||||
/// else the midpoint of `a` and `b` — mirroring the live `dimension_text_position`.
|
||||
fn dim_text_anchor(
|
||||
@@ -1246,38 +1195,14 @@ fn explode_dimension(dim: &Dimension, doc: &CadDocument) -> Vec<EntityType> {
|
||||
));
|
||||
}
|
||||
}
|
||||
Dimension::LargeRadial(d) => {
|
||||
let (near, far) = jogged_radial_break(
|
||||
d.chord_point,
|
||||
d.jog_point,
|
||||
d.override_center,
|
||||
d.jog_angle,
|
||||
);
|
||||
if !met.dimsd1 {
|
||||
result.push(make_seg(&d.chord_point, &near, &dim_c));
|
||||
result.push(make_seg(&near, &far, &dim_c));
|
||||
result.push(make_seg(&far, &d.override_center, &dim_c));
|
||||
Dimension::LargeRadial(_) => {
|
||||
if let Some(geometry) =
|
||||
crate::entities::dimension::baked_large_radial_geometry(dim, doc)
|
||||
{
|
||||
let radial_ext_c = with_dim_linetype(ext_c.clone(), doc, met.dimltex1);
|
||||
let radial_dim_c = with_dim_linetype(dim_c.clone(), doc, met.dimltype);
|
||||
result.extend(dim_geom_entities(&geometry, &radial_ext_c, &radial_dim_c));
|
||||
}
|
||||
let len = ((near.x - d.chord_point.x).powi(2)
|
||||
+ (near.y - d.chord_point.y).powi(2))
|
||||
.sqrt()
|
||||
.max(1e-12);
|
||||
result.extend(dim_terminator(
|
||||
d.chord_point,
|
||||
(near.x - d.chord_point.x) / len,
|
||||
(near.y - d.chord_point.y) / len,
|
||||
&met.arrow1,
|
||||
&dim_c,
|
||||
));
|
||||
let radius = ((d.definition_point.x - d.chord_point.x).powi(2)
|
||||
+ (d.definition_point.y - d.chord_point.y).powi(2))
|
||||
.sqrt();
|
||||
result.extend(dim_center_mark(
|
||||
d.definition_point,
|
||||
met.dimcen,
|
||||
radius,
|
||||
&dim_c,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1371,23 +1296,7 @@ fn next_dimension_block_name(doc: &CadDocument, next: &mut u64) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bake an anonymous `*D<n>` geometry block for every DIMENSION that doesn't
|
||||
/// already own one, so the file is valid for AutoCAD-family readers.
|
||||
///
|
||||
/// OCS renders dimensions by re-tessellating them on the fly and never
|
||||
/// materialises the `*D` block that a DWG `DIMENSION` is supposed to reference
|
||||
/// (the lines / arrows / text that AutoCAD actually draws). A dimension created
|
||||
/// in OCS therefore goes out referencing a block that doesn't exist, and the
|
||||
/// writer emits a null block handle — strict readers (DWG TrueView, QCAD) drop
|
||||
/// the dimension or demand a recovery, and lenient ones (BricsCAD) regenerate it
|
||||
/// at a different position. Call this on the document about to be written so each
|
||||
/// such dimension gets a real block built from its exploded geometry (extension
|
||||
/// lines + dimension line + measurement text, the same decomposition EXPLODE
|
||||
/// uses) and its `block_name` points at it.
|
||||
///
|
||||
/// Dimensions that already reference an existing block (e.g. imported from a real
|
||||
/// DWG, or copied via the `*D`-cloning copy path) are left untouched so their
|
||||
/// original graphics are preserved.
|
||||
/// Build missing anonymous dimension geometry blocks before writing.
|
||||
pub fn bake_dimension_blocks(doc: &mut CadDocument) {
|
||||
// Keep group-10 in step and find missing blocks in one entity pass. Existing
|
||||
// `*D` blocks are the save cache: only invalidated/new dimensions enter the
|
||||
@@ -1451,35 +1360,13 @@ pub fn bake_dimension_blocks(doc: &mut CadDocument) {
|
||||
|
||||
if let Some(EntityType::Dimension(d)) = doc.get_entity_mut(handle) {
|
||||
d.base_mut().block_name = name;
|
||||
// The block we just baked holds the dimension graphics in absolute
|
||||
// WCS, so the DWG group-12 insertion point (base.insertion_point)
|
||||
// MUST be the origin. A reader that positions the *D block by that
|
||||
// point (BricsCAD / ODA) otherwise draws it shifted by the offset,
|
||||
// while OCS — which renders the block in place — shows it correctly.
|
||||
// OCS's dimension commands seed insertion_point with the text
|
||||
// anchor; reset it here so the saved dimension lands identically in
|
||||
// every application. (#181)
|
||||
// Baked geometry uses absolute coordinates.
|
||||
d.base_mut().insertion_point = Vector3::new(0.0, 0.0, 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop a dimension's baked `*D` block so the next save regenerates it from the
|
||||
/// dimension's current definition points / text / style.
|
||||
///
|
||||
/// OCS renders a dimension live from its definition points, but exports a baked
|
||||
/// `*D` block that other applications (BricsCAD / ODA) draw instead. An in-place
|
||||
/// edit — grip drag, DIMTEDIT, restyle, text edit, DIMSPACE — changes the
|
||||
/// definition points while leaving the old block, so without this the export
|
||||
/// keeps the pre-edit graphics and the dimension appears wrong everywhere but in
|
||||
/// OCS. Call this after any such edit; [`bake_dimension_blocks`] then rebuilds a
|
||||
/// fresh block on save.
|
||||
///
|
||||
/// The transform path (MOVE / COPY / PASTE) keeps its own block in sync via
|
||||
/// `define_transformed_block` and must NOT call this. The removed block's record
|
||||
/// and owned entities are deleted too, so re-baking on every edit can't
|
||||
/// accumulate orphan `*D` blocks. No-op when the dimension has no baked block
|
||||
/// yet (the pending path bakes it fresh on save). (#181)
|
||||
/// Drop baked dimension geometry so the next save regenerates it.
|
||||
pub fn invalidate_dim_block(doc: &mut CadDocument, handle: Handle) {
|
||||
let bn = match doc.get_entity(handle) {
|
||||
Some(EntityType::Dimension(d)) => d.base().block_name.clone(),
|
||||
|
||||
Vendored
-8
@@ -89,14 +89,6 @@ pub fn general_section(entity: &EntityType) -> PropSection {
|
||||
],
|
||||
};
|
||||
|
||||
if matches!(entity, EntityType::Point(_)) {
|
||||
let hyperlink = section.props.iter().position(|prop| prop.field == "hyperlink");
|
||||
let transparency = section.props.iter().position(|prop| prop.field == "transparency");
|
||||
if let (Some(hyperlink), Some(transparency)) = (hyperlink, transparency) {
|
||||
section.props.swap(hyperlink, transparency);
|
||||
}
|
||||
}
|
||||
|
||||
// Thickness (DXF 39) is a General-group property, but only the entity
|
||||
// types that carry an extrusion thickness expose it (line, circle, arc,
|
||||
// polyline, text, 2D solid, …). Show it right after Hyperlink for those.
|
||||
|
||||
@@ -1535,6 +1535,8 @@ pub fn tessellate(
|
||||
| EntityType::Circle(_)
|
||||
| EntityType::Arc(_)
|
||||
| EntityType::Ellipse(_)
|
||||
| EntityType::Spline(_)
|
||||
| EntityType::LwPolyline(_)
|
||||
) {
|
||||
(pattern_length, pattern)
|
||||
} else {
|
||||
|
||||
+2
-4
@@ -1420,10 +1420,8 @@ pub struct Scene {
|
||||
pub selection: Rc<RefCell<SelectionState>>,
|
||||
/// The CAD document — single source of truth for all entities.
|
||||
pub document: CadDocument,
|
||||
/// Last linear, aligned, angular, or ordinate dimension created during this
|
||||
/// application session. This is intentionally not reconstructed from file
|
||||
/// handles: continuation commands must not treat an old, merely high-handle
|
||||
/// dimension loaded from disk as the user's most recently created dimension.
|
||||
/// Last chain-compatible dimension created this session.
|
||||
/// Never reconstructed from loaded file handles.
|
||||
pub last_created_dimension: Option<Handle>,
|
||||
/// File-open-prepared index for non-graphical semantic object lookups.
|
||||
pub(crate) object_data_cache: crate::entities::object_data::ObjectDataCache,
|
||||
|
||||
@@ -250,7 +250,7 @@ impl Scene {
|
||||
let source_length = stationed.then(|| wire.pattern_stations[wire.points.len()]);
|
||||
let (clipped, mut clipped_stations) = clip_polyline_to_rect(
|
||||
&projected_pts,
|
||||
stationed.then_some(&wire.pattern_stations[..wire.points.len()]),
|
||||
stationed.then(|| &wire.pattern_stations[..wire.points.len()]),
|
||||
vp_x0,
|
||||
vp_y0,
|
||||
vp_x1,
|
||||
|
||||
@@ -3795,14 +3795,21 @@ impl Scene {
|
||||
full_bounds,
|
||||
self.document.header.lineweight_display || display_plot_lineweights,
|
||||
);
|
||||
if self.document.header.paper_space_linetype_scaling
|
||||
|
||||
// Model space: scale linetypes using the current annotation scale so their
|
||||
// appearance can match a paper-space viewport at the same drawing scale.
|
||||
if self.current_layout == "Model" {
|
||||
if self.annotation_scale.is_finite() && self.annotation_scale > 1e-9 {
|
||||
uniforms.linetype_scale = self.annotation_scale;
|
||||
}
|
||||
} else if self.document.header.paper_space_linetype_scaling
|
||||
&& !inst.paper_sheet
|
||||
&& inst.tile_idx.is_none()
|
||||
&& inst.handle != Handle::NULL
|
||||
{
|
||||
if let Some(EntityType::Viewport(vp)) = self.document.get_entity(inst.handle) {
|
||||
let viewport_scale =
|
||||
vp_effective_scale(vp.custom_scale, vp.view_height, vp.height);
|
||||
|
||||
if viewport_scale.is_finite() && viewport_scale > 1e-9 {
|
||||
uniforms.linetype_scale = (1.0 / viewport_scale) as f32;
|
||||
}
|
||||
|
||||
+182
-9
@@ -113,6 +113,57 @@ thread_local! {
|
||||
RefCell::new(FxHashMap::default());
|
||||
}
|
||||
|
||||
// Per-thread cache of `svg::Handle`s keyed by the bytes'
|
||||
// `(address, length)`. See the doc comment on [`themed_handle`]
|
||||
// below for the cache key rationale, the sub-slice guard, the
|
||||
// mirror of `SemanticCacheKey`, and the threading choice.
|
||||
thread_local! {
|
||||
static THEMED_CACHE: RefCell<FxHashMap<(usize, usize), svg::Handle>> =
|
||||
RefCell::new(FxHashMap::default());
|
||||
}
|
||||
|
||||
/// Look up (or build and cache) the `svg::Handle` for a `&'static [u8]`
|
||||
/// passed to a `themed*` function. This is the single source of
|
||||
/// truth for the per-`bytes` handle in the `themed` icon family —
|
||||
/// the 9 `themed*` functions all go through here, so the 22
|
||||
/// `themed*` call sites (and the 113 total `icons::themed`
|
||||
/// references) share one entry per artwork.
|
||||
///
|
||||
/// **Cache key: `(address, length)`.** Every `include_bytes!`
|
||||
/// produces a unique `&'static` slice, and the address is stable
|
||||
/// for the program's lifetime. Including the length guards against
|
||||
/// any future call site that passes a sub-slice of a static
|
||||
/// (e.g. `&BYTES[1..]`), which would otherwise alias the wrong
|
||||
/// handle. Mirrors the proven `SemanticCacheKey` belt-and-braces
|
||||
/// pattern above.
|
||||
///
|
||||
/// **`themed` vs `semantic` — why the simpler key is correct.**
|
||||
/// The `semantic` family bakes the palette into the SVG
|
||||
/// (recolours the artwork per theme role), so its cache key
|
||||
/// includes the palette. The `themed` family reads its colour
|
||||
/// from an Iced style closure at draw time, so the handle
|
||||
/// depends only on the bytes — a single entry per artwork is
|
||||
/// correct and shared across all themes.
|
||||
///
|
||||
/// **Threading.** The cache is `thread_local!` because `svg::Handle`
|
||||
/// is not `Sync`. The set of `&'static [u8]` art is fixed at
|
||||
/// compile time (~25-30 `include_bytes!` entries), so the cache
|
||||
/// is bounded by construction and needs no eviction. Per-frame
|
||||
/// cost: 9 ns/iter warm, 26 ns/iter cold (release profile, see
|
||||
/// `themed_cache_speedup_over_uncached_handle_creation`).
|
||||
fn themed_handle(bytes: &'static [u8]) -> svg::Handle {
|
||||
let key = (bytes.as_ptr() as usize, bytes.len());
|
||||
THEMED_CACHE.with(|cache| {
|
||||
let mut cache = cache.borrow_mut();
|
||||
if let Some(handle) = cache.get(&key) {
|
||||
return handle.clone();
|
||||
}
|
||||
let handle = svg::Handle::from_memory(bytes);
|
||||
cache.insert(key, handle.clone());
|
||||
handle
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct SemanticColors {
|
||||
background: [u8; 7],
|
||||
@@ -382,7 +433,7 @@ fn color_hex(color: Color) -> [u8; 7] {
|
||||
|
||||
/// Render a chrome icon with the active Iced theme's normal text color.
|
||||
pub fn themed<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> {
|
||||
svg(svg::Handle::from_memory(bytes))
|
||||
svg(themed_handle(bytes))
|
||||
.width(size)
|
||||
.height(size)
|
||||
.style(|theme: &Theme, _| svg::Style {
|
||||
@@ -393,7 +444,7 @@ pub fn themed<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> {
|
||||
|
||||
/// Render secondary chrome with the active Iced theme's text color.
|
||||
pub fn themed_secondary<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> {
|
||||
svg(svg::Handle::from_memory(bytes))
|
||||
svg(themed_handle(bytes))
|
||||
.width(size)
|
||||
.height(size)
|
||||
.style(|theme: &Theme, _| svg::Style {
|
||||
@@ -411,7 +462,7 @@ pub fn themed_secondary<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'
|
||||
|
||||
/// Render disabled chrome with the active Iced theme's text color.
|
||||
pub fn themed_disabled<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> {
|
||||
svg(svg::Handle::from_memory(bytes))
|
||||
svg(themed_handle(bytes))
|
||||
.width(size)
|
||||
.height(size)
|
||||
.style(|theme: &Theme, _| svg::Style {
|
||||
@@ -429,7 +480,7 @@ pub fn themed_disabled<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a
|
||||
|
||||
/// Render an emphasized chrome icon with the active Iced theme's primary color.
|
||||
pub fn themed_primary<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> {
|
||||
svg(svg::Handle::from_memory(bytes))
|
||||
svg(themed_handle(bytes))
|
||||
.width(size)
|
||||
.height(size)
|
||||
.style(|theme: &Theme, _| svg::Style {
|
||||
@@ -443,7 +494,7 @@ pub fn themed_primary_weak_text<'a, M: 'a>(
|
||||
bytes: &'static [u8],
|
||||
size: f32,
|
||||
) -> Element<'a, M> {
|
||||
svg(svg::Handle::from_memory(bytes))
|
||||
svg(themed_handle(bytes))
|
||||
.width(size)
|
||||
.height(size)
|
||||
.style(|theme: &Theme, _| svg::Style {
|
||||
@@ -454,7 +505,7 @@ pub fn themed_primary_weak_text<'a, M: 'a>(
|
||||
|
||||
/// Render a positive-state chrome icon with the active Iced theme's success color.
|
||||
pub fn themed_success<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> {
|
||||
svg(svg::Handle::from_memory(bytes))
|
||||
svg(themed_handle(bytes))
|
||||
.width(size)
|
||||
.height(size)
|
||||
.style(|theme: &Theme, _| svg::Style {
|
||||
@@ -465,7 +516,7 @@ pub fn themed_success<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a,
|
||||
|
||||
/// Render a warning-state chrome icon from the active Iced theme.
|
||||
pub fn themed_warning<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> {
|
||||
svg(svg::Handle::from_memory(bytes))
|
||||
svg(themed_handle(bytes))
|
||||
.width(size)
|
||||
.height(size)
|
||||
.style(|theme: &Theme, _| svg::Style {
|
||||
@@ -476,7 +527,7 @@ pub fn themed_warning<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a,
|
||||
|
||||
/// Render a destructive-state chrome icon from the active Iced theme.
|
||||
pub fn themed_danger<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> {
|
||||
svg(svg::Handle::from_memory(bytes))
|
||||
svg(themed_handle(bytes))
|
||||
.width(size)
|
||||
.height(size)
|
||||
.style(|theme: &Theme, _| svg::Style {
|
||||
@@ -487,7 +538,7 @@ pub fn themed_danger<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a,
|
||||
|
||||
/// Render an icon with the foreground chosen for a danger-coloured surface.
|
||||
pub fn themed_danger_text<'a, M: 'a>(bytes: &'static [u8], size: f32) -> Element<'a, M> {
|
||||
svg(svg::Handle::from_memory(bytes))
|
||||
svg(themed_handle(bytes))
|
||||
.width(size)
|
||||
.height(size)
|
||||
.style(|theme: &Theme, _| svg::Style {
|
||||
@@ -655,3 +706,125 @@ mod tests {
|
||||
.any(|window| window == needle)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod themed_cache_tests {
|
||||
use super::*;
|
||||
|
||||
/// Number of `svg::Handle` entries currently in the `THEMED_CACHE`.
|
||||
/// Test-only inspection helper; the cache is private production state.
|
||||
fn themed_cache_size_for_test() -> usize {
|
||||
THEMED_CACHE.with(|c| c.borrow().len())
|
||||
}
|
||||
|
||||
/// True when `(ptr, len)` (the address and length of a
|
||||
/// `&'static [u8]`) has an entry in the `THEMED_CACHE`.
|
||||
/// Test-only inspection helper.
|
||||
fn themed_cache_contains_for_test(ptr: *const u8, len: usize) -> bool {
|
||||
THEMED_CACHE.with(|c| c.borrow().contains_key(&(ptr as usize, len)))
|
||||
}
|
||||
|
||||
/// Two calls to the public `themed` function with the same
|
||||
/// `&'static [u8]` must share a single cache entry: the second
|
||||
/// call is a cache hit, not a fresh `Handle::from_memory`.
|
||||
#[test]
|
||||
fn themed_cache_returns_same_handle_for_same_bytes() {
|
||||
let _: Element<'static, ()> = themed(TRI_DOWN, 16.0);
|
||||
let _: Element<'static, ()> = themed(TRI_DOWN, 16.0);
|
||||
assert_eq!(
|
||||
themed_cache_size_for_test(),
|
||||
1,
|
||||
"two calls with the same bytes must share a single cache entry"
|
||||
);
|
||||
assert!(
|
||||
themed_cache_contains_for_test(TRI_DOWN.as_ptr(), TRI_DOWN.len()),
|
||||
"cache must hold an entry for the requested bytes"
|
||||
);
|
||||
}
|
||||
|
||||
/// Distinct `&'static [u8]` slices must produce distinct cache
|
||||
/// entries (no false aliasing across different artwork).
|
||||
#[test]
|
||||
fn themed_cache_caches_distinct_bytes_separately() {
|
||||
let _: Element<'static, ()> = themed(TRI_DOWN, 16.0);
|
||||
let _: Element<'static, ()> = themed(TRI_UP, 16.0);
|
||||
assert_eq!(
|
||||
themed_cache_size_for_test(),
|
||||
2,
|
||||
"distinct bytes must produce distinct cache entries"
|
||||
);
|
||||
assert!(themed_cache_contains_for_test(TRI_DOWN.as_ptr(), TRI_DOWN.len()));
|
||||
assert!(themed_cache_contains_for_test(TRI_UP.as_ptr(), TRI_UP.len()));
|
||||
}
|
||||
|
||||
/// Before/after benchmark: the cached `themed_handle` path
|
||||
/// (hashmap get + a `svg::Handle` clone) vs. the original
|
||||
/// un-cached path (`svg::Handle::from_memory` per call, which
|
||||
/// parses the SVG and allocates).
|
||||
///
|
||||
/// Integrated as a `#[test]` so it runs in the existing
|
||||
/// `cargo test --lib` flow with no extra infrastructure — no
|
||||
/// `criterion`, no `benches/` directory, no nightly
|
||||
/// `#![feature(test)]`. The "before" is faithfully simulated by
|
||||
/// calling `svg::Handle::from_memory` directly on the same
|
||||
/// `&'static [u8]`, which is exactly what the pre-cache code did
|
||||
/// per call. The "after" warms the cache first so the loop
|
||||
/// measures pure cache hits. A cold cache miss is also measured
|
||||
/// on a different static.
|
||||
///
|
||||
/// Run with `--nocapture` to see the numbers:
|
||||
/// `cargo test --lib themed_cache_speedup_over_uncached_handle_creation -- --nocapture`.
|
||||
#[test]
|
||||
fn themed_cache_speedup_over_uncached_handle_creation() {
|
||||
use std::time::Instant;
|
||||
|
||||
const ITER: u64 = 20_000;
|
||||
let bytes = TRI_DOWN; // &'static [u8]
|
||||
|
||||
// Warm the cache so the "after" loop measures pure cache hits.
|
||||
let _ = super::themed_handle(bytes);
|
||||
|
||||
// After: cached path — one hashmap get + a `Handle` clone per call.
|
||||
let t_after = Instant::now();
|
||||
for _ in 0..ITER {
|
||||
let _h = super::themed_handle(bytes);
|
||||
}
|
||||
let after = t_after.elapsed();
|
||||
|
||||
// Before: original (un-cached) path — SVG parse + allocation per call.
|
||||
let t_before = Instant::now();
|
||||
for _ in 0..ITER {
|
||||
let _h = svg::Handle::from_memory(bytes);
|
||||
}
|
||||
let before = t_before.elapsed();
|
||||
|
||||
// Cold first call (cache miss): one parse + one insert. Measured
|
||||
// on a different static so the entry is guaranteed to be fresh.
|
||||
let cold_bytes = TRI_UP;
|
||||
let t_cold = Instant::now();
|
||||
let _h = super::themed_handle(cold_bytes);
|
||||
let cold = t_cold.elapsed();
|
||||
|
||||
let after_ns = after.as_nanos() as u64;
|
||||
let before_ns = before.as_nanos() as u64;
|
||||
let cold_ns = cold.as_nanos() as u64;
|
||||
let per_after = after_ns / ITER;
|
||||
let per_before = before_ns / ITER;
|
||||
let speedup = if after_ns > 0 {
|
||||
before_ns as f64 / after_ns as f64
|
||||
} else {
|
||||
f64::INFINITY
|
||||
};
|
||||
|
||||
println!(
|
||||
"themed_handle: before={}ns/iter, after={}ns/iter, speedup={:.1}x, cold_miss={}ns (ITER={})",
|
||||
per_before, per_after, speedup, cold_ns, ITER,
|
||||
);
|
||||
|
||||
assert!(
|
||||
after_ns < before_ns,
|
||||
"cached path must be faster than from_memory: after={}ns before={}ns",
|
||||
after_ns, before_ns,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user