From 6aced3ac7e241e706b1a5ebbbaed05431832de3e Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:17:00 +0300 Subject: [PATCH 1/7] SWEEP: base point from the path; kernel sweep validity and placement fixes A single profile's base point comes from the path it is swept along: a path leaving the profile's plane from inside the profile sweeps it where it stands. Kernel 30b3810: pcurves in the edge's parameterisation (valid spline-faced sweeps), corner crossings fitted to the modeller's resolution, run faces through stations evenly spaced along the run. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 4 ++-- Cargo.toml | 4 ++++ src/scene/model/sweep_command_model.rs | 10 +++++++--- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 78908a489..0328c339d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4237,7 +4237,7 @@ dependencies = [ [[package]] name = "opencadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=86497ae#86497ae923b7d9297707aaedb712a8678815a238" +source = "git+https://github.com/ramox81/cadkernel.git?rev=30b3810#30b38101831c454421438acffaa648a53c9d8a2e" dependencies = [ "cavalier_contours", "opencadcodec", @@ -4248,7 +4248,7 @@ dependencies = [ [[package]] name = "opencadkernel-constraints" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=86497ae#86497ae923b7d9297707aaedb712a8678815a238" +source = "git+https://github.com/ramox81/cadkernel.git?rev=30b3810#30b38101831c454421438acffaa648a53c9d8a2e" dependencies = [ "nalgebra", ] diff --git a/Cargo.toml b/Cargo.toml index 0d21f6f45..c4124e354 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -181,3 +181,7 @@ harness = false [[test]] name = "integration" path = "tests/integration.rs" + +[patch."https://github.com/HakanSeven12/opencadkernel.git"] +opencadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "30b3810" } +opencadkernel-constraints = { git = "https://github.com/ramox81/cadkernel.git", rev = "30b3810" } diff --git a/src/scene/model/sweep_command_model.rs b/src/scene/model/sweep_command_model.rs index 7913e22a6..486156ec3 100644 --- a/src/scene/model/sweep_command_model.rs +++ b/src/scene/model/sweep_command_model.rs @@ -167,7 +167,11 @@ pub fn sweep_selection_options(profiles: &[EntityType], mut options: SweepOption } // Spatial profiles keep their own anchor. let profiles = profiles.iter().filter(|profile| spatial_profile(profile).is_none()).collect::>(); - if profiles.is_empty() { + // A single profile takes its base from the path it is swept along + // (`sweep_record`). + // ponytail: several profiles share the group anchor even when the path + // starts inside one of them; the reference rule for that case is unmeasured. + if profiles.len() < 2 { return Some(options); } let geometry = profiles.iter().map(|profile| { @@ -229,10 +233,10 @@ pub fn sweep_record(profile: &EntityType, path: &EntityType, options: SweepOptio return Some(record); } let (sweep_entity, sweep_entity_transform) = embedded_sweep_profile(profile)?; - let (plane, wires, _) = kernel::acis::sweep_profile_geometry(&sweep_entity, sweep_entity_transform).ok()?; + kernel::acis::sweep_profile_geometry(&sweep_entity, sweep_entity_transform).ok()?; let base_point = match options.base_point { Some(point) => point.to_array(), - None => kernel::brep::sweep_profile_base(plane, &wires)?, + None => kernel::acis::sweep_default_base(&sweep_entity, sweep_entity_transform, &embedded_sweep_path(path)?).ok()?, }; let mut base = SolidHistoryNodeBase::new(1); base.transform = glam::DMat4::IDENTITY.to_cols_array(); From 48c3c433a59c383a3698d9cbb820fe0c4e2b31e6 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:29:55 +0300 Subject: [PATCH 2/7] feat(sweep): associative swept surfaces in the drawing's network Swept surfaces made with SURFACEASSOCIATIVITY on keep their profile and path and are written as a swept-surface action of the drawing's associative network, reading the profile and path and their scale and twist expressions. They follow edits of their sources. Co-Authored-By: Claude Opus 5.5 --- src/app/command_driver/solid3d.rs | 16 +- src/app/commands/mod.rs | 1 + src/app/commands/styleprops.rs | 23 ++ src/app/settings.rs | 3 + src/app/update/file.rs | 2 + src/scene/dwg_native_constraints.rs | 301 ++++++++++++++++++++++++- src/scene/mod.rs | 5 + src/scene/model/sweep_command_model.rs | 226 +++++++++++++++++-- src/scene/model/sweep_model.rs | 2 +- src/scene/modify.rs | 70 ++++++ 10 files changed, 627 insertions(+), 22 deletions(-) diff --git a/src/app/command_driver/solid3d.rs b/src/app/command_driver/solid3d.rs index d9a78dc21..5042fb036 100644 --- a/src/app/command_driver/solid3d.rs +++ b/src/app/command_driver/solid3d.rs @@ -489,6 +489,8 @@ impl OpenCADStudio { .collect::>(); // Group 294: a base point the user picked. let picked_base = options.base_point.is_some(); + let several = profiles.len() > 1; + let picked = options; let options = sweep_model::sweep_selection_options(&selection, options); let pending = if profiles.is_empty() { None @@ -532,8 +534,10 @@ impl OpenCADStudio { continue; } }; + // An associative surface keeps the profile and path it reads. + let associative = surface && sweep_model::surface_associativity(); let deletes_path = - crate::app::delobj_deletes_auxiliary(delete_objects, surface); + !associative && crate::app::delobj_deletes_auxiliary(delete_objects, surface); if deletes_path && self.tabs[i].scene.is_layer_locked(path_handle) { failed += 1; continue; @@ -546,6 +550,14 @@ impl OpenCADStudio { created, &expressions, ); + if associative { + // A picked base point, or the one several profiles share. + let linked = crate::command::SweepOptions { + base_point: if picked_base || several { options.and_then(|o| o.base_point) } else { None }, + ..picked + }; + sweep_model::link_sweep_sources(&mut self.tabs[i].scene.document, created, handle, path_handle, &linked); + } created } else { self.add_solid_model( @@ -558,7 +570,7 @@ impl OpenCADStudio { failed += 1; } else { created_handles.push(created); - if delete_profiles { + if delete_profiles && !associative { consumed.push(handle); } if deletes_path { diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index d1b05da03..72c664de6 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -697,6 +697,7 @@ inventory::submit!(crate::command::CommandRegistration { "PDFOSNAP", "UOSNAP", "FIELDDISPLAY", + "SURFACEASSOCIATIVITY", "FIELDEVAL", "PDFIMPORTMODE", "PDFIMPORTFILTER", diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index e38b4adfa..8bd80ca92 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -1093,6 +1093,7 @@ impl OpenCADStudio { | "DGNOSNAP" | "UOSNAP" | "FIELDDISPLAY" + | "SURFACEASSOCIATIVITY" | "PDFIMPORTMODE" | "PDFIMPORTFILTER" | "PDFIMPORTLAYERS" @@ -1301,6 +1302,28 @@ impl OpenCADStudio { } return Some(self.finish_dispatch(cmd)); } + if name == "SURFACEASSOCIATIVITY" { + use crate::scene::model::sweep_model; + let current = i16::from(sweep_model::surface_associativity()); + match value.as_deref().map(|v| v.trim().parse::().ok().filter(|v| (0..=1).contains(v))) { + Some(Some(mode)) => { + if current != mode { + sweep_model::set_surface_associativity(mode == 1); + self.save_config(); + } + } + Some(None) => { + self.command_line.push_error(crate::t!("Requires 0 or 1 only.").as_ref()); + self.command_line.push_output(&format!("Enter new value for SURFACEASSOCIATIVITY <{current}>:")); + self.pending_setvar = Some(name.clone()); + } + None => { + self.command_line.push_output(&format!("Enter new value for SURFACEASSOCIATIVITY <{current}>:")); + self.pending_setvar = Some(name.clone()); + } + } + return Some(self.finish_dispatch(cmd)); + } if name == "FIELDDISPLAY" { let current = i16::from(crate::entities::field::display()); match value.as_deref().map(|v| v.trim().parse::().ok().filter(|v| (0..=1).contains(v))) { diff --git a/src/app/settings.rs b/src/app/settings.rs index 93fa75b38..491097c2b 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -412,6 +412,8 @@ pub struct UserSettings { pub attdef_on_screen: bool, /// FIELDDISPLAY: fields show on a gray background. pub field_display: bool, + /// SURFACEASSOCIATIVITY: new surfaces stay associative to their sources. + pub surface_associativity: bool, /// Nested-copy symbol handling: false inserts, true binds. pub ncopy_bind: bool, /// Last Options page; unknown saved names fall back without rejecting the config. @@ -823,6 +825,7 @@ impl Default for UserSettings { image_frame: 1, attdef_on_screen: true, field_display: true, + surface_associativity: true, ncopy_bind: false, cursor_type: CursorType::Crosshair, crosshair_color: None, diff --git a/src/app/update/file.rs b/src/app/update/file.rs index d5618c9dd..dc1be04bf 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -678,6 +678,7 @@ impl OpenCADStudio { image_frame: crate::scene::frame::profile_image_mode(), attdef_on_screen: crate::modules::draw::draw::attdef::session().on_screen, field_display: crate::entities::field::display(), + surface_associativity: crate::scene::model::sweep_model::surface_associativity(), ncopy_bind: self.ncopy_bind, cursor_type: self.cursor_type, crosshair_color: self.crosshair_color, @@ -787,6 +788,7 @@ impl OpenCADStudio { crate::scene::frame::set_profile_image_mode(s.image_frame); crate::modules::draw::draw::attdef::session().on_screen = s.attdef_on_screen; crate::entities::field::set_display(s.field_display); + crate::scene::model::sweep_model::set_surface_associativity(s.surface_associativity); self.ncopy_bind = s.ncopy_bind; self.cursor_type = s.cursor_type; self.crosshair_color = s.crosshair_color; diff --git a/src/scene/dwg_native_constraints.rs b/src/scene/dwg_native_constraints.rs index ce2327f55..f2779fa26 100644 --- a/src/scene/dwg_native_constraints.rs +++ b/src/scene/dwg_native_constraints.rs @@ -2482,6 +2482,9 @@ fn detach_preserved_scope( Some(ObjectType::Associative(object)) => match &object.data { AssociativeData::ConstraintGroup(_) | AssociativeData::Network(_) => false, AssociativeData::Variable(variable) => variable.name.trim().is_empty(), + // A surface action whose surface was erased goes with it. + AssociativeData::Action(_) => action_surface(document, *handle) + .is_none_or(|surface| document.get_entity(surface).is_some()), _ => true, }, _ => false, @@ -2489,6 +2492,35 @@ fn detach_preserved_scope( .collect(), _ => Vec::new(), }; + // Unnamed variables stay only while a kept action reads them. + let read_variables: Vec = foreign_actions + .iter() + .filter_map(|handle| match document.objects.get(handle) { + Some(ObjectType::Associative(AssociativeObject { + data: AssociativeData::Action(action), + .. + })) => Some(action), + _ => None, + }) + .flat_map(|action| action.values.iter().flat_map(|value| value.variables.iter().map(|variable| variable.handle))) + .filter_map(|handle| match document.objects.get(&handle) { + Some(ObjectType::Associative(AssociativeObject { + data: AssociativeData::ValueDependency(value), + .. + })) => Some(value.dependency.dependent_on), + _ => None, + }) + .collect(); + let foreign_actions: Vec = foreign_actions + .into_iter() + .filter(|handle| match document.objects.get(handle) { + Some(ObjectType::Associative(AssociativeObject { + data: AssociativeData::Variable(_), + .. + })) => read_variables.contains(handle), + _ => true, + }) + .collect(); let preserved_groups: Vec<_> = native_group_handles(document, owner) .into_iter() .filter(|handle| { @@ -2574,6 +2606,265 @@ fn detach_preserved_scope( (variables, preserved_groups, foreign_actions) } +/// The surface an associative surface action writes. +fn action_surface(document: &CadDocument, action: Handle) -> Option { + let Some(ObjectType::Associative(AssociativeObject { + data: AssociativeData::Action(action), + .. + })) = document.objects.get(&action) + else { + return None; + }; + let Some(ObjectType::Associative(AssociativeObject { + data: AssociativeData::SurfaceActionBody(body), + .. + })) = document.objects.get(&action.action_body) + else { + return None; + }; + match document.objects.get(&body.surface_body.dependency) { + Some(ObjectType::Associative(AssociativeObject { + data: AssociativeData::Dependency(dependency), + .. + })) => Some(dependency.dependent_on), + _ => None, + } +} + +/// Associative swept surfaces made here that the drawing's network does +/// not carry yet: (surface, profile, path). +fn pending_swept_surfaces(document: &CadDocument) -> Vec<(Handle, Handle, Handle)> { + use crate::scene::model::sweep_model; + let carried: Vec = document + .objects + .keys() + .filter_map(|handle| action_surface(document, *handle)) + .collect(); + let mut pending = document + .entities() + .filter(|entity| matches!(entity, codec::EntityType::Surface(surface) + if matches!(surface.surface_data, codec::entities::SurfaceData::Swept { .. }))) + .filter_map(|entity| { + let (profile, path) = sweep_model::sweep_sources(entity)?; + let surface = entity.common().handle; + (!carried.contains(&surface) + && document.get_entity(profile).is_some_and(|e| sweep_model::assoc_edge_curve(e).is_some()) + && document.get_entity(path).is_some_and(|e| sweep_model::assoc_edge_curve(e).is_some())) + .then_some((surface, profile, path)) + }) + .collect::>(); + pending.sort_by_key(|(surface, _, _)| surface.value()); + pending +} + +/// The names an expression reads. +fn expression_names(expression: &str) -> Vec { + let mut names = Vec::new(); + let mut current = String::new(); + for character in expression.chars().chain(std::iter::once(' ')) { + if character.is_alphanumeric() || character == '_' { + current.push(character); + } else if !current.is_empty() { + if current.chars().next().is_some_and(|c| c.is_alphabetic()) && !names.contains(¤t) { + names.push(current.clone()); + } + current.clear(); + } + } + names +} + +/// The reference's associative network for each pending swept surface: an +/// action with a swept-surface body that writes the surface and reads its +/// profile and path, with the rotation, scale and twist as value +/// parameters; a scale or twist expression is an unnamed variable reading +/// the named ones. Returns the network's new actions in order. +fn materialize_swept_surface_actions( + allocator: &mut Allocator, + network: Handle, + parameters: &ParameterTable, + pending: &[(Handle, Handle, Handle)], + first_index: i32, +) -> Vec { + use crate::scene::model::sweep_model; + use codec::objects::*; + let mut added = Vec::new(); + for (surface, profile, path) in pending.iter().copied() { + let document = &*allocator.document; + let Some(surface_entity) = document.get_entity(surface) else { continue }; + let codec::EntityType::Surface(surface_value) = surface_entity else { continue }; + let codec::entities::SurfaceData::Swept { options, .. } = &surface_value.surface_data else { continue }; + let (rotation, scale, twist) = (options.align_angle, options.scale_factor, options.twist_angle); + let expressions = sweep_model::sweep_expressions(surface_entity).unwrap_or([None, None]); + let (Some(profile_curve), Some(path_curve)) = ( + document.get_entity(profile).and_then(sweep_model::assoc_edge_curve), + document.get_entity(path).and_then(sweep_model::assoc_edge_curve), + ) else { continue }; + let evaluate = |expression: &str| { + let mut table = parameters.clone(); + table.set("sweepExpression", expression).ok()?; + table.resolve("sweepExpression").ok().filter(|value| value.is_finite()) + }; + let document = &mut *allocator.document; + let mut next = || document.allocate_handle(); + let (action, body, written, profile_param, profile_edge, profile_read, path_param, path_edge, path_read) = + (next(), next(), next(), next(), next(), next(), next(), next(), next()); + let dependency = |dependent_on: Handle, read: bool, id: i32, order: i32| AssocDependency { + class_version: 2, + status: 0, + is_read_dependency: read, + is_write_dependency: !read, + is_attached_to_object: true, + is_delegating_to_owning_action: true, + order, + dependent_on, + name: None, + read_dependency: Handle::NULL, + node: Handle::NULL, + dependency_body: Handle::NULL, + dependency_body_id: id, + }; + let mut objects: Vec<(Handle, Handle, &str, &str, AssociativeData)> = Vec::new(); + let mut reactors: Vec<(Handle, Handle)> = vec![(surface, written), (profile, profile_read), (path, path_read)]; + let mut dependencies = vec![written, profile_read, path_read]; + let mut value_handles = [Handle::NULL, Handle::NULL]; + let mut network_actions = vec![action]; + // Scale (slot 0) and twist (slot 1) expressions. + for (slot, expression) in expressions.iter().enumerate() { + let Some(expression) = expression else { continue }; + let Some(value) = evaluate(expression) else { continue }; + let (variable, read_by_action) = (document.allocate_handle(), document.allocate_handle()); + let mut variable_dependencies = Vec::new(); + for (index, name) in expression_names(expression).iter().enumerate() { + let Some(named) = allocator.variables.get(name).copied() else { continue }; + let reads = document.allocate_handle(); + let named_value = parameters.resolve(name).unwrap_or(0.0); + objects.push((reads, variable, "ACDBASSOCVALUEDEPENDENCY", "AcDbAssocValueDependency", + AssociativeData::ValueDependency(AssocValueDependency { + dependency: dependency(named, true, index as i32 + 1, 0), + class_version: 0, + name: String::new(), + value: Allocator::numeric_eval(named_value), + }))); + reactors.push((named, reads)); + variable_dependencies.push(AssocVariableDependency { dependency: reads, flags: 0 }); + } + objects.push((variable, network, "ACDBASSOCVARIABLE", "AcDbAssocVariable", AssociativeData::Variable(AssocVariable { + action: AssocAction { + class_version: 2, + owning_network: network, + max_dependency_index: variable_dependencies.len() as i32, + ..Default::default() + }, + class_version: 2, + name: String::new(), + expression: expression.clone(), + evaluator: "AcDbCalc:1.0".to_string(), + description: String::new(), + value: Allocator::numeric_eval(value), + has_cached_value: false, + cached_value: String::new(), + flag: false, + dependencies: variable_dependencies, + }))); + objects.push((read_by_action, action, "ACDBASSOCVALUEDEPENDENCY", "AcDbAssocValueDependency", + AssociativeData::ValueDependency(AssocValueDependency { + dependency: dependency(variable, true, dependencies.len() as i32 + 1, 0), + class_version: 0, + name: String::new(), + value: Allocator::numeric_eval(value), + }))); + reactors.push((variable, read_by_action)); + dependencies.push(read_by_action); + value_handles[slot] = read_by_action; + network_actions.push(variable); + } + let real = |value: f64| AssocEvalVariant { code: 40, value: AssocEvalValue::Real(value) }; + let value = |name: &str, unit_type: i32, amount: f64, handle: Handle| AssocValueParam { + class_version: 0, + name: name.to_string(), + unit_type, + variables: vec![AssocValueParamVariable { value: real(amount), handle }], + controlled_object_dependency: written, + }; + objects.push((action, network, "ACDBASSOCACTION", "AcDbAssocAction", AssociativeData::Action(AssocAction { + class_version: 2, + geometry_status: 0, + owning_network: network, + action_body: body, + action_index: 0, + max_dependency_index: dependencies.len() as i32, + dependencies: dependencies.iter().map(|handle| AssocActionDependency { is_owned: true, dependency: *handle }).collect(), + owned_parameters: vec![profile_param, path_param], + values: vec![ + value("RotationAngle", 2, rotation, Handle::NULL), + value("ScaleFactor", 0, scale, value_handles[0]), + value("TwistAngle", 2, twist, value_handles[1]), + ], + }))); + objects.push((body, action, "ACDBASSOCSWEPTSURFACEACTIONBODY", "AcDbAssocSweptSurfaceActionBody", + AssociativeData::SurfaceActionBody(AssocSurfaceActionBody { + kind: AssocSurfaceActionKind::Swept, + action_body: AssocActionBody { version: 2 }, + surface_body: AssocSurfaceBody { version: 0, dependency: written, is_semi_associative: false, marker: 1, is_semi_override: false, grip_status: 0 }, + ..Default::default() + }))); + objects.push((written, action, "ACDBASSOCDEPENDENCY", "AcDbAssocDependency", AssociativeData::Dependency(dependency(surface, false, 1, i32::MIN)))); + let edge_param = |read: Handle, (kind, subcurve, curve): (i32, AssocSubcurveKind, Vec)| AssociativeData::EdgeActionParam(AssocEdgeActionParam { + single_dependency: AssocSingleDependencyActionParam { + action_param: AssocActionParam { is_r2013: 1, version: 0, name: String::new() }, + dependency_class_version: 0, + dependency: read, + class_version: 0, + }, + parameter: Handle::NULL, + has_action: true, + action_type: kind, + subcurve_kind: subcurve, + curve, + }); + let path_parameter = |name: &str, child: Handle| AssociativeData::PathActionParam(AssocPathActionParam { + compound: AssocCompoundActionParam { + action_param: AssocActionParam { is_r2013: 1, version: 0, name: name.to_string() }, + class_version: 0, + status: 0, + parameters: vec![child], + child_parameter: None, + }, + version: 0, + }); + objects.push((profile_param, action, "ACDBASSOCPATHACTIONPARAM", "AcDbAssocPathActionParam", path_parameter("SweepProfile", profile_edge))); + objects.push((profile_edge, profile_param, "ACDBASSOCEDGEACTIONPARAM", "AcDbAssocEdgeActionParam", edge_param(profile_read, profile_curve))); + objects.push((profile_read, action, "ACDBASSOCDEPENDENCY", "AcDbAssocDependency", AssociativeData::Dependency(dependency(profile, true, 2, 0)))); + objects.push((path_param, action, "ACDBASSOCPATHACTIONPARAM", "AcDbAssocPathActionParam", path_parameter("SweepPath", path_edge))); + objects.push((path_edge, path_param, "ACDBASSOCEDGEACTIONPARAM", "AcDbAssocEdgeActionParam", edge_param(path_read, path_curve))); + objects.push((path_read, action, "ACDBASSOCDEPENDENCY", "AcDbAssocDependency", AssociativeData::Dependency(dependency(path, true, 3, 0)))); + for (handle, owner, dxf, class, data) in objects { + allocator.insert_associative_at(handle, owner, dxf, class, data); + } + for (target, reactor) in reactors { + let document = &mut *allocator.document; + if let Some(entity) = document.get_entity_mut(target) { + entity.common_mut().reactors.push(reactor); + } else if let Some(ObjectType::Associative(object)) = document.objects.get_mut(&target) { + object.reactors.push(reactor); + } + } + added.extend(network_actions); + } + for (offset, handle) in added.iter().copied().enumerate() { + if let Some(ObjectType::Associative(object)) = allocator.document.objects.get_mut(&handle) { + let action = match &mut object.data { + AssociativeData::Action(action) => action, + AssociativeData::Variable(variable) => &mut variable.action, + _ => continue, + }; + action.action_index = first_index + offset as i32 + 1; + } + } + added +} + /// Re-evaluates the unnamed variables of kept actions (a swept surface's /// scale or twist expression) and the values their actions cache, so the /// file carries the values the named parameters give. @@ -2714,6 +3005,7 @@ fn materialize_scope( && existing_variables.is_empty() && preserved_groups.is_empty() && foreign_actions.is_empty() + && !(materialize_all_parameters && !pending_swept_surfaces(document).is_empty()) { return None; } @@ -2898,6 +3190,9 @@ fn materialize_scope( } } refresh_foreign_values(allocator.document, &foreign_actions, parameters); + let pending = if materialize_all_parameters { pending_swept_surfaces(allocator.document) } else { Vec::new() }; + let surface_actions = materialize_swept_surface_actions( + &mut allocator, network_handle, parameters, &pending, first_foreign + foreign_actions.len() as i32); let network = AssocNetwork { action: AssocAction { @@ -2907,7 +3202,7 @@ fn materialize_scope( ..Default::default() }, network_version: 0, - network_action_index: first_foreign + foreign_actions.len() as i32, + network_action_index: first_foreign + (foreign_actions.len() + surface_actions.len()) as i32, actions: allocator .variable_actions .iter() @@ -2915,6 +3210,7 @@ fn materialize_scope( .chain(group_handle) .chain(preserved_groups) .chain(foreign_actions) + .chain(surface_actions) .map(|dependency| AssocActionDependency { is_owned: true, dependency, @@ -3055,7 +3351,8 @@ impl Scene { }; if set.is_none() && !existing - && (owner != model_owner || self.named_parameters.is_empty()) + && (owner != model_owner + || (self.named_parameters.is_empty() && pending_swept_surfaces(&self.document).is_empty())) { continue; } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index a2b7c264d..04208f985 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -3677,6 +3677,11 @@ impl Scene { changes.push(change); } } + for change in self.refresh_associative_sweeps(&changes) { + if !changes.iter().any(|(handle, _)| *handle == change.0) { + changes.push(change); + } + } if solve_parametric && !self.parametric_constraints.is_empty() { for change in self.refresh_parametric_constraints_with_initial_policy( &changes, diff --git a/src/scene/model/sweep_command_model.rs b/src/scene/model/sweep_command_model.rs index 7913e22a6..61be3864d 100644 --- a/src/scene/model/sweep_command_model.rs +++ b/src/scene/model/sweep_command_model.rs @@ -184,23 +184,7 @@ pub fn sweep_selection_options(profiles: &[EntityType], mut options: SweepOption /// The path traversed the other way when it was picked nearer its end /// (measured in plan, as the reference measures the pick), else `None`. fn reversed_toward_pick(path: &EntityType, pick: glam::DVec3) -> Option { - let (start, end) = match path { - EntityType::Polyline3D(value) if !value.is_closed() => { - let p = |v: &codec::entities::Vertex3DPolyline| glam::DVec3::new(v.position.x, v.position.y, v.position.z); - (p(value.vertices.first()?), p(value.vertices.last()?)) - } - EntityType::Spline(value) if !value.flags.closed => { - let points = if value.control_points.is_empty() { &value.fit_points } else { &value.control_points }; - let p = |v: &codec::types::Vector3| glam::DVec3::new(v.x, v.y, v.z); - (p(points.first()?), p(points.last()?)) - } - _ => { - let planar = crate::entities::curve::entity_curve(path)?; - if planar.curve.is_closed() { return None; } - let at = |t: f64| glam::DVec3::from_array(planar.plane.point_at(planar.curve.point_at(t))); - (at(0.0), at(1.0)) - } - }; + let (start, end) = path_ends(path)?; let flat = |p: glam::DVec3| p.truncate().distance(pick.truncate()); if flat(end) >= flat(start) { return None; } match path { @@ -220,6 +204,27 @@ fn reversed_toward_pick(path: &EntityType, pick: glam::DVec3) -> Option Option<(glam::DVec3, glam::DVec3)> { + Some(match path { + EntityType::Polyline3D(value) if !value.is_closed() => { + let p = |v: &codec::entities::Vertex3DPolyline| glam::DVec3::new(v.position.x, v.position.y, v.position.z); + (p(value.vertices.first()?), p(value.vertices.last()?)) + } + EntityType::Spline(value) if !value.flags.closed => { + let points = if value.control_points.is_empty() { &value.fit_points } else { &value.control_points }; + let p = |v: &codec::types::Vector3| glam::DVec3::new(v.x, v.y, v.z); + (p(points.first()?), p(points.last()?)) + } + _ => { + let planar = crate::entities::curve::entity_curve(path)?; + if planar.curve.is_closed() { return None; } + let at = |t: f64| glam::DVec3::from_array(planar.plane.point_at(planar.curve.point_at(t))); + (at(0.0), at(1.0)) + } + }) +} + pub fn sweep_record(profile: &EntityType, path: &EntityType, options: SweepOptions) -> Option { // Placed from the picked end; the record keeps the original path, and // the placed frame tells which end the sweep starts from. @@ -337,6 +342,193 @@ pub fn link_sweep_expressions( crate::scene::view::dispatch::set_entity_xdata(document, handle, SWEEP_EXPRESSION_APP, Some(values)); } +/// SURFACEASSOCIATIVITY: whether new surfaces stay associative to the +/// objects they were made from. A profile setting, so one value for every +/// drawing. +static SURFACE_ASSOCIATIVITY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true); + +pub fn surface_associativity() -> bool { + SURFACE_ASSOCIATIVITY.load(std::sync::atomic::Ordering::Relaxed) +} + +pub fn set_surface_associativity(on: bool) { + SURFACE_ASSOCIATIVITY.store(on, std::sync::atomic::Ordering::Relaxed); +} + +/// The application name under which an associative swept surface keeps +/// the profile and path it was swept from. +pub const SWEEP_SOURCES_APP: &str = "OCS_SWEEP_SOURCES"; + +/// Marks a swept surface associative to the profile and path it was swept +/// from, with the options it is swept again with when they change; the +/// drawing's associative network for it is written on save. +pub fn link_sweep_sources( + document: &mut codec::CadDocument, + surface: codec::Handle, + profile: codec::Handle, + path: codec::Handle, + options: &SweepOptions, +) { + use codec::xdata::XDataValue; + if surface.is_null() { return; } + let point = |p: glam::DVec3| XDataValue::Point3D(Vector3::new(p.x, p.y, p.z)); + // The path keeps the direction it was swept in when it is edited. + let reversed = options.path_pick + .zip(document.get_entity(path)) + .is_some_and(|(pick, path)| reversed_toward_pick(path, pick).is_some()); + let mut values = vec![ + XDataValue::Handle(profile), + XDataValue::Handle(path), + XDataValue::Integer16(i16::from(options.align) | (i16::from(options.bank) << 1) | (i16::from(reversed) << 2)), + ]; + if let Some(base) = options.base_point { + values.push(XDataValue::String("BASE".to_string())); + values.push(point(base)); + } + crate::scene::view::dispatch::set_entity_xdata(document, surface, SWEEP_SOURCES_APP, Some(values)); +} + +/// The profile and path an associative swept surface was swept from. +pub fn sweep_sources(entity: &EntityType) -> Option<(codec::Handle, codec::Handle)> { + let record = entity.common().extended_data.records().iter() + .find(|record| record.application_name == SWEEP_SOURCES_APP)?; + let mut handles = record.values.iter().filter_map(|value| match value { + codec::xdata::XDataValue::Handle(handle) => Some(*handle), + _ => None, + }); + Some((handles.next()?, handles.next()?)) +} + +/// The surface swept again from its current profile and path, with the +/// options it was made with: (surface record, body). +pub fn resweep_from_sources( + surface: &EntityType, + profile: &EntityType, + path: &EntityType, +) -> Option<(SolidHistorySweep, Body)> { + use codec::xdata::XDataValue; + let record = surface.common().extended_data.records().iter() + .find(|record| record.application_name == SWEEP_SOURCES_APP)?; + let EntityType::Surface(value) = surface else { return None }; + let SurfaceData::Swept { options: stored, .. } = &value.surface_data else { return None }; + let mut options = SweepOptions { + scale: stored.scale_factor, + twist_angle: stored.twist_angle, + ..SweepOptions::default() + }; + let mut values = record.values.iter(); + while let Some(value) = values.next() { + match value { + XDataValue::Integer16(flags) => { + options.align = flags & 1 != 0; + options.bank = flags & 2 != 0; + // Swept from the end: picked there. + if flags & 4 != 0 { + options.path_pick = path_ends(path).map(|(_, end)| end); + } + } + XDataValue::String(key) if key == "BASE" => { + if let Some(XDataValue::Point3D(p)) = values.next() { + options.base_point = Some(glam::DVec3::new(p.x, p.y, p.z)); + } + } + _ => {} + } + } + let record = if spatial_profile(profile).is_some() { + spatial_sweep_record(profile, path, options).ok()? + } else { + sweep_record(profile, path, options)? + }; + let body = kernel::acis::rebuild_sweep_with_mode(&record, true).ok()?; + Some((record, body)) +} + +/// An entity's curve as the reference stores it in an edge action +/// parameter: line segment (23: start, vector to the end), arc (11: +/// centre, normal, reference axis, radius, start and end angle, 0) or a +/// composite of those (47: count, then each part's type and values). +pub fn assoc_edge_curve(entity: &EntityType) -> Option<(i32, codec::objects::AssocSubcurveKind, Vec)> { + use codec::objects::{AssocCurveValue as V, AssocSubcurveKind as K}; + use codec::types::Vector3 as P; + let point = |p: glam::DVec3| V::Point(P::new(p.x, p.y, p.z)); + let segment = |a: glam::DVec3, b: glam::DVec3| vec![point(a), point(b - a)]; + // An arc about `normal` from `start` through `sweep` (signed, about the + // normal) on a circle of `radius`; measured from the reference axis. + let arc = |centre: glam::DVec3, normal: glam::DVec3, axis: glam::DVec3, radius: f64, start: f64, end: f64| vec![ + point(centre), point(normal), point(axis), V::Real(radius), V::Real(start), V::Real(end), V::Real(0.0), + ]; + match entity { + EntityType::Line(line) => { + let (a, b) = (glam::DVec3::new(line.start.x, line.start.y, line.start.z), glam::DVec3::new(line.end.x, line.end.y, line.end.z)); + Some((23, K::LineSegment3d, segment(a, b))) + } + EntityType::Circle(circle) => { + let normal = glam::DVec3::new(circle.normal.x, circle.normal.y, circle.normal.z).try_normalize()?; + let axis = arbitrary_x(normal); + Some((11, K::Arc, arc(glam::DVec3::new(circle.center.x, circle.center.y, circle.center.z), normal, axis, circle.radius, 0.0, std::f64::consts::TAU))) + } + EntityType::Arc(value) => { + let normal = glam::DVec3::new(value.normal.x, value.normal.y, value.normal.z).try_normalize()?; + let axis = arbitrary_x(normal); + let mut end = value.end_angle; + while end <= value.start_angle { end += std::f64::consts::TAU; } + Some((11, K::Arc, arc(glam::DVec3::new(value.center.x, value.center.y, value.center.z), normal, axis, value.radius, value.start_angle, end))) + } + EntityType::LwPolyline(polyline) => { + let normal = glam::DVec3::new(polyline.normal.x, polyline.normal.y, polyline.normal.z).try_normalize()?; + let axis_x = arbitrary_x(normal); + let axis_y = normal.cross(axis_x); + let at = |v: &codec::entities::LwVertex| axis_x * v.location.x + axis_y * v.location.y + normal * polyline.elevation; + let count = polyline.vertices.len(); + let spans = if polyline.is_closed { count } else { count.saturating_sub(1) }; + if spans == 0 { return None; } + let mut values = vec![V::Int(spans as i32)]; + for index in 0..spans { + let (from, to) = (&polyline.vertices[index], &polyline.vertices[(index + 1) % count]); + let (a, b) = (at(from), at(to)); + if from.bulge.abs() <= 1e-12 { + values.push(V::Int(23)); + values.extend(segment(a, b)); + } else { + // The arc in the polyline's plane; a clockwise one turns about + // the opposite normal, from the same reference axis. + let bulge = from.bulge; + let chord = b - a; + let centre = a + chord * 0.5 + normal.cross(chord) * ((1.0 - bulge * bulge) / (4.0 * bulge)); + let radius = chord.length() * (1.0 + bulge * bulge) / (4.0 * bulge.abs()); + let turn = normal * bulge.signum(); + let across = turn.cross(axis_x); + let angle = |p: glam::DVec3| (p - centre).dot(across).atan2((p - centre).dot(axis_x)); + let start = angle(a); + let end = start + 4.0 * bulge.atan().abs(); + values.push(V::Int(11)); + values.extend(arc(centre, turn, axis_x, radius, start, end)); + } + } + Some((47, K::None, values)) + } + EntityType::Polyline3D(polyline) => { + let points = polyline.vertices.iter().map(|v| glam::DVec3::new(v.position.x, v.position.y, v.position.z)).collect::>(); + let spans = if polyline.is_closed() { points.len() } else { points.len().saturating_sub(1) }; + if spans == 0 { return None; } + let mut values = vec![V::Int(spans as i32)]; + for index in 0..spans { + values.push(V::Int(23)); + values.extend(segment(points[index], points[(index + 1) % points.len()])); + } + Some((47, K::None, values)) + } + _ => None, + } +} + +/// The arbitrary-axis X direction of an entity normal. +fn arbitrary_x(normal: glam::DVec3) -> glam::DVec3 { + let world = if normal.x.abs() < 1.0 / 64.0 && normal.y.abs() < 1.0 / 64.0 { glam::DVec3::Y } else { glam::DVec3::Z }; + world.cross(normal).normalize() +} + /// The scale and twist expressions a swept surface is linked to. pub fn sweep_expressions(entity: &EntityType) -> Option<[Option; 2]> { let record = entity.common().extended_data.records().iter() diff --git a/src/scene/model/sweep_model.rs b/src/scene/model/sweep_model.rs index 3a3e3f15e..dcdc21641 100644 --- a/src/scene/model/sweep_model.rs +++ b/src/scene/model/sweep_model.rs @@ -13,7 +13,7 @@ use codec::EntityType; use crate::entities::curve::entity_curve; pub use super::sweep_command_model::{ - is_sweep_path, is_sweep_profile, scaled_sweep_path_refusal, spatial_profile, spatial_sweep_record, surface_sweep_record, sweep_expressions, link_sweep_expressions, sweep_record, sweep_refusal_code, sweep_selection_options, swept_surface_entity, swept_with_options, + is_sweep_path, is_sweep_profile, scaled_sweep_path_refusal, spatial_profile, spatial_sweep_record, surface_sweep_record, sweep_expressions, link_sweep_expressions, link_sweep_sources, sweep_sources, resweep_from_sources, surface_associativity, set_surface_associativity, assoc_edge_curve, sweep_record, sweep_refusal_code, sweep_selection_options, swept_surface_entity, swept_with_options, }; /// A drawn profile, as the kernel wants it: the plane it lies in and the diff --git a/src/scene/modify.rs b/src/scene/modify.rs index ac069dcaf..23d798a0b 100644 --- a/src/scene/modify.rs +++ b/src/scene/modify.rs @@ -1130,6 +1130,49 @@ impl Scene { rebuilt } + /// Associative swept surfaces follow edits of their profile or path: + /// swept again from them with the options they were made with. A + /// surface edited along with its sources keeps its own edit. Returns the + /// surfaces rebuilt. + pub(crate) fn refresh_associative_sweeps(&mut self, changes: &[(Handle, crate::scene::ChangeKind)]) -> Vec<(Handle, crate::scene::ChangeKind)> { + use crate::scene::model::sweep_model; + let changed = changes.iter() + .filter(|(_, kind)| !matches!(kind, crate::scene::ChangeKind::Removed)) + .map(|(handle, _)| *handle) + .collect::>(); + if changed.is_empty() { return Vec::new(); } + let linked = self.document.entities() + .filter_map(|entity| { + let handle = entity.common().handle; + let (profile, path) = sweep_model::sweep_sources(entity)?; + (!changed.contains(&handle) && (changed.contains(&profile) || changed.contains(&path))) + .then_some((handle, profile, path)) + }) + .collect::>(); + let mut rebuilt = Vec::new(); + for (handle, profile, path) in linked { + let (Some(surface), Some(profile), Some(path)) = ( + self.document.get_entity(handle), + self.document.get_entity(profile), + self.document.get_entity(path), + ) else { continue }; + let Some((record, body)) = sweep_model::resweep_from_sources(surface, profile, path) else { continue }; + let EntityType::Surface(fresh) = sweep_model::swept_surface_entity(&record) else { continue }; + let Some(document) = crate::scene::convert::acis_export::solid_to_sat(&body) else { continue }; + let Some(display) = self.prepare_solid_model_display(handle, &body) else { continue }; + let before = self.document.get_entity(handle).cloned().map(std::sync::Arc::new); + self.record_undo_before(handle, before); + if let Some(EntityType::Surface(surface)) = self.document.get_entity_mut(handle) { + surface.acis_data = codec::entities::AcisData::from_sat(&document.to_sat_string()); + surface.surface_data = fresh.surface_data; + surface.point_of_reference = fresh.point_of_reference; + } + self.register_prepared_solid_model(handle, body, display); + rebuilt.push((handle, crate::scene::ChangeKind::Modified)); + } + rebuilt + } + /// Swept surfaces the reference keeps associative to a scale or twist /// expression (an associative swept surface action whose value parameter /// depends on an expression variable) are linked the same way here. @@ -1140,6 +1183,7 @@ impl Scene { _ => None, }; let mut links = Vec::new(); + let mut sources = Vec::new(); for item in self.document.objects.values() { let ObjectType::Associative(action) = item else { continue }; let AssociativeData::Action(action) = &action.data else { continue }; @@ -1160,6 +1204,32 @@ impl Scene { if expressions.iter().any(Option::is_some) { links.push((written.dependent_on, expressions)); } + // The profile and path the action reads. + let source = |name: &str| { + action.owned_parameters.iter().find_map(|parameter| { + let Some(AssociativeData::PathActionParam(path)) = object(*parameter) else { return None }; + if path.compound.action_param.name != name { return None; } + let Some(AssociativeData::EdgeActionParam(edge)) = object(*path.compound.parameters.first()?) else { return None }; + match object(edge.single_dependency.dependency) { + Some(AssociativeData::Dependency(dependency)) => Some(dependency.dependent_on), + _ => None, + } + }) + }; + if let (Some(profile), Some(path)) = (source("SweepProfile"), source("SweepPath")) { + sources.push((written.dependent_on, profile, path)); + } + } + for (surface, profile, path) in sources { + let Some(entity @ EntityType::Surface(value)) = self.document.get_entity(surface) else { continue }; + if crate::scene::model::sweep_model::sweep_sources(entity).is_some() { continue; } + let codec::entities::SurfaceData::Swept { options, .. } = &value.surface_data else { continue }; + let linked = crate::command::SweepOptions { + align: options.sweep_alignment_flags == 1, + bank: options.bank, + ..crate::command::SweepOptions::default() + }; + crate::scene::model::sweep_model::link_sweep_sources(&mut self.document, surface, profile, path, &linked); } for (handle, expressions) in links { let Some(entity) = self.document.get_entity(handle) else { continue }; From ff5bfc8c4716de630729259931f9fd913f51ea11 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Thu, 8 Oct 2026 14:41:38 +0300 Subject: [PATCH 3/7] fix(purge): PURGE ALL no longer hangs on unnamed shape styles PURGE repeats until a pass removes nothing. A drawing whose complex linetypes use shape files carries unnamed STYLE records for them; the table holds those under keys the empty name does not reach, so every pass listed them again and removed nothing, and the loop never ended. - Unnamed and shape-file styles, and styles a complex linetype reaches by handle, are never listed for purging. - In-use checks compare names case-insensitively, as the tables do, so a layer, style or linetype referenced as WALL is not purged as Wall. - Counts are what the tables actually removed, and a pass that removes nothing ends the loop. Fixes #1662 Co-Authored-By: Claude Opus 5.5 --- src/app/commands/styleprops.rs | 64 ++++++++++++++++++++++++---------- 1 file changed, 45 insertions(+), 19 deletions(-) diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index e38b4adfa..3065e4c48 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -279,7 +279,7 @@ impl OpenCADStudio { if name.is_empty() { None } else { - Some(name.clone()) + Some(name.to_uppercase()) } }) .collect(); @@ -310,6 +310,17 @@ impl OpenCADStudio { .map(|ds| ds.dimtxsty.clone()), ) .filter(|s| !s.is_empty()) + .map(|s| s.to_uppercase()) + .collect(); + // Complex linetypes reach their text and shape styles by handle. + let linetype_styles: rustc_hash::FxHashSet = self.tabs[i] + .scene + .document + .line_types + .iter() + .flat_map(|lt| lt.elements.iter()) + .filter_map(|element| element.complex.as_ref()) + .map(|complex| complex.style_handle) .collect(); // Linetypes: entity references plus every layer's own // linetype — a ByLayer entity reaches it through the layer. @@ -334,6 +345,7 @@ impl OpenCADStudio { .map(|l| l.line_type.clone()), ) .filter(|s| !s.is_empty()) + .map(|s| s.to_uppercase()) .collect(); // Live blocks are reachable from layouts and style roots @@ -403,7 +415,9 @@ impl OpenCADStudio { .document .layers .iter() - .filter(|l| l.name != "0" && !used_layers.contains(&l.name)) + .filter(|l| { + l.name != "0" && !used_layers.contains(&l.name.to_uppercase()) + }) .map(|l| l.name.clone()) .collect() } else { @@ -415,8 +429,14 @@ impl OpenCADStudio { .document .text_styles .iter() + // Unnamed shape-file records serve complex linetypes + // and are never listed for purging. .filter(|s| { - s.name != "Standard" && !used_text_styles.contains(&s.name) + !s.name.is_empty() + && !s.is_shape_file + && !s.name.eq_ignore_ascii_case("Standard") + && !used_text_styles.contains(&s.name.to_uppercase()) + && !linetype_styles.contains(&s.handle) }) .map(|s| s.name.clone()) .collect() @@ -432,7 +452,7 @@ impl OpenCADStudio { .iter() .filter(|lt| { !standard.iter().any(|s| s.eq_ignore_ascii_case(<.name)) - && !used_linetypes.contains(<.name) + && !used_linetypes.contains(<.name.to_uppercase()) }) .map(|lt| lt.name.clone()) .collect() @@ -479,16 +499,17 @@ impl OpenCADStudio { snapshot_pushed = true; } - // Apply removals (mutable) - for name in &layer_remove { - self.tabs[i].scene.document.layers.remove(name); - } - for name in &style_remove { - self.tabs[i].scene.document.text_styles.remove(name); - } - for name in <_remove { - self.tabs[i].scene.document.line_types.remove(name); - } + // Apply removals (mutable). Count what actually went: an + // entry the table cannot remove by name would otherwise be + // listed again on every pass and never let the loop end. + let doc = &mut self.tabs[i].scene.document; + let layers_gone = + layer_remove.iter().filter(|n| doc.layers.remove(n).is_some()).count(); + let styles_gone = + style_remove.iter().filter(|n| doc.text_styles.remove(n).is_some()).count(); + let lts_gone = + lt_remove.iter().filter(|n| doc.line_types.remove(n).is_some()).count(); + let mut blocks_gone = 0usize; for name in &block_remove { // Drop the block definition's member entities (and the // BLOCK/ENDBLK delimiters) before the record so no orphaned @@ -506,12 +527,17 @@ impl OpenCADStudio { for h in handles { self.tabs[i].scene.document.remove_entity(h); } - self.tabs[i].scene.document.block_records.remove(name); + if self.tabs[i].scene.document.block_records.remove(name).is_some() { + blocks_gone += 1; + } + } + n_layers += layers_gone; + n_styles += styles_gone; + n_lts += lts_gone; + n_blocks += blocks_gone; + if layers_gone + styles_gone + lts_gone + blocks_gone == 0 { + break; } - n_layers += layer_remove.len(); - n_styles += style_remove.len(); - n_lts += lt_remove.len(); - n_blocks += block_remove.len(); } // Remove draw-order tables whose owning blocks are gone. From 340104bf1bfe497c6f98c14308177e9d7c748f84 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Thu, 8 Oct 2026 14:41:46 +0300 Subject: [PATCH 4/7] feat(options): interface scale for the whole window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a non-Retina screen macOS draws the interface at 1x, which reads small on a Full HD monitor, and there was no way to enlarge it. Options > Display now offers an interface scale of 75–200 % in 25 % steps, applied through the window's scale factor on top of the display's own and kept with the user settings. It is a list rather than a slider because the dialog rescales under the pointer as the value changes. Fixes #1655 Co-Authored-By: Claude Opus 5.5 --- locales/ar-SA/opencadstudio.ftl | 1 + locales/bg-BG/opencadstudio.ftl | 1 + locales/cs-CZ/opencadstudio.ftl | 1 + locales/de-DE/opencadstudio.ftl | 1 + locales/el-GR/opencadstudio.ftl | 1 + locales/en-US/opencadstudio.ftl | 1 + locales/es-ES/opencadstudio.ftl | 1 + locales/fi-FI/opencadstudio.ftl | 1 + locales/fr-FR/opencadstudio.ftl | 1 + locales/hi-IN/opencadstudio.ftl | 1 + locales/hu-HU/opencadstudio.ftl | 1 + locales/it-IT/opencadstudio.ftl | 1 + locales/ja-JP/opencadstudio.ftl | 1 + locales/ko-KR/opencadstudio.ftl | 1 + locales/lo-LA/opencadstudio.ftl | 1 + locales/nl-NL/opencadstudio.ftl | 1 + locales/pl-PL/opencadstudio.ftl | 1 + locales/pt-BR/opencadstudio.ftl | 1 + locales/ru-RU/opencadstudio.ftl | 1 + locales/tr-TR/opencadstudio.ftl | 1 + locales/zh-CN/opencadstudio.ftl | 1 + locales/zh-TW/opencadstudio.ftl | 1 + src/app/mod.rs | 6 ++++++ src/app/settings.rs | 8 ++++++++ src/app/update/file.rs | 2 ++ src/app/update/mod.rs | 6 ++++++ src/app/view/modal.rs | 1 + src/locale_catalog.rs | 1 + src/ui/window/options.rs | 19 +++++++++++++++++++ 29 files changed, 65 insertions(+) diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 9e7adf385..3fa33ea45 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -1451,6 +1451,7 @@ common = .zoom = التكبير .reverse-mouse-wheel-zoom-zoomwheel = عكس التكبير بعجلة الفأرة (ZOOMWHEEL) .touchpad-scroll-pans-ctrl-scroll-or-pinch-zooms = لوحة اللمس: التمرير يحرك العرض، Ctrl+تمرير أو القرص يكبّر + .interface-scale = مقياس الواجهة .zoom-factor = معامل التكبير .how-far-one-wheel-notch-zooms-zoomfactor = مقدار التكبير لكل درجة من العجلة (ZOOMFACTOR). .text-and-dimensions = النص والأبعاد diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index d4ca54bf4..d186cba66 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -1438,6 +1438,7 @@ common = .zoom = Мащабиране .reverse-mouse-wheel-zoom-zoomwheel = Обръщане на мащабирането с колелцето (ZOOMWHEEL) .touchpad-scroll-pans-ctrl-scroll-or-pinch-zooms = Тъчпад: превъртането мести изгледа, Ctrl+превъртане или щипване мащабира + .interface-scale = Мащаб на интерфейса .zoom-factor = Коефициент на мащабиране .how-far-one-wheel-notch-zooms-zoomfactor = Колко мащабира едно щракване на колелцето (ZOOMFACTOR). .text-and-dimensions = Текст и размери diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 1d3710279..07669d6b8 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -1438,6 +1438,7 @@ common = .zoom = Zoom .reverse-mouse-wheel-zoom-zoomwheel = Obrátit zoom kolečkem myši (ZOOMWHEEL) .touchpad-scroll-pans-ctrl-scroll-or-pinch-zooms = Touchpad: posun posouvá pohled, Ctrl+posun nebo sevření zvětšuje + .interface-scale = Měřítko rozhraní .zoom-factor = Faktor zoomu .how-far-one-wheel-notch-zooms-zoomfactor = O kolik přiblíží jeden zub kolečka (ZOOMFACTOR). .text-and-dimensions = Text a kóty diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 68af4c46f..ea5b01467 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -1439,6 +1439,7 @@ common = .zoom = Zoom .reverse-mouse-wheel-zoom-zoomwheel = Mausradzoom umkehren (ZOOMWHEEL) .touchpad-scroll-pans-ctrl-scroll-or-pinch-zooms = Touchpad: Scrollen verschiebt, Strg+Scrollen oder Zusammenziehen zoomt + .interface-scale = Oberflächenskalierung .zoom-factor = Zoomfaktor .how-far-one-wheel-notch-zooms-zoomfactor = Wie weit eine Radrastung zoomt (ZOOMFACTOR). .text-and-dimensions = Text und Bemaßung diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index c3e4f2c83..743e2bb7c 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -1440,6 +1440,7 @@ common = .zoom = Ζουμ .reverse-mouse-wheel-zoom-zoomwheel = Αντιστροφή ζουμ με τον τροχό (ZOOMWHEEL) .touchpad-scroll-pans-ctrl-scroll-or-pinch-zooms = Επιφάνεια αφής: η κύλιση μετακινεί, Ctrl+κύλιση ή τσίμπημα κάνει ζουμ + .interface-scale = Κλίμακα διεπαφής .zoom-factor = Συντελεστής ζουμ .how-far-one-wheel-notch-zooms-zoomfactor = Πόσο ζουμάρει ένα σκαλί του τροχού (ZOOMFACTOR). .text-and-dimensions = Κείμενο και διαστάσεις diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 39581bfde..7c22d658b 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -1440,6 +1440,7 @@ common = .zoom = Zoom .reverse-mouse-wheel-zoom-zoomwheel = Reverse mouse wheel zoom (ZOOMWHEEL) .touchpad-scroll-pans-ctrl-scroll-or-pinch-zooms = Touchpad: scroll pans, Ctrl+scroll or pinch zooms + .interface-scale = Interface scale .zoom-factor = Zoom factor .how-far-one-wheel-notch-zooms-zoomfactor = How far one wheel notch zooms (ZOOMFACTOR). .text-and-dimensions = Text and Dimensions diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 3e23e63e2..66d5e31d7 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -1439,6 +1439,7 @@ common = .zoom = Zoom .reverse-mouse-wheel-zoom-zoomwheel = Invertir el zoom de la rueda del ratón (ZOOMWHEEL) .touchpad-scroll-pans-ctrl-scroll-or-pinch-zooms = Panel táctil: el desplazamiento encuadra, Ctrl+desplazamiento o pellizco hace zoom + .interface-scale = Escala de la interfaz .zoom-factor = Factor de zoom .how-far-one-wheel-notch-zooms-zoomfactor = Cuánto amplía un paso de la rueda (ZOOMFACTOR). .text-and-dimensions = Texto y acotación diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index f5332f224..fb4ce630b 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -1438,6 +1438,7 @@ common = .zoom = Zoomaus .reverse-mouse-wheel-zoom-zoomwheel = Käännä hiiren rullan zoomaus (ZOOMWHEEL) .touchpad-scroll-pans-ctrl-scroll-or-pinch-zooms = Kosketuslevy: vieritys panoroi, Ctrl+vieritys tai nipistys zoomaa + .interface-scale = Käyttöliittymän skaalaus .zoom-factor = Zoomauskerroin .how-far-one-wheel-notch-zooms-zoomfactor = Kuinka paljon yksi rullan pykälä zoomaa (ZOOMFACTOR). .text-and-dimensions = Teksti ja mitoitus diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index 4b1c9c4f6..85bf5e706 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -1439,6 +1439,7 @@ common = .zoom = Zoom .reverse-mouse-wheel-zoom-zoomwheel = Inverser le zoom à la molette (ZOOMWHEEL) .touchpad-scroll-pans-ctrl-scroll-or-pinch-zooms = Pavé tactile : le défilement déplace, Ctrl+défilement ou pincement zoome + .interface-scale = Échelle de l'interface .zoom-factor = Facteur de zoom .how-far-one-wheel-notch-zooms-zoomfactor = De combien zoome un cran de molette (ZOOMFACTOR). .text-and-dimensions = Texte et cotation diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index 1374059b8..46e2c5f75 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -1427,6 +1427,7 @@ common = .zoom = ज़ूम .reverse-mouse-wheel-zoom-zoomwheel = माउस व्हील ज़ूम उलटें (ZOOMWHEEL) .touchpad-scroll-pans-ctrl-scroll-or-pinch-zooms = टचपैड: स्क्रॉल पैन करता है, Ctrl+स्क्रॉल या पिंच ज़ूम करता है + .interface-scale = इंटरफ़ेस स्केल .zoom-factor = ज़ूम गुणक .how-far-one-wheel-notch-zooms-zoomfactor = व्हील का एक पायदान कितना ज़ूम करता है (ZOOMFACTOR)। .text-and-dimensions = पाठ और आयाम diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index e61e6db3f..59b432544 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -1438,6 +1438,7 @@ common = .zoom = Nagyítás .reverse-mouse-wheel-zoom-zoomwheel = Egérgörgős nagyítás megfordítása (ZOOMWHEEL) .touchpad-scroll-pans-ctrl-scroll-or-pinch-zooms = Érintőpad: a görgetés mozgat, a Ctrl+görgetés vagy csippentés nagyít + .interface-scale = Felület mérete .zoom-factor = Nagyítási tényező .how-far-one-wheel-notch-zooms-zoomfactor = Mennyit nagyít egy görgőkattanás (ZOOMFACTOR). .text-and-dimensions = Szöveg és méretezés diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index db0ca1b45..d2bfc1ae1 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -1438,6 +1438,7 @@ common = .zoom = Zoom .reverse-mouse-wheel-zoom-zoomwheel = Inverti lo zoom con la rotellina (ZOOMWHEEL) .touchpad-scroll-pans-ctrl-scroll-or-pinch-zooms = Touchpad: lo scorrimento sposta la vista, Ctrl+scorrimento o pizzico esegue lo zoom + .interface-scale = Scala dell'interfaccia .zoom-factor = Fattore di zoom .how-far-one-wheel-notch-zooms-zoomfactor = Di quanto ingrandisce uno scatto della rotellina (ZOOMFACTOR). .text-and-dimensions = Testo e quote diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index b3da26db1..72c9b13e0 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -1429,6 +1429,7 @@ common = .zoom = ズーム .reverse-mouse-wheel-zoom-zoomwheel = マウス ホイールのズーム方向を反転 (ZOOMWHEEL) .touchpad-scroll-pans-ctrl-scroll-or-pinch-zooms = タッチパッド: スクロールで画面移動、Ctrl+スクロールまたはピンチでズーム + .interface-scale = インターフェースの拡大率 .zoom-factor = ズーム倍率 .how-far-one-wheel-notch-zooms-zoomfactor = ホイール 1 ノッチのズーム量 (ZOOMFACTOR)。 .text-and-dimensions = 文字と寸法 diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index 07b8733d4..403135aa8 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -1438,6 +1438,7 @@ common = .zoom = 줌 .reverse-mouse-wheel-zoom-zoomwheel = 마우스 휠 줌 방향 반전 (ZOOMWHEEL) .touchpad-scroll-pans-ctrl-scroll-or-pinch-zooms = 터치패드: 스크롤은 화면 이동, Ctrl+스크롤 또는 핀치는 확대/축소 + .interface-scale = 인터페이스 배율 .zoom-factor = 줌 배율 .how-far-one-wheel-notch-zooms-zoomfactor = 휠 한 칸이 확대하는 정도 (ZOOMFACTOR). .text-and-dimensions = 문자 및 치수 diff --git a/locales/lo-LA/opencadstudio.ftl b/locales/lo-LA/opencadstudio.ftl index 0c633a84e..8b75a109a 100644 --- a/locales/lo-LA/opencadstudio.ftl +++ b/locales/lo-LA/opencadstudio.ftl @@ -1440,6 +1440,7 @@ common = .zoom = ຊູມ .reverse-mouse-wheel-zoom-zoomwheel = ສະຫຼັບທິດທາງການໝຸນລໍ້ເມົາສ໌ຊູມ (ZOOMWHEEL) .touchpad-scroll-pans-ctrl-scroll-or-pinch-zooms = ທັດແພດ: ເລື່ອນເພື່ອຍ້າຍມຸມມອງ, Ctrl+ເລື່ອນ ຫຼື ບີບເພື່ອຊູມ + .interface-scale = ຂະໜາດສ່ວນຕິດຕໍ່ .zoom-factor = ຕົວຄູນການຊູມ .how-far-one-wheel-notch-zooms-zoomfactor = ໄລຍະຊູມຕໍ່ການໝຸນລໍ້ເມົາສ໌ໜຶ່ງຄລິກ (ZOOMFACTOR). .text-and-dimensions = ຂໍ້ຄວາມ ແລະ ຂະໜາດ diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index b80c45c52..112069a59 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -1437,6 +1437,7 @@ common = .zoom = Zoomen .reverse-mouse-wheel-zoom-zoomwheel = Muiswielzoom omkeren (ZOOMWHEEL) .touchpad-scroll-pans-ctrl-scroll-or-pinch-zooms = Touchpad: scrollen verschuift, Ctrl+scrollen of knijpen zoomt + .interface-scale = Interfaceschaal .zoom-factor = Zoomfactor .how-far-one-wheel-notch-zooms-zoomfactor = Hoeveel één wielklik zoomt (ZOOMFACTOR). .text-and-dimensions = Tekst en maatvoering diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index 4bda1bd8b..16cec2da3 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -1438,6 +1438,7 @@ common = .zoom = Powiększenie .reverse-mouse-wheel-zoom-zoomwheel = Odwróć powiększanie kółkiem myszy (ZOOMWHEEL) .touchpad-scroll-pans-ctrl-scroll-or-pinch-zooms = Touchpad: przewijanie przesuwa widok, Ctrl+przewijanie lub szczypanie powiększa + .interface-scale = Skala interfejsu .zoom-factor = Współczynnik powiększenia .how-far-one-wheel-notch-zooms-zoomfactor = O ile powiększa jedno kliknięcie kółka (ZOOMFACTOR). .text-and-dimensions = Tekst i wymiary diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index f16e194c4..e32d1d614 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -1439,6 +1439,7 @@ common = .zoom = Zoom .reverse-mouse-wheel-zoom-zoomwheel = Inverter o zoom da roda do mouse (ZOOMWHEEL) .touchpad-scroll-pans-ctrl-scroll-or-pinch-zooms = Touchpad: rolar move a vista, Ctrl+rolar ou pinçar aplica zoom + .interface-scale = Escala da interface .zoom-factor = Fator de zoom .how-far-one-wheel-notch-zooms-zoomfactor = Quanto um passo da roda amplia (ZOOMFACTOR). .text-and-dimensions = Texto e cotas diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index adb69ae0f..5734d26e5 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -1442,6 +1442,7 @@ common = .zoom = Зумирование .reverse-mouse-wheel-zoom-zoomwheel = Обратить зумирование колесом мыши (ZOOMWHEEL) .touchpad-scroll-pans-ctrl-scroll-or-pinch-zooms = Тачпад: прокрутка панорамирует, Ctrl+прокрутка или щипок масштабирует + .interface-scale = Масштаб интерфейса .zoom-factor = Коэффициент зумирования .how-far-one-wheel-notch-zooms-zoomfactor = Насколько приближает один щелчок колеса (ZOOMFACTOR). .text-and-dimensions = Текст и размеры diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index fb9d7010f..87e53c2af 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -1425,6 +1425,7 @@ common = .zoom = Yakınlaştırma .reverse-mouse-wheel-zoom-zoomwheel = Fare tekerleği yakınlaştırmasını ters çevir (ZOOMWHEEL) .touchpad-scroll-pans-ctrl-scroll-or-pinch-zooms = Dokunmatik yüzey: kaydırma kaydırır, Ctrl+kaydırma veya sıkıştırma yakınlaştırır + .interface-scale = Arayüz ölçeği .zoom-factor = Yakınlaştırma çarpanı .how-far-one-wheel-notch-zooms-zoomfactor = Bir tekerlek adımının ne kadar yakınlaştıracağı (ZOOMFACTOR). .text-and-dimensions = Metin ve ölçülendirme diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index da267f304..ac1e10a16 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -1426,6 +1426,7 @@ common = .zoom = 缩放 .reverse-mouse-wheel-zoom-zoomwheel = 反转鼠标滚轮缩放方向 (ZOOMWHEEL) .touchpad-scroll-pans-ctrl-scroll-or-pinch-zooms = 触控板:滚动平移,Ctrl+滚动或捏合缩放 + .interface-scale = 界面缩放 .zoom-factor = 缩放系数 .how-far-one-wheel-notch-zooms-zoomfactor = 滚轮每格的缩放幅度 (ZOOMFACTOR)。 .text-and-dimensions = 文字和标注 diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index 07312b40e..122cf89e5 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -1438,6 +1438,7 @@ common = .zoom = 縮放 .reverse-mouse-wheel-zoom-zoomwheel = 反轉滑鼠滾輪縮放方向 (ZOOMWHEEL) .touchpad-scroll-pans-ctrl-scroll-or-pinch-zooms = 觸控板:捲動平移,Ctrl+捲動或捏合縮放 + .interface-scale = 介面縮放 .zoom-factor = 縮放係數 .how-far-one-wheel-notch-zooms-zoomfactor = 滾輪每一格的縮放幅度 (ZOOMFACTOR)。 .text-and-dimensions = 文字與標註 diff --git a/src/app/mod.rs b/src/app/mod.rs index 2532acacb..50dc08597 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -561,6 +561,8 @@ pub(super) struct OpenCADStudio { zoom_wheel_reversed: bool, /// Scroll pans and Ctrl+scroll zooms (touchpads); see `scroll_intent`. touchpad_pan: bool, + /// Interface scale in percent, applied through the window scale factor. + ui_scale_percent: u16, /// Mouse-wheel zoom sensitivity (ZOOMFACTOR), held inside /// `settings::ZOOM_FACTOR_MIN..=settings::ZOOM_FACTOR_MAX`. `SETVAR` /// only sets the range the system variable itself has; the Options @@ -2378,6 +2380,8 @@ pub enum Message { /// Toggle reversing the mouse-wheel zoom direction (ZOOMWHEEL). ZoomWheelReversedChanged(bool), TouchpadPanChanged(bool), + /// Interface scale in percent (Options > Display). + UiScaleChanged(u16), /// Change how far one wheel notch zooms, from the Options slider /// (ZOOMFACTOR). ZoomFactorChanged(i32), @@ -4161,6 +4165,7 @@ impl OpenCADStudio { polar_increment_deg: 45.0, zoom_wheel_reversed: false, touchpad_pan: false, + ui_scale_percent: 100, zoom_factor: 60, zoom_factor_input: 60.to_string(), cursor_size: 5, @@ -4904,6 +4909,7 @@ pub fn run() -> iced::Result { ..iced::Settings::default() }) .subscription(OpenCADStudio::subscription) + .scale_factor(|state: &OpenCADStudio, _| f32::from(state.ui_scale_percent) / 100.0) .title(|state: &OpenCADStudio, window_id: window::Id| { let _ = window_id; // all dialogs are in-canvas modals now if let Some(tab) = state.tabs.get(state.active_tab) { diff --git a/src/app/settings.rs b/src/app/settings.rs index aa6b60548..2395c610d 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -183,6 +183,11 @@ pub const ZOOM_FACTOR_MAX: i32 = 500; /// Hold a ZOOMFACTOR inside the range the application can store, wherever it /// arrives from — the slider, the Options field, or a configuration file /// written by hand or by an older build. +/// Interface scale in percent, kept to the range the Options slider offers. +pub fn clamp_ui_scale(percent: u16) -> u16 { + percent.clamp(75, 200) +} + pub fn clamp_zoom_factor(v: i32) -> i32 { v.clamp(ZOOM_FACTOR_MIN, ZOOM_FACTOR_MAX) } @@ -391,6 +396,8 @@ pub struct UserSettings { /// Scrolling pans and Ctrl+scroll (a touchpad pinch) zooms, for a /// touchpad that reports two-finger drags as wheel scrolls. pub touchpad_pan: bool, + /// Interface scale in percent, on top of the display's own scale. + pub ui_scale_percent: u16, pub zoom_factor: i32, /// CURSORSIZE: normalized crosshair reach; 5 retains the original 60 px arms. pub cursor_size: i32, @@ -811,6 +818,7 @@ impl Default for UserSettings { polar_increment_deg: 45.0, zoom_wheel_reversed: false, touchpad_pan: false, + ui_scale_percent: 100, zoom_factor: 60, cursor_size: 5, pick_box: 3, diff --git a/src/app/update/file.rs b/src/app/update/file.rs index b9c7cc58c..cf5533296 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -663,6 +663,7 @@ impl OpenCADStudio { polar_increment_deg: self.polar_increment_deg, zoom_wheel_reversed: self.zoom_wheel_reversed, touchpad_pan: self.touchpad_pan, + ui_scale_percent: self.ui_scale_percent, zoom_factor: self.zoom_factor, cursor_size: self.cursor_size, pick_box: self.pick_box, @@ -764,6 +765,7 @@ impl OpenCADStudio { self.polar_increment_deg = s.polar_increment_deg; self.zoom_wheel_reversed = s.zoom_wheel_reversed; self.touchpad_pan = s.touchpad_pan; + self.ui_scale_percent = crate::app::settings::clamp_ui_scale(s.ui_scale_percent); self.zoom_factor = crate::app::settings::clamp_zoom_factor(s.zoom_factor); // Like the drafting-rotation field below, the Options field edits a // buffer rather than the value, so it is reseeded whenever the value diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 61a3dffaf..b4bac5d8b 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -8391,6 +8391,12 @@ impl OpenCADStudio { Task::none() } + Message::UiScaleChanged(percent) => { + self.ui_scale_percent = crate::app::settings::clamp_ui_scale(percent); + self.persist_settings_if_changed(); + Task::none() + } + Message::ZoomFactorChanged(factor) => { self.zoom_factor = crate::app::settings::clamp_zoom_factor(factor); // The field beside the slider reads the same value, so the diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 510b5edc2..83a6d3bfb 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -317,6 +317,7 @@ impl OpenCADStudio { commandline_fade_ms: self.commandline_fade_ms, zoom_wheel_reversed: self.zoom_wheel_reversed, touchpad_pan: self.touchpad_pan, + ui_scale_percent: self.ui_scale_percent, zoom_factor: self.zoom_factor, texteditmode: self.texteditmode, dimension_continue_mode: self.dimension_continue_mode, diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index b38ab91df..19b4ed554 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -4798,6 +4798,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Zoom" => Some(("common", "zoom")), "Reverse mouse wheel zoom (ZOOMWHEEL)" => Some(("common", "reverse-mouse-wheel-zoom-zoomwheel")), "Touchpad: scroll pans, Ctrl+scroll or pinch zooms" => Some(("common", "touchpad-scroll-pans-ctrl-scroll-or-pinch-zooms")), + "Interface scale" => Some(("common", "interface-scale")), "Zoom factor" => Some(("common", "zoom-factor")), "How far one wheel notch zooms (ZOOMFACTOR)." => Some(("common", "how-far-one-wheel-notch-zooms-zoomfactor")), "Text and Dimensions" => Some(("common", "text-and-dimensions")), diff --git a/src/ui/window/options.rs b/src/ui/window/options.rs index a8ebaa668..427cbc742 100644 --- a/src/ui/window/options.rs +++ b/src/ui/window/options.rs @@ -61,6 +61,8 @@ pub struct AppPrefs { pub zoom_wheel_reversed: bool, /// Scroll pans and Ctrl+scroll zooms (touchpads). pub touchpad_pan: bool, + /// Interface scale in percent. + pub ui_scale_percent: u16, /// ZOOMFACTOR. The slider sets the range the system variable has; the /// field beside it reaches `settings::ZOOM_FACTOR_MAX`. pub zoom_factor: i32, @@ -679,6 +681,23 @@ pub fn view_window<'a>( .width(sizing.width), Space::new().height(12), color_controls, + Space::new().height(16), + // A list, not a slider: the dialog rescales under the cursor as the + // value changes, which would drag a slider along with it. + row![ + text(crate::t!("Interface scale")).size(12).width(150), + iced::widget::pick_list( + Some(format!("{}%", prefs.ui_scale_percent)), + [75u16, 100, 125, 150, 175, 200].map(|p| format!("{p}%")).to_vec(), + |label: &String| label.clone(), + ) + .on_select(|label: String| { + Message::UiScaleChanged(label.trim_end_matches('%').parse().unwrap_or(100)) + }) + .width(sizing.width), + ] + .spacing(12) + .align_y(iced::Center), Space::new().height(24), row![ text(crate::t!("Model Space Appearance")).size(15), From 1e4c10bc6b301945f1e549e6e58317874539027e Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Thu, 8 Oct 2026 14:41:52 +0300 Subject: [PATCH 5/7] feat(cursor): a command asking for objects shows the pickbox alone Idle and object selection drew the same cursor, crosshair arms with the pickbox, so the cursor did not tell a pick from a free move. While a command gathers a selection or waits for an object pick the arms now go and the pickbox stays: idle keeps arms and box, point input arms and dot. Fixes #1661 Co-Authored-By: Claude Opus 5.5 --- src/app/view/mod.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index d12abbd5a..8f177d0dd 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -927,11 +927,14 @@ bg={bg_ms:.1}ms n={view_count}" snap_angle_deg: self.snap_angle_deg, pick_pending: self.pending_pick_label().is_some(), // An infinite-line preview runs along the arms; drop them - // so the preview stays visible (#1437). - hide_arms: tab - .active_cmd - .as_ref() - .is_some_and(|c| matches!(c.name(), "XLINE" | "RAY")), + // so the preview stays visible (#1437). A command asking + // for objects shows the pickbox alone, so picking reads + // apart from both idle and point input (#1661). + hide_arms: tab.active_cmd.as_ref().is_some_and(|c| { + matches!(c.name(), "XLINE" | "RAY") + || c.needs_entity_pick() + || c.is_selection_gathering() + }), snap_color: self.snap_marker_color, }, crate::ui::overlay::SelectionVisualOptions { From e82e97b0d977a736a366a4a7df0d6da358256504 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Thu, 8 Oct 2026 14:41:52 +0300 Subject: [PATCH 6/7] fix(properties): renamed, added and removed layers reach the layer list The Properties palette reads the layer names when it is built, and a layer rename only refreshed the layer panel and the ribbon, so the palette's layer list kept the old name beside the new one. The layer panel refresh now rebuilds the palette whenever the set of layer names changed: rename, new, delete and purge all pass through it. A layer toggle that leaves the names alone does not rebuild it, as the rebuild is costly for a large selection. Co-Authored-By: Claude Opus 5.5 --- src/app/layers.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/app/layers.rs b/src/app/layers.rs index 7ec119470..474792cde 100644 --- a/src/app/layers.rs +++ b/src/app/layers.rs @@ -62,10 +62,25 @@ impl OpenCADStudio { let i = self.active_tab; let doc_layers = self.tabs[i].scene.document.layers.clone(); let vp_info = self.tabs[i].scene.viewport_list(); + let names_before: Vec = + self.tabs[i].layers.layers.iter().map(|l| l.name.clone()).collect(); self.tabs[i] .layers .sync_with_viewports(&doc_layers, vp_info); self.sync_ribbon_layers(); + // The Properties palette lists layer names it read when it was built; + // a layer added, removed or renamed must reach that list too (#1664). + // Only then: the rebuild is costly for a large selection. + let names_changed = self.tabs[i].layers.layers.len() != names_before.len() + || self.tabs[i] + .layers + .layers + .iter() + .zip(&names_before) + .any(|(layer, before)| layer.name != *before); + if names_changed { + self.refresh_properties(); + } } /// Pick up layers an entity edit registered on the fly (`ensure_layer`). From ff6e2a6613cf452c309170d362074cebbd8ced04 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Thu, 8 Oct 2026 14:56:30 +0300 Subject: [PATCH 7/7] review(#1660, #1663): associative sweep scan, arc angles, viewport off bit - refresh_associative_sweeps runs on every edit and scanned the xdata of every entity; it now returns at once when no associative swept surface exists (its application is not registered) and looks at surfaces only. - An arc's end angle was raised by TAU in a loop, which never ends on a huge angle from a damaged file; the sweep is taken with rem_euclid. - The sweep DELOBJ test expected surface sweeps to delete their sources, which #1663 changed on purpose: an associative surface keeps them. - codec c9ffcac (opencadcodec #103) adds an explicit viewport-off bit: the viewport on state is read with is_on() and set with turn_on()/turn_off() everywhere, so a viewport the file marks off shows off and can be turned back on from Properties. Co-Authored-By: Claude Opus 5.5 --- src/app/automation.rs | 2 +- src/app/command_driver/tests.rs | 19 +++++++++++-------- src/app/commands/view.rs | 2 +- src/app/viewport_dimension_tests.rs | 4 ++-- src/entities/viewport.rs | 13 ++++++++----- src/io/mod.rs | 2 +- src/scene/mod.rs | 2 +- src/scene/model/sweep_command_model.rs | 6 ++++-- src/scene/model/sweep_model.rs | 2 +- src/scene/modify.rs | 7 ++++++- src/scene/mspace.rs | 6 +++--- src/scene/paper.rs | 2 +- src/scene/project.rs | 4 ++-- 13 files changed, 42 insertions(+), 29 deletions(-) diff --git a/src/app/automation.rs b/src/app/automation.rs index 9d230d30c..088493ab7 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -1801,7 +1801,7 @@ mod tests { viewport.id = 2; viewport.width = 100.0; viewport.height = 50.0; - viewport.status.is_on = true; + viewport.turn_on(); scene.add_entity(codec::EntityType::Viewport(viewport)); for entity in scene.document.entities_mut() { if let codec::EntityType::Viewport(viewport) = entity { diff --git a/src/app/command_driver/tests.rs b/src/app/command_driver/tests.rs index c5a250660..85e6e3c8b 100644 --- a/src/app/command_driver/tests.rs +++ b/src/app/command_driver/tests.rs @@ -935,14 +935,17 @@ mod delobj_tests { expected, ); } - assert_eq!( - sweep_source_presence(2, crate::command::ExtrudeMode::Surface), - (false, false), - ); - assert_eq!( - sweep_source_presence(3, crate::command::ExtrudeMode::Surface), - (false, true), - ); + // A swept surface is associative by default (SURFACEASSOCIATIVITY 1) + // and keeps the profile and path it follows, whatever DELOBJ says; + // DELOBJ reaches surfaces only with associativity off, a profile-wide + // switch this parallel test leaves alone. + assert!(crate::scene::model::sweep_model::surface_associativity()); + for value in [2, 3] { + assert_eq!( + sweep_source_presence(value, crate::command::ExtrudeMode::Surface), + (true, true), + ); + } } } diff --git a/src/app/commands/view.rs b/src/app/commands/view.rs index 3c6bc31dd..1436e1450 100644 --- a/src/app/commands/view.rs +++ b/src/app/commands/view.rs @@ -719,7 +719,7 @@ impl OpenCADStudio { vp.view_height, vp.height, ), - vp.status.is_on, + vp.is_on(), vp.status.locked, )) } else { diff --git a/src/app/viewport_dimension_tests.rs b/src/app/viewport_dimension_tests.rs index 04e09759f..2628f1355 100644 --- a/src/app/viewport_dimension_tests.rs +++ b/src/app/viewport_dimension_tests.rs @@ -27,7 +27,7 @@ fn fixture() -> (OpenCADStudio, Handle, ViewportFrame) { viewport.view_height = 1000.0; viewport.custom_scale = 0.1; viewport.view_direction = Vector3::UNIT_Z; - viewport.status.is_on = true; + viewport.turn_on(); let viewport = scene.add_entity(EntityType::Viewport(viewport)); let frame = scene.viewport_frame(viewport).unwrap(); scene.document.header.dimension_associativity = 2; @@ -484,7 +484,7 @@ fn viewport_dimension_eligibility_is_shared_and_uses_clipping() { first.viewport ); if let Some(EntityType::Viewport(vp)) = scene.document.get_entity_mut(first.viewport) { - vp.status.is_on = false; + vp.turn_off(); } assert!(scene.viewport_frames_at_paper_point(paper).is_empty()); assert!(scene.dimension_pick_through_viewport(paper, 0.2).is_none()); diff --git a/src/entities/viewport.rs b/src/entities/viewport.rs index 3e65cca64..37798bef2 100644 --- a/src/entities/viewport.rs +++ b/src/entities/viewport.rs @@ -113,7 +113,7 @@ fn properties(vp: &Viewport) -> Vec { field: "vp_on", value: PropValue::BoolToggle { field: "vp_on", - value: vp.status.is_on, + value: vp.is_on(), }, }, ro( @@ -193,11 +193,14 @@ fn apply_geom_prop(vp: &mut Viewport, field: &str, value: &str) { return; } "vp_on" => { - vp.status.is_on = if value == "toggle" { - !vp.status.is_on + // turn_on/turn_off keep the explicit-off bit in step, so a + // viewport the file marked off can be turned back on. + let on = if value == "toggle" { !vp.is_on() } else { value == "true" }; + if on { + vp.turn_on(); } else { - value == "true" - }; + vp.turn_off(); + } return; } "vp_perspective" => { diff --git a/src/io/mod.rs b/src/io/mod.rs index 0ad3565bc..7ba12b2c0 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -2539,7 +2539,7 @@ fn fix_viewport_status_flags(doc: &mut CadDocument) { // If bit 0 is not set but bit 15 is, this is an external-format viewport: // treat bit 15 as "on" and bit 14 as "locked". if (bits & 0x0001) == 0 && (bits & 0x8000) != 0 { - vp.status.is_on = true; + vp.turn_on(); vp.status.locked = (bits & 0x4000) != 0; } } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 2949edd90..bb92874c9 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -7504,7 +7504,7 @@ impl Scene { let Some(EntityType::Viewport(viewport)) = self.document.get_entity(handle) else { continue; }; - if viewport.common.owner_handle != paper_block || !viewport.status.is_on { + if viewport.common.owner_handle != paper_block || !viewport.is_on() { continue; } let mode = render_mode_override.unwrap_or(viewport.render_mode); diff --git a/src/scene/model/sweep_command_model.rs b/src/scene/model/sweep_command_model.rs index babc1824a..5d68a3994 100644 --- a/src/scene/model/sweep_command_model.rs +++ b/src/scene/model/sweep_command_model.rs @@ -475,8 +475,10 @@ pub fn assoc_edge_curve(entity: &EntityType) -> Option<(i32, codec::objects::Ass EntityType::Arc(value) => { let normal = glam::DVec3::new(value.normal.x, value.normal.y, value.normal.z).try_normalize()?; let axis = arbitrary_x(normal); - let mut end = value.end_angle; - while end <= value.start_angle { end += std::f64::consts::TAU; } + // The end past the start by the arc's sweep; an equal pair is a + // full turn. (A loop adding TAU never ends on a huge angle.) + let sweep = (value.end_angle - value.start_angle).rem_euclid(std::f64::consts::TAU); + let end = value.start_angle + if sweep > 0.0 { sweep } else { std::f64::consts::TAU }; Some((11, K::Arc, arc(glam::DVec3::new(value.center.x, value.center.y, value.center.z), normal, axis, value.radius, value.start_angle, end))) } EntityType::LwPolyline(polyline) => { diff --git a/src/scene/model/sweep_model.rs b/src/scene/model/sweep_model.rs index dcdc21641..fb5f12072 100644 --- a/src/scene/model/sweep_model.rs +++ b/src/scene/model/sweep_model.rs @@ -13,7 +13,7 @@ use codec::EntityType; use crate::entities::curve::entity_curve; pub use super::sweep_command_model::{ - is_sweep_path, is_sweep_profile, scaled_sweep_path_refusal, spatial_profile, spatial_sweep_record, surface_sweep_record, sweep_expressions, link_sweep_expressions, link_sweep_sources, sweep_sources, resweep_from_sources, surface_associativity, set_surface_associativity, assoc_edge_curve, sweep_record, sweep_refusal_code, sweep_selection_options, swept_surface_entity, swept_with_options, + is_sweep_path, is_sweep_profile, scaled_sweep_path_refusal, spatial_profile, spatial_sweep_record, surface_sweep_record, sweep_expressions, link_sweep_expressions, link_sweep_sources, sweep_sources, SWEEP_SOURCES_APP, resweep_from_sources, surface_associativity, set_surface_associativity, assoc_edge_curve, sweep_record, sweep_refusal_code, sweep_selection_options, swept_surface_entity, swept_with_options, }; /// A drawn profile, as the kernel wants it: the plane it lies in and the diff --git a/src/scene/modify.rs b/src/scene/modify.rs index 23d798a0b..dbde57ece 100644 --- a/src/scene/modify.rs +++ b/src/scene/modify.rs @@ -1140,8 +1140,13 @@ impl Scene { .filter(|(_, kind)| !matches!(kind, crate::scene::ChangeKind::Removed)) .map(|(handle, _)| *handle) .collect::>(); - if changed.is_empty() { return Vec::new(); } + // Every edit passes through here: a drawing with no associative + // swept surface (no application registered for them) skips the scan. + if changed.is_empty() || !self.document.app_ids.contains(sweep_model::SWEEP_SOURCES_APP) { + return Vec::new(); + } let linked = self.document.entities() + .filter(|entity| matches!(entity, EntityType::Surface(_))) .filter_map(|entity| { let handle = entity.common().handle; let (profile, path) = sweep_model::sweep_sources(entity)?; diff --git a/src/scene/mspace.rs b/src/scene/mspace.rs index 2d4a0b305..de7e10b5a 100644 --- a/src/scene/mspace.rs +++ b/src/scene/mspace.rs @@ -801,7 +801,7 @@ impl Scene { let Some(EntityType::Viewport(vp)) = self.document.get_entity(*handle) else { return None; }; - if !vp.status.is_on { + if !vp.is_on() { return None; } let rect = self.viewport_screen_rect(vp.common.handle, canvas)?; @@ -838,7 +838,7 @@ impl Scene { pub fn viewport_displays_content(&self, vp_handle: Handle) -> bool { matches!( self.document.get_entity(vp_handle), - Some(EntityType::Viewport(vp)) if vp.status.is_on + Some(EntityType::Viewport(vp)) if vp.is_on() ) } @@ -850,7 +850,7 @@ impl Scene { let Some(EntityType::Viewport(vp)) = self.document.get_entity(*handle) else { return None; }; - if vp.status.is_on { + if vp.is_on() { Some(vp.common.handle) } else { None diff --git a/src/scene/paper.rs b/src/scene/paper.rs index 11aa7b34d..669d6f74c 100644 --- a/src/scene/paper.rs +++ b/src/scene/paper.rs @@ -194,7 +194,7 @@ impl Scene { let Some(EntityType::Viewport(vp)) = self.document.get_entity(handle) else { continue; }; - if !vp.status.is_on + if !vp.is_on() || self.entity_temporarily_hidden(handle) { continue; diff --git a/src/scene/project.rs b/src/scene/project.rs index 765c6169a..0cf433927 100644 --- a/src/scene/project.rs +++ b/src/scene/project.rs @@ -21,7 +21,7 @@ impl Scene { }) .filter(|vp| { vp.common.owner_handle == paper_block - && vp.status.is_on + && vp.is_on() && only_vp.map_or(true, |h| vp.common.handle == h) && exclude_vp.map_or(true, |h| vp.common.handle != h) }) @@ -439,7 +439,7 @@ impl Scene { .iter() .filter_map(|handle| match self.document.get_entity(*handle) { Some(EntityType::Viewport(viewport)) - if viewport.common.owner_handle == paper_block && viewport.status.is_on => + if viewport.common.owner_handle == paper_block && viewport.is_on() => { Some(viewport) }