chore(dimension): sync with main

This commit is contained in:
Hakan Seven committed 2026-08-28 19:01:59 +03:00
commit d991289a56
18 files changed
+2556 -1051

No files matched your search

Generated
+1 -1
View File
@@ -4,7 +4,7 @@ version = 4
[[package]]
name = "OpenCADStudio"
version = "0.9.7"
version = "0.9.8"
dependencies = [
"acadrust",
"ashpd",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "OpenCADStudio"
version = "0.9.7"
version = "0.9.8"
edition = "2021"
build = "build.rs"
+11 -14
View File
@@ -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
View File
@@ -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>) {
+22 -22
View File
@@ -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
View File
@@ -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
View File
@@ -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", &lt);
}
}
// 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", &lt);
}
});
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
View File
@@ -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", &lt,
);
}
}
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()
}
+20 -5
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+74 -187
View File
@@ -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(),
-8
View File
@@ -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.
+2
View File
@@ -1535,6 +1535,8 @@ pub fn tessellate(
| EntityType::Circle(_)
| EntityType::Arc(_)
| EntityType::Ellipse(_)
| EntityType::Spline(_)
| EntityType::LwPolyline(_)
) {
(pattern_length, pattern)
} else {
+2 -4
View File
@@ -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,
+1 -1
View File
@@ -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,
+9 -2
View File
@@ -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
View File
@@ -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,
);
}
}