Merge remote-tracking branch 'origin/main' into plugin/host-model-api

This commit is contained in:
Felix Riestra committed 2026-09-21 22:20:09 +02:00
commit b1e6d2a349
42 files changed
+4134 -356

No files matched your search

-1
View File
@@ -1 +0,0 @@
use flake
+44
View File
@@ -0,0 +1,44 @@
name: Tests
# Until now nothing ran the test suite on a change: the release workflows
# build the app, web-check.yml type-checks the wasm target, and pull requests
# reported their own local runs. Run the workspace tests on every push to
# main and on every pull request. There is deliberately no formatter check:
# the tree is not rustfmt-clean and is not going to be reformatted.
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
concurrency:
group: tests-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
# The same libraries the Linux release build needs (windowing, GL,
# fonts); the packaging tools are left out.
- name: System libraries
run: |
sudo apt-get update
sudo apt-get install -y \
libgl1-mesa-dev libx11-dev libxcursor-dev libxi-dev libxrandr-dev \
libxkbcommon-dev libwayland-dev libfontconfig1-dev libfreetype6-dev
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
# LC_ALL=C: a few tests compare English message text.
- name: cargo test
env:
LC_ALL: C
run: cargo test --workspace --locked
-3
View File
@@ -36,9 +36,6 @@ __pycache__/
# Claude Code local preview/dev-server config
.claude/launch.json
# nix-direnv
.direnv/
# Worktrees created for isolated runs
.claude/worktrees/
Generated
-48
View File
@@ -1,48 +0,0 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1789149629,
"narHash": "sha256-H6GwaZzZf+4npqv0tph94w9tZddSjFjmQrVsW0z78uk=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "eaad089433ca2bb662274377d33df3d0e51ef28b",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1789196581,
"narHash": "sha256-yJr1Bt4fKkKIpPYbsKGqJ0VFdoDnURgRwuffmBQ2WzY=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "228ecefb6329d5a531b77b46b581a2f0c26ee056",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
-156
View File
@@ -1,156 +0,0 @@
{
description = "Open CAD Studio - 2D drafting and 3D modeling in Rust";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
# rust-overlay ships upstream Rust binaries rather than building them, so
# following nixpkgs costs it no binary cache.
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = { self, nixpkgs, rust-overlay }:
let
systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
forAllSystems = f:
nixpkgs.lib.genAttrs systems (system: f (import nixpkgs {
inherit system;
overlays = [ rust-overlay.overlays.default ];
}));
in
{
packages = forAllSystems (pkgs:
let
inherit (pkgs) lib stdenv;
toolchain = pkgs.rust-bin.stable.latest.default.override {
targets = [ "wasm32-unknown-unknown" ];
};
# Build with the overlay's toolchain rather than the nixpkgs default,
# so the package and the devShell compile with the same rustc.
rustPlatform = pkgs.makeRustPlatform {
cargo = toolchain;
rustc = toolchain;
};
# winit/wgpu dlopen these at run time, so they must also be on the
# wrapped binary's LD_LIBRARY_PATH, not just at link time.
runtimeLibs = with pkgs; [
libGL
libxkbcommon
vulkan-loader
wayland
xorg.libX11
xorg.libXcursor
xorg.libXi
xorg.libXrandr
];
in
rec {
default = opencadstudio;
opencadstudio = rustPlatform.buildRustPackage {
pname = "opencadstudio";
version = (lib.importTOML ./Cargo.toml).package.version;
src = self;
cargoLock = {
lockFile = ./Cargo.lock;
# One entry per distinct git source in Cargo.lock, keyed by any
# one package from it. Two of these are transitive and easy to
# miss by reading Cargo.toml alone: cryoglyph and dpi arrive
# through iced, which pins its own forks. Regenerate the list
# from the lock rather than the manifest:
# grep -B2 'source = "git+' Cargo.lock
# Each hash must be refreshed whenever the matching rev moves.
outputHashes = {
"acadrust-0.5.5" = "sha256-rYtJaJsb+lZZIDBtPTCfWRVyA1Gfinn3UqJlfzkhhGk=";
"cadkernel-0.1.0" = "sha256-7smcL9oo5mJwoCLYUbD4+lA9Xcm+/qpVswYxzoGjTDk=";
"cryoglyph-0.1.0" = "sha256-5BOJNDcjhpt17/XSri3JGtX8NwC7db18bddp5wanMes=";
"dpi-0.1.1" = "sha256-pQn1lCFSJMkjUfHoggEzMHnm5k+Chnzi5JEDjahnjUA=";
"iced-0.15.0-dev" = "sha256-Atg+nr1VgXwW5I7LiMAMqHemz/9ZiehTc2EWuSFEGHI=";
"iced_aw-0.14.1" = "sha256-OynSIjv8SgSoxpw1S4wH58vDdqga/YojOR5A9ZLKrd8=";
};
};
nativeBuildInputs = with pkgs; [
pkg-config
makeWrapper
];
buildInputs = with pkgs; [
fontconfig
freetype
] ++ runtimeLibs
++ lib.optionals stdenv.hostPlatform.isDarwin [ darwin.apple_sdk.frameworks.AppKit ];
# build.rs asks git for a revision and falls back to "unknown"
# when the repo or the binary is absent, which it is in the
# sandbox. Nothing to do but let it take the fallback.
doCheck = false;
postInstall = lib.optionalString stdenv.hostPlatform.isLinux ''
wrapProgram $out/bin/OpenCADStudio \
--prefix LD_LIBRARY_PATH : ${lib.makeLibraryPath runtimeLibs}
'';
meta = {
description = "Open-source 2D drafting and 3D modeling, built with Rust";
homepage = "https://www.opencadstudio.com";
license = lib.licenses.gpl3Only;
mainProgram = "OpenCADStudio";
platforms = systems;
};
};
});
devShells = forAllSystems (pkgs:
let
toolchain = pkgs.rust-bin.stable.latest.default.override {
extensions = [ "rust-src" "rust-analyzer" "clippy" "rustfmt" ];
targets = [ "wasm32-unknown-unknown" ];
};
runtimeLibs = with pkgs; [
libGL
libxkbcommon
vulkan-loader
wayland
xorg.libX11
xorg.libXcursor
xorg.libXi
xorg.libXrandr
];
in
{
default = pkgs.mkShell {
packages = with pkgs; [
toolchain
pkg-config
fontconfig
freetype
git
# Web build: `trunk build` per Trunk.toml. wasm-bindgen-cli must
# match the wasm-bindgen version Cargo.lock resolves, the same
# constraint .github/workflows/pages.yml:83 reads out of the lock.
trunk
wasm-bindgen-cli
# Rasterises assets/logo.svg for packaging, as release.yml does.
librsvg
] ++ runtimeLibs;
# A dev binary is run straight out of ./target, so it never gets
# the wrapper the package build applies.
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath runtimeLibs;
};
});
checks = forAllSystems (pkgs: {
default = self.packages.${pkgs.stdenv.hostPlatform.system}.opencadstudio;
});
formatter = forAllSystems (pkgs: pkgs.nixpkgs-fmt);
};
}
+870 -14
View File
@@ -3,6 +3,28 @@ use crate::command::{CmdResult, SelectionEntity, StepInput};
use acadrust::Handle;
use iced::Task;
/// `old` as a whole identifier in `source` becomes `new`.
fn replace_identifier(source: &str, old: &str, new: &str) -> String {
let mut out = String::with_capacity(source.len());
let mut token = String::new();
let flush = |token: &mut String, out: &mut String| {
if !token.is_empty() {
out.push_str(if token == old { new } else { token.as_str() });
token.clear();
}
};
for c in source.chars() {
if c.is_alphanumeric() || c == '_' {
token.push(c);
} else {
flush(&mut token, &mut out);
out.push(c);
}
}
flush(&mut token, &mut out);
out
}
impl OpenCADStudio {
pub(super) fn reject_locked_edit(&mut self, i: usize, handle: Handle) -> bool {
let Some(layer) = self.tabs[i].scene.locked_layer_name(handle) else {
@@ -1103,6 +1125,257 @@ impl OpenCADStudio {
TableCellEditStart::Started
}
/// Reports a refused command-line value and shows the prompt again.
fn reprompt_active_command(&mut self, i: usize, message: &str) {
self.command_line.push_error(message);
if let Some(prompt) = self.tabs[i].active_cmd.as_ref().map(|command| command.prompt()) {
self.command_line.push_info(&prompt);
}
}
/// Drops what removed dimensional constraints leave behind: their dynamic
/// dimensions, and their parameters when no other constraint reads them.
pub(crate) fn purge_dimensional_extras(
&mut self,
i: usize,
dimensions: Vec<Handle>,
parameters: Vec<String>,
) {
if !dimensions.is_empty() {
self.tabs[i].scene.erase_entities(&dimensions);
}
let unused: Vec<String> = parameters
.into_iter()
.filter(|name| {
!self.tabs[i]
.scene
.parametric_constraints
.iter()
.flat_map(|set| set.constraints.iter())
.any(|constraint| {
matches!(
&constraint.driving_param,
Some(crate::scene::named_parameters::DrivingValue::Named(used))
if used == name
)
})
})
.collect();
if !unused.is_empty() {
self.tabs[i].scene.record_undo_named_parameters_before();
for name in &unused {
self.tabs[i].scene.named_parameters_mut().remove(name);
}
}
}
/// `Dynamic` or `Annotational`, the DCFORM setting.
pub(crate) fn constraint_form_name(&self) -> &'static str {
if self.constraint_form_annotational {
"Annotational"
} else {
"Dynamic"
}
}
/// The value prompt a double-clicked constraint dimension opens.
pub(crate) fn dynamic_dimension_value_command(
&self,
i: usize,
handle: Handle,
) -> Option<crate::modules::parametric::DimensionValueCommand> {
use crate::scene::named_parameters::DrivingValue;
let scene = &self.tabs[i].scene;
for set in &scene.parametric_constraints {
let Some((id, _)) = set
.dimensions
.iter()
.find(|(_, dimension)| **dimension == handle)
else {
continue;
};
let constraint = set.get(*id)?;
let Some(DrivingValue::Named(name)) = &constraint.driving_param else {
return None;
};
let table = if set.local_parameters.is_empty() {
scene.named_parameters()
} else {
&set.local_parameters
};
let current = table
.get(name)
.map(|parameter| parameter.source.trim().to_string())
.unwrap_or_default();
return Some(crate::modules::parametric::DimensionValueCommand::new(
name.clone(),
current,
));
}
None
}
/// Applies `expression` or `newname=expression` to parameter `name`:
/// the dimension value prompt and -PARAMETERS Edit.
pub(crate) fn apply_parameter_input(&mut self, i: usize, name: &str, input: &str) {
let input = input.trim();
let (target, expression) = match input.split_once('=') {
Some((new_name, expression)) => {
(new_name.trim().to_string(), expression.trim().to_string())
}
None => (name.to_string(), input.to_string()),
};
if expression.is_empty() {
return;
}
if target != name {
if let Err(error) = self.rename_parameter(i, name, &target) {
self.command_line.push_error(&error);
return;
}
}
let mut table = self.tabs[i].scene.named_parameters().clone();
if let Err(error) = table.set(&target, &expression) {
self.command_line.push_error(&error.to_string());
return;
}
if !table
.resolve(&target)
.is_ok_and(|value| value.is_finite())
{
self.command_line.push_error("Invalid expression.");
return;
}
let pending = self.begin_undo(i, "Edit parameter", 0, true);
self.tabs[i].scene.record_undo_named_parameters_before();
self.tabs[i].scene.named_parameters = table;
self.resolve_named_parameter_edit(i, &target);
self.tabs[i].scene.refresh_dynamic_dimension_texts();
self.tabs[i].dirty = true;
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
}
/// -PARAMETERS New.
pub(crate) fn create_parameter(&mut self, i: usize, name: &str, expression: &str) {
if self.tabs[i].scene.named_parameters().contains(name) {
self.command_line
.push_error(&format!("Parameter {name} already exists."));
return;
}
let mut table = self.tabs[i].scene.named_parameters().clone();
if let Err(error) = table.set(name, expression) {
self.command_line.push_error(&error.to_string());
return;
}
let pending = self.begin_undo(i, "New parameter", 0, true);
self.tabs[i].scene.record_undo_named_parameters_before();
self.tabs[i].scene.named_parameters = table;
self.tabs[i].scene.sync_native_parametric_graph();
self.tabs[i].dirty = true;
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
}
/// Renames a parameter everywhere: the table, the expressions that use
/// it and the constraints it drives.
pub(crate) fn rename_parameter(&mut self, i: usize, old: &str, new: &str) -> Result<(), String> {
use crate::scene::named_parameters::{is_reserved_name, is_valid_name, DrivingValue};
let table = self.tabs[i].scene.named_parameters().clone();
let Some(source) = table.get(old).map(|parameter| parameter.source.clone()) else {
return Err(format!("Parameter {old} not found."));
};
if !is_valid_name(new) || is_reserved_name(new) || table.contains(new) {
return Err(format!("Invalid parameter name {new}."));
}
let mut renamed = table.clone();
renamed.set(new, &source).map_err(|error| error.to_string())?;
for parameter in table.iter() {
if parameter.name == old {
continue;
}
let rewritten = replace_identifier(&parameter.source, old, new);
if rewritten != parameter.source {
renamed
.set(&parameter.name, &rewritten)
.map_err(|error| error.to_string())?;
}
}
renamed.remove(old);
let pending = self.begin_undo(i, "Rename parameter", 0, true);
self.tabs[i].scene.record_undo_named_parameters_before();
self.tabs[i].scene.named_parameters = renamed;
let scopes: Vec<_> = self.tabs[i]
.scene
.parametric_constraints
.iter()
.map(|set| set.scope)
.collect();
for scope in scopes {
let before = self.tabs[i]
.scene
.parametric_constraint_set(scope)
.filter(|set| {
set.constraints.iter().any(|constraint| {
matches!(&constraint.driving_param, Some(DrivingValue::Named(name)) if name == old)
})
})
.cloned();
let Some(before) = before else {
continue;
};
self.tabs[i]
.scene
.record_undo_parametric_constraints_before(scope, before);
let set = self.tabs[i].scene.parametric_constraint_set_mut(scope);
for constraint in &mut set.constraints {
if matches!(&constraint.driving_param, Some(DrivingValue::Named(name)) if name == old) {
constraint.driving_param = Some(DrivingValue::Named(new.to_string()));
}
}
}
self.tabs[i].scene.refresh_dynamic_dimension_texts();
self.tabs[i].scene.sync_native_parametric_graph();
self.tabs[i].dirty = true;
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
Ok(())
}
/// -PARAMETERS Delete: a parameter no constraint drives with.
pub(crate) fn delete_parameter(&mut self, i: usize, name: &str) {
use crate::scene::named_parameters::DrivingValue;
if !self.tabs[i].scene.named_parameters().contains(name) {
self.command_line
.push_error(&format!("Parameter {name} not found."));
return;
}
let in_use = self.tabs[i]
.scene
.parametric_constraints
.iter()
.flat_map(|set| set.constraints.iter())
.any(|constraint| {
matches!(&constraint.driving_param, Some(DrivingValue::Named(used)) if used == name)
});
if in_use {
self.command_line
.push_error(&format!("Parameter {name} is used by a constraint."));
return;
}
let pending = self.begin_undo(i, "Delete parameter", 0, true);
self.tabs[i].scene.record_undo_named_parameters_before();
self.tabs[i].scene.named_parameters_mut().remove(name);
self.tabs[i].scene.sync_native_parametric_graph();
self.tabs[i].dirty = true;
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
}
fn remove_parametric_constraint(
&mut self,
id: crate::scene::parametric_constraints::ConstraintId,
@@ -1117,8 +1390,13 @@ impl OpenCADStudio {
return false;
};
let touched: Vec<Handle> = constraint.refs.iter().map(|r| r.entity).collect();
let dimension = set.dimensions.get(&id).copied();
let parameter = match &constraint.driving_param {
Some(crate::scene::named_parameters::DrivingValue::Named(name)) => Some(name.clone()),
_ => None,
};
let constraints_before = set.clone();
let pending = self.begin_undo(i, label, touched.len(), true);
let pending = self.begin_undo(i, label, touched.len(), dimension.is_none());
self.tabs[i]
.scene
.record_undo_parametric_constraints_before(scope, constraints_before);
@@ -1126,6 +1404,11 @@ impl OpenCADStudio {
.scene
.parametric_constraint_set_mut(scope)
.remove(id);
self.purge_dimensional_extras(
i,
dimension.into_iter().collect(),
parameter.into_iter().collect(),
);
if self.tabs[i].scene.selected_constraint == Some(id) {
self.tabs[i].scene.selected_constraint = None;
}
@@ -1219,6 +1502,23 @@ impl OpenCADStudio {
.flat_map(|c| c.refs.iter().map(|r| r.entity))
.collect();
// A changed value moves a dimensional constraint's second point; its
// first point stays, as in the reference.
let anchors: Vec<crate::scene::parametric_constraints::ParametricRef> = self.tabs[i]
.scene
.parametric_constraints
.iter()
.flat_map(|set| set.constraints.iter())
.filter(|c| {
c.enabled
&& matches!(
c.driving_param,
Some(crate::scene::named_parameters::DrivingValue::Named(_))
)
})
.filter_map(|c| c.refs.first().copied())
.filter(|reference| reference.marker.is_some())
.collect();
let pending = self.begin_undo(i, "Apply named parameters", touched.len(), true);
self.tabs[i].scene.record_undo_named_parameters_before();
self.tabs[i].scene.named_parameters = table;
@@ -1228,10 +1528,15 @@ impl OpenCADStudio {
.into_iter()
.map(|h| (h, crate::scene::ChangeKind::Modified))
.collect();
self.tabs[i].scene.bump_entities(&changes);
self.tabs[i].scene.bump_entities_with_parametric_policy(
&changes,
&anchors,
self.constraint_solve_mode,
);
} else {
self.tabs[i].scene.sync_native_parametric_graph();
}
self.tabs[i].scene.refresh_dynamic_dimension_texts();
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
@@ -1248,7 +1553,8 @@ impl OpenCADStudio {
/// table, so unlike that whole-table Apply this can cheaply scope the
/// re-solve to just the entities that actually reference it.
fn resolve_named_parameter_edit(&mut self, i: usize, name: &str) {
let touched: Vec<Handle> = self.tabs[i]
let readers: Vec<&crate::scene::parametric_constraints::ParametricConstraint> = self
.tabs[i]
.scene
.parametric_constraints
.iter()
@@ -1257,8 +1563,24 @@ impl OpenCADStudio {
c.enabled
&& matches!(&c.driving_param, Some(crate::scene::named_parameters::DrivingValue::Named(n)) if n == name)
})
.collect();
let touched: Vec<Handle> = readers
.iter()
.flat_map(|c| c.refs.iter().map(|r| r.entity))
.collect();
// A changed value moves a dimensional constraint's second point; its
// first point (and the line it measures perpendicular to) stays, as
// in the reference.
let anchors: Vec<crate::scene::parametric_constraints::ParametricRef> = readers
.iter()
.flat_map(|c| {
crate::scene::parametric_constraints::dimensional_anchor_refs(
&self.tabs[i].scene.document,
&c.refs,
)
})
.filter(|reference| reference.marker.is_some())
.collect();
self.tabs[i].dirty = true;
if touched.is_empty() {
self.tabs[i].scene.sync_native_parametric_graph();
@@ -1268,7 +1590,9 @@ impl OpenCADStudio {
.into_iter()
.map(|h| (h, crate::scene::ChangeKind::Modified))
.collect();
self.tabs[i].scene.bump_entities(&changes);
self.tabs[i]
.scene
.bump_entities_with_parametric_policy(&changes, &anchors, false);
}
/// Commits one field of one Parameters-section row (Properties panel) —
@@ -1337,6 +1661,11 @@ impl OpenCADStudio {
current.name.clone(),
),
ParamField::Name => {
let description = self.tabs[i]
.scene
.named_parameters()
.description(&current.name)
.to_string();
self.tabs[i]
.scene
.named_parameters_mut()
@@ -1345,12 +1674,19 @@ impl OpenCADStudio {
.scene
.named_parameters_mut()
.set(&typed, &current.source);
if outcome.is_err() {
let kept = if outcome.is_err() {
let _ = self.tabs[i]
.scene
.named_parameters_mut()
.set(&current.name, &current.source);
}
current.name.as_str()
} else {
typed.as_str()
};
self.tabs[i]
.scene
.named_parameters_mut()
.set_description(kept, &description);
(outcome, typed.clone())
}
};
@@ -1405,6 +1741,190 @@ impl OpenCADStudio {
Task::none()
}
/// The dynamic dimension the Properties panel targets: its handle, scope,
/// constraint id and parameter name.
fn dynamic_dimension_target(
&self,
i: usize,
) -> Option<(
Handle,
crate::scene::parametric_constraints::ParametricScope,
crate::scene::parametric_constraints::ConstraintId,
String,
)> {
use crate::scene::named_parameters::DrivingValue;
use crate::scene::parametric_constraints::dynamic_dimension_constraint;
let handle = *self.property_target_handles(i).first()?;
let (set, constraint) =
dynamic_dimension_constraint(&self.tabs[i].scene.parametric_constraints, handle)?;
let Some(DrivingValue::Named(name)) = &constraint.driving_param else {
return None;
};
Some((handle, set.scope, constraint.id, name.clone()))
}
/// Commits a dynamic dimension's Description row into its parameter.
pub(super) fn on_dynamic_dimension_description_commit(
&mut self,
field: &'static str,
) -> Task<Message> {
use crate::ui::properties::FieldKey;
let i = self.active_tab;
self.tabs[i].properties.active_field = None;
let Some(typed) = self.tabs[i]
.properties
.edit_buf
.remove(&FieldKey::Geom(field))
else {
return Task::none();
};
let Some((_, _, _, name)) = self.dynamic_dimension_target(i) else {
self.refresh_properties();
return Task::none();
};
let pending = self.begin_undo(i, "Constraint description", 0, true);
self.tabs[i].scene.record_undo_named_parameters_before();
self.tabs[i]
.scene
.named_parameters_mut()
.set_description(&name, &typed);
self.tabs[i].dirty = true;
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
self.refresh_properties();
Task::none()
}
/// Applies a dynamic dimension's Constraint Form or Reference choice.
pub(super) fn on_dynamic_dimension_choice(
&mut self,
field: &'static str,
value: &str,
) -> Task<Message> {
use crate::scene::parametric_constraints::DYNAMIC_DIMENSION_LAYER;
let i = self.active_tab;
let Some((handle, scope, id, _)) = self.dynamic_dimension_target(i) else {
return Task::none();
};
match field {
"dyn_constraint_form" => {
// Annotational: an ordinary dimension on the current layer
// that plots; Dynamic: the gray one on the constraints layer.
let annotational = value.eq_ignore_ascii_case("Annotational");
let active_layer = self.tabs[i].active_layer.clone();
if !annotational {
self.tabs[i].scene.ensure_dynamic_dimension_layer();
}
self.apply_property_op(i, "Constraint form", &[handle], |app, handle| {
use crate::entities::dim_override as dov;
let Some(mut entity) = app.tabs[i].scene.document.get_entity(handle).cloned()
else {
return;
};
if annotational {
entity.as_entity_mut().set_layer(active_layer.clone());
entity.common_mut().color = acadrust::types::Color::ByLayer;
// The dynamic form's screen-size and horizontal-text
// overrides go; the style draws it.
for code in [dov::DIMSCALE, dov::DIMTIH, dov::DIMTOH] {
dov::set_on_entity(&mut entity, code, None);
}
} else {
entity
.as_entity_mut()
.set_layer(DYNAMIC_DIMENSION_LAYER.to_string());
entity.common_mut().color = acadrust::types::Color::Rgb {
r: 103,
g: 109,
b: 118,
};
}
app.tabs[i].scene.update_entity(entity);
});
// The dynamic form is rescaled to the screen and shows the
// trimmed value; the annotational one is never hidden.
self.tabs[i].scene.refresh_dynamic_dimension_scales(true);
self.tabs[i].scene.refresh_dynamic_dimension_texts();
self.tabs[i].scene.refresh_hidden_dynamic_dimensions();
}
"dyn_constraint_reference" => {
// A reference constraint reads the geometry instead of
// driving it; its text sits in parentheses.
let reference =
value.eq_ignore_ascii_case("Yes") || value == crate::t!("Yes").as_ref();
let Some(before) = self.tabs[i].scene.parametric_constraint_set(scope).cloned()
else {
return Task::none();
};
let pending = self.begin_undo(i, "Constraint reference", 0, true);
self.tabs[i]
.scene
.record_undo_parametric_constraints_before(scope, before);
self.tabs[i].scene.record_undo_named_parameters_before();
let set = self.tabs[i].scene.parametric_constraint_set_mut(scope);
if let Some(constraint) = set.constraints.iter_mut().find(|c| c.id == id) {
constraint.enabled = !reference;
}
self.tabs[i].scene.refresh_dynamic_dimension_texts();
self.tabs[i].scene.sync_native_parametric_graph();
self.tabs[i].dirty = true;
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
self.refresh_properties();
}
_ => {}
}
Task::none()
}
/// Commits a dynamic dimension's Name or Expression row: the typed text
/// goes to the Parameters-section row of the constraint's parameter.
pub(super) fn on_dynamic_dimension_field_commit(
&mut self,
field: &'static str,
param_field: crate::ui::window::named_parameters::ParamField,
) -> Task<Message> {
use crate::scene::named_parameters::DrivingValue;
use crate::scene::parametric_constraints::dynamic_dimension_constraint;
use crate::ui::properties::FieldKey;
let i = self.active_tab;
self.tabs[i].properties.active_field = None;
let Some(typed) = self.tabs[i]
.properties
.edit_buf
.remove(&FieldKey::Geom(field))
else {
return Task::none();
};
let handles = self.property_target_handles(i);
let name = handles.first().and_then(|handle| {
let (_, constraint) =
dynamic_dimension_constraint(&self.tabs[i].scene.parametric_constraints, *handle)?;
match &constraint.driving_param {
Some(DrivingValue::Named(name)) => Some(name.clone()),
_ => None,
}
});
let index = name.and_then(|name| {
self.tabs[i]
.scene
.named_parameters()
.iter()
.position(|parameter| parameter.name == name)
});
let Some(index) = index else {
self.refresh_properties();
return Task::none();
};
self.tabs[i]
.properties
.edit_buf
.insert(FieldKey::Param(index, param_field), typed);
self.on_prop_param_commit(index, param_field)
}
/// Removes Parameters-section row `index` immediately. Any
/// constraint that referenced it gets the same "undefined reference"
/// resolve-failure treatment a formula's own bad reference already gets
@@ -3357,8 +3877,13 @@ impl OpenCADStudio {
|| equal_size_follower(&self.tabs[i].scene.document, first, other)
.is_none()
{
self.command_line
.push_error(EqualConstraintCommand::INVALID_OBJECT);
// The reference names the kind the first object
// asks for.
let message = match equal_size(&self.tabs[i].scene.document, first) {
Some(EqualSize::Radius(_)) => EqualConstraintCommand::INVALID_RADIUS_OBJECT,
_ => EqualConstraintCommand::INVALID_LENGTH_OBJECT,
};
self.command_line.push_error(message);
continue;
}
let exists = self.tabs[i]
@@ -3378,7 +3903,10 @@ impl OpenCADStudio {
}
followers.push(other);
}
if multiple && !finishing {
// A refused second object is asked for again, as in the
// reference; Multiple keeps asking anyway.
let keep = (multiple && !finishing) || (!multiple && followers.is_empty());
if keep {
if let Some(prompt) =
self.tabs[i].active_cmd.as_ref().map(|command| command.prompt())
{
@@ -3450,6 +3978,322 @@ impl OpenCADStudio {
self.commit_undo_delta(i, pd);
}
}
CmdResult::CheckConstraintPoint(pick) => {
use crate::scene::parametric_constraints::{
nearest_parametric_point, nearest_parametric_point_on_entity, resolve_point,
};
let scope = self.tabs[i].current_parametric_scope();
let resolved = {
let document = &self.tabs[i].scene.document;
let world =
acadrust::types::Vector3::new(pick.point.x, pick.point.y, pick.point.z);
let reference = match pick.handle {
Some(handle) => {
nearest_parametric_point_on_entity(document, scope, handle, world)
}
None => nearest_parametric_point(document, scope, world, None),
};
reference.map(|reference| {
let point = document
.get_entity(reference.entity)
.zip(reference.marker)
.and_then(|(entity, marker)| resolve_point(entity, marker))
.map_or(pick.point, |p| glam::DVec3::new(p.x, p.y, p.z));
(reference, point)
})
};
let Some((reference, point)) = resolved else {
return self.apply_cmd_result(CmdResult::ReportError(
crate::modules::parametric::DimConstraintCommand::NO_POINT.to_string(),
));
};
let result = self.tabs[i]
.active_cmd
.as_mut()
.map(|command| command.accept_constraint_point(reference, point));
return match result {
Some(result) => self.apply_cmd_result(result),
None => Task::none(),
};
}
CmdResult::AddDimensionalConstraint {
kind,
first,
second,
first_point,
second_point,
location,
axis,
direction,
name,
expression,
label,
} => {
use crate::modules::annotate::{aligned_dim, linear_dim};
use crate::scene::named_parameters::{is_valid_name, DrivingValue};
use crate::scene::parametric_constraints::{
distance_direction_type, dynamic_dimension_text, ConstraintKind,
};
let scope = self.tabs[i].current_parametric_scope();
let mut refs = vec![first, second];
refs.extend(direction);
// The same measurement between the same references is
// refused after the value, and the command ends.
let duplicate = self.tabs[i]
.scene
.parametric_constraint_set(scope)
.is_some_and(|set| {
set.constraints.iter().any(|c| {
c.enabled
&& c.kind == kind
&& c.refs.len() == refs.len()
&& c.refs[..2].contains(&first)
&& c.refs[..2].contains(&second)
&& c.refs.get(2) == refs.get(2)
})
});
if duplicate {
self.command_line
.push_output("The constraint already exists on the selected objects.");
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
return Task::none();
}
// A refused value keeps the prompt open, as the reference's
// editor does. A directed distance validates as the plain
// two-point distance it also is.
let (validate_kind, validate_refs) = if kind == ConstraintKind::DistanceDirected {
(ConstraintKind::Distance, &refs[..2])
} else {
(kind, &refs[..])
};
if let Err(message) = self.tabs[i].scene.validate_parametric_constraint(
validate_kind,
validate_refs,
Some(&DrivingValue::Literal(1.0)),
) {
self.reprompt_active_command(i, message);
return Task::none();
}
let anchors = crate::scene::parametric_constraints::dimensional_anchor_refs(
&self.tabs[i].scene.document,
&refs,
);
let name = name.trim().to_string();
if !is_valid_name(&name) {
self.reprompt_active_command(i, "Invalid parameter name.");
return Task::none();
}
let mut table = self.tabs[i].scene.named_parameters().clone();
if let Err(error) = table.set(&name, &expression) {
self.reprompt_active_command(i, &error.to_string());
return Task::none();
}
let value = match table.resolve(&name) {
Ok(value) if value.is_finite() => value,
_ => {
self.command_line.push_error("Invalid expression.");
self.reprompt_active_command(
i,
"The parameter is used in an expression which results in an invalid value for a dimensional constraint.",
);
return Task::none();
}
};
// DCFORM Annotational: an ordinary plotted dimension on the
// current layer that shows the value at dimension precision.
let annotational = self.constraint_form_annotational;
let text = dynamic_dimension_text(
&name,
value,
self.tabs[i].scene.constraint_name_format,
false,
Some(&expression),
annotational,
);
let mut entity = if kind == ConstraintKind::Distance {
aligned_dim::aligned_dimension_entity(
first_point,
second_point,
location,
Some(text),
)
} else {
linear_dim::linear_dimension_entity(
first_point,
second_point,
location,
axis,
Some(text),
)
};
crate::scene::creation_style::apply_current_creation_styles(
&self.tabs[i].scene.document,
&mut entity,
);
if annotational {
entity
.as_entity_mut()
.set_layer(self.tabs[i].active_layer.clone());
} else {
// A dynamic dimension lives on the reference's constraints
// layer, hidden from the layer lists and never plotted, and
// draws in the constraint gray, not the current color.
self.tabs[i].scene.ensure_dynamic_dimension_layer();
entity.as_entity_mut().set_layer(
crate::scene::parametric_constraints::DYNAMIC_DIMENSION_LAYER.to_string(),
);
entity.common_mut().color = acadrust::types::Color::Rgb {
r: 103,
g: 109,
b: 118,
};
}
let constraints_before = self.tabs[i]
.scene
.parametric_constraint_set(scope)
.cloned()
.unwrap_or_else(|| {
crate::scene::parametric_constraints::ParametricConstraintSet::new(scope)
});
let mut touched = vec![first.entity];
for reference in [Some(second), direction].into_iter().flatten() {
if !touched.contains(&reference.entity) {
touched.push(reference.entity);
}
}
let pending = self.begin_undo(i, label, touched.len() + 1, false);
self.tabs[i]
.scene
.record_undo_parametric_constraints_before(scope, constraints_before);
self.tabs[i].scene.record_undo_named_parameters_before();
self.tabs[i].scene.named_parameters = table;
let dimension = self.tabs[i].scene.add_entity(entity);
let set = self.tabs[i].scene.parametric_constraint_set_mut(scope);
let id = set.add(kind, refs, Some(DrivingValue::Named(name)));
if direction.is_some() {
if let Some(constraint) = set.constraints.iter_mut().find(|c| c.id == id) {
constraint.distance_direction_type =
distance_direction_type::PERPENDICULAR_TO_LINE;
}
}
set.dimensions.insert(id, dimension);
self.tabs[i].scene.note_parametric_constraint_applied(
scope,
id,
self.constraint_bar_display,
);
self.tabs[i].scene.attach_dimension_association(
dimension,
vec![Some(first.entity), Some(second.entity)],
);
let changes: Vec<(Handle, crate::scene::ChangeKind)> = touched
.into_iter()
.map(|handle| (handle, crate::scene::ChangeKind::Modified))
.collect();
// The first point (and a line it belongs to that the distance
// runs perpendicular to) stays; a value other than the
// measured one moves the second, as in the reference. Not
// `retain_size`: the typed value is the new length.
self.tabs[i]
.scene
.bump_entities_with_parametric_policy(&changes, &anchors, false);
// DYNCONSTRAINTDISPLAY 0 also keeps a new dynamic dimension off screen.
self.tabs[i].scene.refresh_hidden_dynamic_dimensions();
self.tabs[i].scene.refresh_dynamic_dimension_scales(true);
self.tabs[i].dirty = true;
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.refresh_properties();
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
}
CmdResult::MakeParallel {
first_line,
first_ends,
second_line,
second_ends,
} => {
use crate::scene::parametric_constraints::{resolve_point, ConstraintKind};
let scope = self.tabs[i].current_parametric_scope();
let refs = [first_line, second_line];
if let Err(message) =
self.tabs[i]
.scene
.validate_parametric_constraint(ConstraintKind::Parallel, &refs, None)
{
return self.apply_cmd_result(CmdResult::ReportError(message.to_string()));
}
let already = self.tabs[i]
.scene
.parametric_constraint_set(scope)
.is_some_and(|set| {
set.constraints.iter().any(|c| {
c.enabled
&& c.kind == ConstraintKind::Parallel
&& c.refs.contains(&first_line)
&& c.refs.contains(&second_line)
})
});
if !already {
let constraints_before = self.tabs[i]
.scene
.parametric_constraint_set(scope)
.cloned()
.unwrap_or_else(|| {
crate::scene::parametric_constraints::ParametricConstraintSet::new(
scope,
)
});
let pending = self.begin_undo(i, "Parallel constraint", 1, true);
self.tabs[i]
.scene
.record_undo_parametric_constraints_before(scope, constraints_before);
let set = self.tabs[i].scene.parametric_constraint_set_mut(scope);
let id = set.add(ConstraintKind::Parallel, refs.to_vec(), None);
self.tabs[i].scene.note_parametric_constraint_applied(
scope,
id,
self.constraint_bar_display,
);
// The first line stays; the second turns parallel the way the
// Parallel constraint itself moves it.
self.tabs[i].scene.bump_entities_with_parametric_policy(
&[(second_line.entity, crate::scene::ChangeKind::Modified)],
&first_ends,
self.constraint_solve_mode,
);
self.tabs[i].dirty = true;
if let Some(pd) = pending {
self.commit_undo_delta(i, pd);
}
}
let ends = {
let document = &self.tabs[i].scene.document;
document.get_entity(second_line.entity).and_then(|entity| {
let world = |reference: crate::scene::parametric_constraints::ParametricRef| {
let point = resolve_point(entity, reference.marker?)?;
Some((reference, glam::DVec3::new(point.x, point.y, point.z)))
};
Some([world(second_ends[0])?, world(second_ends[1])?])
})
};
let Some(ends) = ends else {
return self.apply_cmd_result(CmdResult::ReportError(
crate::modules::parametric::DimConstraintCommand::NO_OBJECT.to_string(),
));
};
let result = self.tabs[i]
.active_cmd
.as_mut()
.map(|command| command.accept_parallel_line(ends));
return match result {
Some(result) => self.apply_cmd_result(result),
None => Task::none(),
};
}
CmdResult::AddFixedConstraint(pick) => {
use crate::modules::parametric::FixConstraintCommand;
use crate::scene::parametric_constraints::{
@@ -3599,7 +4443,7 @@ impl OpenCADStudio {
("No valid constraint point found.", Some(first))
} else {
(
"The object or point is already selected. Select a different object or constraint point.",
"The object or point is already selected. Select a different object or constraint point.",
Some(first),
)
};
@@ -7857,10 +8701,22 @@ fn entity_at_typed_point(
extent = extent
.max((bounds.max.x - bounds.min.x).abs())
.max((bounds.max.y - bounds.min.y).abs());
let Some(distance) =
crate::scene::viewport_dimension_pick::planar_pick_distance(entity, point)
else {
continue;
let distance = match crate::scene::viewport_dimension_pick::planar_pick_distance(entity, point) {
Some(distance) => distance,
// A text or an ellipse answers by its extent, the way a click
// inside it picks it.
None if matches!(
entity,
acadrust::EntityType::Text(_)
| acadrust::EntityType::MText(_)
| acadrust::EntityType::Ellipse(_)
) =>
{
let dx = (bounds.min.x - point.x).max(point.x - bounds.max.x).max(0.0);
let dy = (bounds.min.y - point.y).max(point.y - bounds.max.y).max(0.0);
dx.hypot(dy)
}
None => continue,
};
if nearest.is_none_or(|(best, _)| distance < best) {
nearest = Some((distance, common.handle));
+179 -3
View File
@@ -1131,7 +1131,26 @@ impl OpenCADStudio {
self.command_line.push_output("No constraints found.");
return None;
}
let pending = self.begin_undo(i, "Delete constraints", handles.len(), true);
let dimensions: Vec<acadrust::Handle> = ids
.iter()
.filter_map(|id| before.dimensions.get(id).copied())
.collect();
let parameters: Vec<String> = ids
.iter()
.filter_map(|id| before.get(*id))
.filter_map(|constraint| match &constraint.driving_param {
Some(crate::scene::named_parameters::DrivingValue::Named(name)) => {
Some(name.clone())
}
_ => None,
})
.collect();
let pending = self.begin_undo(
i,
"Delete constraints",
handles.len(),
dimensions.is_empty(),
);
self.tabs[i]
.scene
.record_undo_parametric_constraints_before(scope, before);
@@ -1139,6 +1158,7 @@ impl OpenCADStudio {
for id in &ids {
set.remove(*id);
}
self.purge_dimensional_extras(i, dimensions, parameters);
let changes: Vec<_> = handles
.iter()
.copied()
@@ -1290,6 +1310,7 @@ impl OpenCADStudio {
}
"DCCONVERT" => {
self.dim_constraint_last = "Convert";
let handles = self.tabs[i].scene.selected_handles_in_order();
if handles.is_empty() {
use crate::modules::draw::select::SelectObjectsCommand;
@@ -1486,6 +1507,131 @@ impl OpenCADStudio {
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
}
"DIMCONSTRAINT" => {
use crate::modules::parametric::DimConstraintMenuCommand;
self.command_line.push_output(&format!(
"Current settings: Constraint form = {}",
self.constraint_form_name()
));
let new_cmd = DimConstraintMenuCommand::new(self.dim_constraint_last);
self.command_line.push_info(&new_cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
}
"DCFORM" => {
use crate::modules::parametric::ConstraintFormCommand;
let new_cmd = ConstraintFormCommand::new(self.constraint_form_annotational);
self.command_line.push_info(&new_cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
}
// DCFORM's answer, then on into DIMCONSTRAINT's options as the
// reference does.
cmd if cmd.starts_with("DCFORM_SET ") => {
use crate::modules::parametric::DimConstraintMenuCommand;
self.constraint_form_annotational = cmd
.trim_start_matches("DCFORM_SET ")
.trim()
.eq_ignore_ascii_case("Annotational");
self.command_line.push_output(&format!(
"Current settings: Constraint form = {}",
self.constraint_form_name()
));
let new_cmd = DimConstraintMenuCommand::new(self.dim_constraint_last);
self.command_line.push_info(&new_cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
}
// A dynamic dimension's value prompt (double-click).
cmd if cmd.starts_with("DCVALUE ") => {
let rest = cmd.trim_start_matches("DCVALUE ").trim();
if let Some((name, input)) = rest.split_once(' ') {
self.apply_parameter_input(i, name.trim(), input);
}
}
"-PARAMETERS" => {
use crate::modules::parametric::ParametersCliCommand;
let new_cmd = ParametersCliCommand::new();
self.command_line.push_info(&new_cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
}
cmd if cmd.starts_with("-PARAMETERS ") => {
let rest = cmd.trim_start_matches("-PARAMETERS ").trim();
let (op, args) = rest.split_once(' ').unwrap_or((rest, ""));
match op {
"LIST" => {
let rule = "-".repeat(74);
let rows: Vec<String> = {
let table = self.tabs[i].scene.named_parameters();
table
.iter()
.map(|parameter| {
let value = table
.resolve(&parameter.name)
.map(|value| format!("{value:.4}"))
.unwrap_or_else(|_| "**".to_string());
format!(
"Parameter: {:<12} Expression: {:<21} Value: {value}",
parameter.name, parameter.source
)
})
.collect()
};
self.command_line.push_output(&rule);
for row in rows {
self.command_line.push_output(&row);
}
self.command_line.push_output(&rule);
}
"NEW" => {
if let Some((name, expression)) = args.split_once(' ') {
self.create_parameter(i, name.trim(), expression.trim());
}
}
"EDIT" => {
use crate::modules::parametric::ParametersCliCommand;
let name = args.trim().to_string();
let old = {
let table = self.tabs[i].scene.named_parameters();
table.get(&name).map(|parameter| {
let value = table
.resolve(&name)
.map(|value| format!("{value:.4}"))
.unwrap_or_else(|_| "**".to_string());
format!("Old Expression = {}, Value = {value}", parameter.source)
})
};
match old {
Some(line) => {
self.command_line.push_output(&line);
let new_cmd = ParametersCliCommand::edit_expression(name);
self.command_line.push_info(&new_cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
}
None => self
.command_line
.push_error(&format!("Parameter {name} not found.")),
}
}
"SET" => {
if let Some((name, expression)) = args.split_once(' ') {
self.apply_parameter_input(i, name.trim(), expression.trim());
}
}
"RENAME" => {
if let Some((old, new)) = args.split_once(' ') {
if let Err(error) = self.rename_parameter(i, old.trim(), new.trim()) {
self.command_line.push_error(&error);
}
}
}
"DELETE" => self.delete_parameter(i, args.trim()),
_ => {}
}
}
"EDCONSTRAINT" => {
use crate::modules::parametric::EqualDistanceConstraintCommand;
let new_cmd = EqualDistanceConstraintCommand::new();
@@ -1726,8 +1872,35 @@ impl OpenCADStudio {
}
}
"DCONSTRAINT" | "DCLINEAR" | "DCHORIZONTAL" | "DCVERTICAL" | "DCALIGNED"
| "DCRADIUS" | "DCDIAMETER" => {
"DCLINEAR" | "DCHORIZONTAL" | "DCVERTICAL" | "DCALIGNED" => {
use crate::modules::parametric::{DimConstraintAxis, DimConstraintCommand};
use crate::scene::parametric_constraints::next_dimensional_parameter_name;
let axis = match cmd {
"DCHORIZONTAL" => DimConstraintAxis::Horizontal,
"DCVERTICAL" => DimConstraintAxis::Vertical,
"DCALIGNED" => DimConstraintAxis::Aligned,
_ => DimConstraintAxis::Linear,
};
self.dim_constraint_last = match cmd {
"DCHORIZONTAL" => "Horizontal",
"DCVERTICAL" => "Vertical",
"DCALIGNED" => "Aligned",
_ => "Linear",
};
let name = next_dimensional_parameter_name(self.tabs[i].scene.named_parameters());
// Both constraint points are picked inside the command.
self.tabs[i].scene.deselect_all();
let command = DimConstraintCommand::new(axis, name);
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
"DCONSTRAINT" | "DCRADIUS" | "DCDIAMETER" => {
if cmd == "DCRADIUS" {
self.dim_constraint_last = "Radius";
} else if cmd == "DCDIAMETER" {
self.dim_constraint_last = "Diameter";
}
let handles = self.tabs[i].scene.selected_handles_in_order();
if handles.is_empty() {
use crate::modules::draw::select::SelectObjectsCommand;
@@ -1767,6 +1940,9 @@ impl OpenCADStudio {
}
"ACONSTRAINT" | "DCANGULAR" => {
if cmd == "DCANGULAR" {
self.dim_constraint_last = "ANgular";
}
let handles = self.tabs[i].scene.selected_handles_in_order();
if handles.is_empty() {
use crate::modules::draw::select::SelectObjectsCommand;
+22 -1
View File
@@ -1052,6 +1052,8 @@ impl OpenCADStudio {
| "CONSTRAINTINFER"
| "CONSTRAINTBARDISPLAY"
| "CONSTRAINTBARMODE"
| "CONSTRAINTNAMEFORMAT"
| "DYNCONSTRAINTDISPLAY"
) =>
{
return self.dispatch_styleprops(&format!("SETVAR {cmd}"), i);
@@ -1075,7 +1077,7 @@ impl OpenCADStudio {
if name.is_empty() || name == "?" {
self.command_line.push_info(&super::plotvars::setvar_listing());
self.command_line.push_info(
crate::t!("SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR SHORTCUTMENU SHORTCUTMENUDURATION CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE CONSTRAINTSOLVEMODE CONSTRAINTINFER CONSTRAINTBARDISPLAY CONSTRAINTBARMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only)").as_ref(),
crate::t!("SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR SHORTCUTMENU SHORTCUTMENUDURATION CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE CONSTRAINTSOLVEMODE CONSTRAINTINFER CONSTRAINTBARDISPLAY CONSTRAINTBARMODE CONSTRAINTNAMEFORMAT DYNCONSTRAINTDISPLAY ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only)").as_ref(),
);
} else {
if name == "CETRANSPARENCY" {
@@ -1275,17 +1277,26 @@ impl OpenCADStudio {
| "CONSTRAINTINFER"
| "CONSTRAINTBARDISPLAY"
| "CONSTRAINTBARMODE"
| "CONSTRAINTNAMEFORMAT"
| "DYNCONSTRAINTDISPLAY"
) {
let current = match name.as_str() {
"CONSTRAINTSOLVEMODE" => i16::from(self.constraint_solve_mode),
"CONSTRAINTINFER" => i16::from(self.constraint_infer),
"CONSTRAINTBARDISPLAY" => self.constraint_bar_display,
"CONSTRAINTBARMODE" => self.constraint_bar_mode,
"CONSTRAINTNAMEFORMAT" => {
i16::from(self.tabs[i].scene.constraint_name_format)
}
"DYNCONSTRAINTDISPLAY" => {
i16::from(self.tabs[i].scene.dynamic_constraint_display)
}
_ => unreachable!(),
};
let maximum = match name.as_str() {
"CONSTRAINTBARDISPLAY" => 3,
"CONSTRAINTBARMODE" => 4095,
"CONSTRAINTNAMEFORMAT" => 2,
_ => 1,
};
if let Some(value) = &value {
@@ -1304,6 +1315,16 @@ impl OpenCADStudio {
self.constraint_bar_display = mode
}
"CONSTRAINTBARMODE" => self.constraint_bar_mode = mode,
"CONSTRAINTNAMEFORMAT" => {
self.tabs[i].scene.constraint_name_format = mode as u8;
self.tabs[i].scene.refresh_dynamic_dimension_texts();
self.tabs[i].dirty = true;
}
"DYNCONSTRAINTDISPLAY" => {
self.tabs[i].scene.dynamic_constraint_display =
mode != 0;
self.tabs[i].scene.refresh_hidden_dynamic_dimensions();
}
_ => unreachable!(),
}
self.persist_settings_if_changed();
+2
View File
@@ -83,6 +83,8 @@ impl OpenCADStudio {
.layers
.layers
.iter()
// The reference's hidden system layers (`*ADSK_CONSTRAINTS`) stay out.
.filter(|l| !l.name.starts_with('*'))
.map(|l| crate::ui::ribbon::LayerInfo {
name: l.name.clone(),
color: crate::ui::window::layers::iced_color_from_acad(&l.color),
+6
View File
@@ -612,6 +612,10 @@ pub(super) struct OpenCADStudio {
pub constraint_solve_mode: bool,
pub constraint_infer: bool,
pub constraint_bar_display: i16,
/// DCFORM: new dimensional constraints use the annotational form.
pub constraint_form_annotational: bool,
/// The dimensional constraint DIMCONSTRAINT offers by default: the last one used.
pub dim_constraint_last: &'static str,
pub constraint_bar_mode: i16,
/// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command);
/// 0 disables autosave.
@@ -3956,6 +3960,8 @@ impl OpenCADStudio {
constraint_solve_mode: true,
constraint_infer: false,
constraint_bar_display: 3,
constraint_form_annotational: false,
dim_constraint_last: "Aligned",
constraint_bar_mode: 4095,
savetime_min: 10,
script_commands: true,
+144
View File
@@ -79,6 +79,8 @@ impl OpenCADStudio {
.layers
.iter()
.map(|l| l.name.clone())
// The reference's hidden system layers (`*ADSK_CONSTRAINTS`) stay out.
.filter(|name| !name.starts_with('*'))
.collect();
let linetype_items: Vec<ui::properties::LinetypeItem> = self.tabs[i]
.scene
@@ -2292,6 +2294,29 @@ impl OpenCADStudio {
}
_ => entity_type_label(entity),
};
// A dynamic dimension shows only its constraint and text
// rotation, as the reference does.
let title = match crate::scene::parametric_constraints::dynamic_dimension_constraint(
&self.tabs[i].scene.parametric_constraints,
handle,
) {
Some((set, constraint)) => {
sections = dynamic_dimension_sections(
&self.tabs[i].scene,
handle,
set,
constraint,
sections,
);
match entity {
acadrust::EntityType::Dimension(
acadrust::entities::Dimension::Aligned(_),
) => t!("Aligned Dimensional Constraint").into_owned(),
_ => t!("Linear Dimensional Constraint").into_owned(),
}
}
None => title,
};
ui::PropertiesPanel {
choice_combos: sections
.iter()
@@ -2739,6 +2764,26 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={}
}
}
}
if self.tabs[i].scene.is_dynamic_dimension(handle) {
// A dynamic dimension shows the reference's grips: a
// triangle at each constraint point aimed at the other
// one, and the text square — no dimension line grip.
let points: Vec<glam::DVec3> = entity_grips
.iter()
.filter(|grip| grip.id <= 1)
.map(|grip| grip.world)
.collect();
entity_grips.retain(|grip| grip.id != 2);
for grip in &mut entity_grips {
if grip.id <= 1 {
let other = points.get(1 - grip.id).copied().unwrap_or(grip.world);
let dir = (other - grip.world).normalize_or(glam::DVec3::X);
*grip = crate::entities::common::oriented_triangle_grip(
grip.id, grip.world, dir,
);
}
}
}
entity_grips.extend(crate::scene::model::solid_history::primitive_grips(
&self.tabs[i].scene.document,
handle,
@@ -3572,6 +3617,105 @@ fn set_row(sections: &mut [crate::scene::model::object::PropSection], field: &st
/// Replace a row's value with an arbitrary control (editable field, dropdown,
/// colour picker …) rather than plain read-only text.
/// A dimensional constraint's Properties as the reference shows them: the
/// Constraint rows, then only the text rotation for a dynamic dimension
/// and the full dimension sections for an annotational one.
fn dynamic_dimension_sections(
scene: &crate::scene::Scene,
handle: Handle,
set: &crate::scene::parametric_constraints::ParametricConstraintSet,
constraint: &crate::scene::parametric_constraints::ParametricConstraint,
sections: Vec<crate::scene::model::object::PropSection>,
) -> Vec<crate::scene::model::object::PropSection> {
use crate::scene::model::object::{PropSection, PropValue, Property};
use crate::scene::named_parameters::DrivingValue;
let table = if set.local_parameters.is_empty() {
scene.named_parameters()
} else {
&set.local_parameters
};
let reference = !constraint.enabled;
let annotational = scene.dimension_is_annotational(handle);
let (name, expression, value, description) = match &constraint.driving_param {
Some(DrivingValue::Named(name)) => (
name.clone(),
table
.iter()
.find(|parameter| &parameter.name == name)
.map(|parameter| parameter.source.clone())
.unwrap_or_default(),
table.resolve(name).ok(),
table.description(name).to_string(),
),
Some(DrivingValue::Literal(value)) => {
(String::new(), format!("{value}"), Some(*value), String::new())
}
None => (String::new(), String::new(), None, String::new()),
};
let row = |label: &str, field: &'static str, value: PropValue| Property {
label: label.to_string(),
field,
value,
};
let yes_no = |flag: bool| PropValue::Choice {
selected: if flag { t!("Yes") } else { t!("No") }.into_owned(),
options: vec![t!("Yes").into_owned(), t!("No").into_owned()],
};
let text_rotation = sections
.iter()
.flat_map(|section| section.props.iter())
.find(|property| property.field == "text_rotation")
.cloned();
let mut result = vec![PropSection {
title: t!("Constraint").into_owned(),
props: vec![
row(
t!("Constraint Form").as_ref(),
"dyn_constraint_form",
PropValue::Choice {
selected: if annotational { "Annotational" } else { "Dynamic" }.to_string(),
options: vec!["Dynamic".to_string(), "Annotational".to_string()],
},
),
row(
t!("Reference").as_ref(),
"dyn_constraint_reference",
yes_no(reference),
),
row(t!("Name").as_ref(), "dyn_constraint_name", PropValue::EditText(name)),
row(
t!("Expression").as_ref(),
"dyn_constraint_expression",
// A reference constraint's expression is the measurement.
if reference {
PropValue::ReadOnly(expression)
} else {
PropValue::EditText(expression)
},
),
row(
t!("Value").as_ref(),
"dyn_constraint_value",
PropValue::ReadOnly(value.map(|v| format!("{v:.4}")).unwrap_or_default()),
),
row(
t!("Description").as_ref(),
"dyn_constraint_description",
PropValue::EditText(description),
),
],
}];
if annotational {
result.extend(sections);
} else if let Some(text_rotation) = text_rotation {
result.push(PropSection {
title: t!("Text").into_owned(),
props: vec![text_rotation],
});
}
result
}
fn set_row_value(
sections: &mut [crate::scene::model::object::PropSection],
field: &str,
+16
View File
@@ -2188,6 +2188,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
field: &'static str,
value: String,
) -> Task<Message> {
if matches!(field, "dyn_constraint_form" | "dyn_constraint_reference") {
return self.on_dynamic_dimension_choice(field, &value);
}
let i = self.active_tab;
let handles = self.property_target_handles(i);
@@ -3259,6 +3262,19 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
}
pub(super) fn on_prop_geom_commit(&mut self, field: &'static str) -> Task<Message> {
use crate::ui::window::named_parameters::ParamField;
// A dynamic dimension's Name/Expression rows edit its parameter.
let dynamic_field = match field {
"dyn_constraint_name" => Some(ParamField::Name),
"dyn_constraint_expression" => Some(ParamField::Formula),
_ => None,
};
if let Some(param_field) = dynamic_field {
return self.on_dynamic_dimension_field_commit(field, param_field);
}
if field == "dyn_constraint_description" {
return self.on_dynamic_dimension_description_commit(field);
}
let i = self.active_tab;
self.tabs[i].properties.active_field = None;
let handles = self.property_target_handles(i);
+3 -3
View File
@@ -1466,7 +1466,7 @@ impl OpenCADStudio {
self.prepare_native_save(i);
let version = self.tabs[i].scene.document.version;
let snapshot = self.tabs[i].scene.document.clone();
let snapshot = self.tabs[i].scene.document_for_save();
crate::io::save_owned_as_version_atomic(
snapshot,
&path,
@@ -1895,7 +1895,7 @@ impl OpenCADStudio {
path: std::path::PathBuf,
) -> Task<Message> {
let i = self.active_tab;
let document = self.tabs[i].scene.document.clone();
let document = self.tabs[i].scene.document_for_save();
let handles: Vec<_> = self.tabs[i].scene.selected.iter().copied().collect();
let worker_name = block_name.clone();
let worker_path = path.clone();
@@ -2300,7 +2300,7 @@ impl OpenCADStudio {
crate::ui::wrap_bar::dropdown_bounds(crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID)
});
let clone_started = iced::time::Instant::now();
let mut snapshot = self.tabs[i].scene.document.clone();
let mut snapshot = self.tabs[i].scene.document_for_save();
// Save-As across folders: rebase relative reference paths onto the
// new base dir inside the snapshot only (live strings are untouched).
if purpose == crate::app::SavePurpose::SaveAs {
+11
View File
@@ -785,6 +785,8 @@ impl OpenCADStudio {
pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
let i = self.active_tab;
self.tabs[i].scene.update(t - self.start);
// Dynamic dimensions keep their screen size across zoom bands.
self.tabs[i].scene.refresh_dynamic_dimension_scales(false);
// If the camera moved since we last synced, write it back to
// the document and mark the file dirty.
@@ -5058,6 +5060,15 @@ properties={:.1}ms picked={}",
{
return Task::none();
}
// A constraint dimension edits its parameter from the
// command line, standing in for the reference's in-place
// value editor.
if let Some(prompt) = self.dynamic_dimension_value_command(i, handle) {
use crate::command::CadCommand;
self.command_line.push_info(&prompt.prompt());
self.tabs[i].active_cmd = Some(Box::new(prompt));
return Task::none();
}
// Any text-bearing entity opens its in-place editor
// (plain box or rich MText editor, per type). A
// Leader resolves to the entity it annotates.
+49
View File
@@ -1610,6 +1610,37 @@ pub enum CmdResult {
multiple: bool,
label: &'static str,
},
/// Resolves a constraint-point pick against the live document and hands
/// it back through `CadCommand::accept_constraint_point`; a miss reports
/// `No valid constraint point found.` and asks again.
CheckConstraintPoint(CoincidentPick),
/// Adds a dimensional constraint between two constraint points together
/// with the dynamic dimension that shows it and the parameter
/// (`name` = `expression`) that drives it.
AddDimensionalConstraint {
kind: crate::scene::parametric_constraints::ConstraintKind,
first: crate::scene::parametric_constraints::ParametricRef,
second: crate::scene::parametric_constraints::ParametricRef,
first_point: DVec3,
second_point: DVec3,
location: DVec3,
axis: DVec3,
/// Aligned's Point & line / 2Lines: the line the distance is
/// measured perpendicular to.
direction: Option<crate::scene::parametric_constraints::ParametricRef>,
name: String,
expression: String,
label: &'static str,
},
/// Aligned's 2Lines: makes `second_line` parallel to `first_line` (whose
/// ends stay put), then hands the second line's solved ends back through
/// `CadCommand::accept_parallel_line`.
MakeParallel {
first_line: crate::scene::parametric_constraints::ParametricRef,
first_ends: [crate::scene::parametric_constraints::ParametricRef; 2],
second_line: crate::scene::parametric_constraints::ParametricRef,
second_ends: [crate::scene::parametric_constraints::ParametricRef; 2],
},
/// Adds a point or object symmetry relation around a picked line. The
/// first reference and axis remain fixed during initial placement.
AddSymmetricConstraint {
@@ -2453,6 +2484,24 @@ pub trait CadCommand: Send {
false
}
/// A constraint point the host resolved for a `CheckConstraintPoint`
/// pick, with its world position.
fn accept_constraint_point(
&mut self,
_reference: crate::scene::parametric_constraints::ParametricRef,
_point: DVec3,
) -> CmdResult {
CmdResult::NeedPoint
}
/// The second line's ends after a `MakeParallel` solve.
fn accept_parallel_line(
&mut self,
_ends: [(crate::scene::parametric_constraints::ParametricRef, DVec3); 2],
) -> CmdResult {
CmdResult::NeedPoint
}
/// Include filled hatch / DXF SOLID regions in the entity hit-test.
///
/// Most entity-pick commands operate on curve geometry and intentionally
+34
View File
@@ -6303,6 +6303,40 @@ fn dimension_text_is_outside(dim: &Dimension, style: Option<&DimStyle>) -> bool
}
}
/// Where a dynamic dimension's lock mark sits: just after the text on its
/// baseline, with the direction that points away from the text.
pub(crate) fn dynamic_dimension_lock_anchor(
document: &CadDocument,
dim: &Dimension,
anno_scale: f64,
) -> Option<(Vector3, Vector3)> {
let style_name = &dim.base().style_name;
let source_style = document.dim_styles.iter().find(|s| {
s.name.eq_ignore_ascii_case(style_name)
|| (style_name.trim().is_empty() && s.name.eq_ignore_ascii_case("Standard"))
});
let effective_style = source_style.map(|style| resolved_dimension_style(style, dim, document));
let style = effective_style.as_ref();
let dim_scale = style
.map(|s| if s.dimscale > 1e-6 { s.dimscale } else { anno_scale })
.unwrap_or(1.0);
let text_height = style.map(|s| s.dimtxt * dim_scale).unwrap_or(2.5);
let value = dimension_text_value(dim, style)?;
let stack_scale = style.map(dimtfac_or_one).unwrap_or(1.0);
let half_width = text_cells(&value, stack_scale) * text_height * CELL_WIDTH * 0.5;
let pos = dimension_text_pos_f64(dim, style, text_height, dim_scale);
// The text angle the renderer draws (DIMTIH/DIMTOH overrides make a
// dynamic dimension's text horizontal).
let (sr, cr) = dimension_text_rotation(dim, style).sin_cos();
let outward = Vector3::new(cr, sr, 0.0);
// A glyph-sized gap keeps the lock clear of the last digit.
let reach = half_width + text_height * 0.9;
Some((
Vector3::new(pos.x + outward.x * reach, pos.y + outward.y * reach, pos.z),
outward,
))
}
fn dimension_text_natural_rotation(dim: &Dimension) -> f64 {
let angle = match dim {
Dimension::Linear(d) => d.rotation,
+113 -4
View File
@@ -77,6 +77,27 @@ pub fn resolve_text_style(style_name: &str, document: &CadDocument) -> ResolvedT
}
}
// A style's "Big Font" (`chineset.shx`, `hztxt.shx`, …) supplies the
// double-byte characters. Resolve it like the primary font (next to the
// drawing, then the per-user fonts folder) and pair it with the face; an
// unresolvable big font just leaves those characters to the fallback.
if let Some(big) = style
.map(|s| s.big_font_file.trim())
.filter(|big| !big.is_empty() && big.to_ascii_lowercase().ends_with(".shx"))
{
let base = document
.source_path
.as_deref()
.map(std::path::Path::new)
.and_then(|p| p.parent());
let big_path = crate::io::resolve_image_file(big, base).or_else(|| {
crate::io::font_repo::local_font_file(big).map(|p| p.to_string_lossy().into_owned())
});
if let Some(big_path) = big_path {
font_name = Face::with_big_font(&font_name, &big_path);
}
}
ResolvedTextStyle {
font_name,
width_factor: style.map(|s| s.width_factor as f32).unwrap_or(1.0),
@@ -539,11 +560,18 @@ pub fn adapt_mtext_paragraphs(
}
let (align, indent_first, indent_left, indent_right, tab_stops) =
if has_own_props(props) || carried.is_none() {
// `\pxi…,l…,r…,t…;` values are multiples of the text height,
// not drawing units, and the first-line indent is relative to
// the left indent (a numbered list's hanging indent is
// `i-1.5,l1.5`: number at the margin, wrapped lines 1.5 h in).
// Taken raw they were a fraction of a unit — no indent at all —
// so every wrapped list line snapped back to the margin.
let left = props.left_margin.unwrap_or(0.0) as f32 * entity_height;
let v = (
props.alignment.and_then(map_align),
props.first_line_indent.unwrap_or(0.0) as f32,
props.left_margin.unwrap_or(0.0) as f32,
props.right_margin.unwrap_or(0.0) as f32,
left + props.first_line_indent.unwrap_or(0.0) as f32 * entity_height,
left,
props.right_margin.unwrap_or(0.0) as f32 * entity_height,
props
.tab_stops
.iter()
@@ -556,7 +584,7 @@ pub fn adapt_mtext_paragraphs(
ATab::Decimal(_) => TabKind::Decimal,
};
TabStop {
position: ts.position() as f32,
position: ts.position() as f32 * entity_height,
kind,
}
})
@@ -879,6 +907,20 @@ pub fn resolve_font<'a>(state: &'a RunState, base: &'a str) -> std::borrow::Cow<
}
}
/// Whether a line may break between `prev` and `next`. Ideographic text has
/// no spaces, so a break is allowed on either side of a full-width character,
/// except before a closing mark (`,。、)」…`) or after an opening one
/// (`(「《…`) — the basic kinsoku rule.
pub(crate) fn cjk_break_between(prev: char, next: char) -> bool {
use crate::scene::text::ttf_glyph::is_full_width;
const CLOSING: &str = ",。、;:?!)」』】〕》〉』﹚﹞.~…‥、〞”’,.;:!?)]}%";
const OPENING: &str = "(「『【〔《〈﹙﹝“‘([{";
if !(is_full_width(prev) || is_full_width(next)) {
return false;
}
!CLOSING.contains(next) && !OPENING.contains(prev)
}
pub fn measure_word(
text: &str,
state: &RunState,
@@ -1383,6 +1425,21 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
doc_char_offset += 1;
word_start = doc_char_offset;
} else {
// CJK text carries no spaces: every ideograph is a
// wrap opportunity (bar the usual "no line starts
// with a closing mark / ends with an opening one"
// rule), so a long Chinese sentence must not stay
// one unbreakable word that overshoots its column.
if let Some(prev) = word.chars().last() {
if cjk_break_between(prev, ch) {
atoms.push(LayoutAtom {
kind: AtomKind::Word(std::mem::take(&mut word)),
state: run.state.clone(),
char_offset: word_start,
});
word_start = doc_char_offset;
}
}
word.push(ch);
doc_char_offset += 1;
}
@@ -2976,6 +3033,58 @@ mod v_anchor_tests {
assert_eq!(reordered6, vec!["שלום", " ", "«x"]);
}
#[test]
fn cjk_paragraph_wraps_between_ideographs() {
use crate::entities::text_support::{layout_mtext, MTextRenderOpts, MTextVAnchor, ResolvedTextStyle};
// Breaks are allowed between ideographs but not before a closing mark
// or after an opening one.
assert!(cjk_break_between('工', '程'));
assert!(cjk_break_between('程', 'A'));
assert!(!cjk_break_between('工', ','));
assert!(!cjk_break_between('(', '工'));
assert!(!cjk_break_between('a', 'b'));
let style = ResolvedTextStyle {
font_name: "Standard".to_string(),
width_factor: 1.0,
oblique_angle: 0.0,
is_backward: false,
is_upside_down: false,
is_vertical: false,
};
// 40 ideographs with no spaces in a column ~12 characters wide used
// to stay one unbreakable word: a single line overshooting the box.
let value = "本工程之各種平面圖均須相互配合如有不符或須更正處均按工程慣例施工至完整並請於估價前提出";
let opts = MTextRenderOpts {
columns: Default::default(),
value,
insertion: [0.0, 0.0, 0.0],
height: 2.5,
rect_w: 30.0,
rotation: 0.0,
style: &style,
attach_h_anchor: 0.0,
v_anchor: MTextVAnchor::Top,
line_spacing_factor: 1.0,
exact_line_spacing: false,
rectangle_height: 0.0,
vertical_text: false,
want_glyph_boxes: true,
};
let layout = layout_mtext(&opts);
assert!(
layout.line_count >= 3,
"a 40-ideograph paragraph in a 30-unit column must wrap (got {} line(s))",
layout.line_count
);
assert!(
layout.line_widths.iter().all(|w| *w <= 30.0 + 1e-3),
"no wrapped line may overshoot the column: {:?}",
layout.line_widths
);
}
#[test]
fn test_mtext_arabic_bidi_layout() {
use crate::entities::text_support::{layout_mtext, MTextRenderOpts, MTextVAnchor, ResolvedTextStyle};
+7
View File
@@ -158,6 +158,13 @@ impl std::fmt::Display for Language {
}
fn system_languages() -> Vec<i18n_embed::unic_langid::LanguageIdentifier> {
// The unit tests compare English prompts and messages through one
// process-wide loader, and every `apply_config` of a default config asks
// for the system language, so the machine's locale must never reach
// them — not even from a test that runs in parallel.
#[cfg(test)]
return vec!["en-US".parse().expect("en-US is a valid language identifier")];
#[cfg(target_arch = "wasm32")]
let mut requested = WebLanguageRequester::requested_languages();
#[cfg(not(target_arch = "wasm32"))]
+15 -12
View File
@@ -70,18 +70,21 @@ pub fn missing_shx_fonts(doc: &CadDocument) -> Vec<String> {
.map(Path::new)
.and_then(|p| p.parent());
for style in doc.text_styles.iter() {
let file = style.font_file.trim();
if file.is_empty() || !file.to_ascii_lowercase().ends_with(".shx") {
continue;
}
let resolved = crate::io::resolve_image_file(file, base).is_some()
|| local_font_file(file).is_some();
if resolved {
continue;
}
let name = file.rsplit(['/', '\\']).next().unwrap_or(file).to_string();
if !missing.iter().any(|m| m.eq_ignore_ascii_case(&name)) {
missing.push(name);
// The primary font and the big font (Asian double-byte glyphs) are
// both plain .shx files the style depends on.
for file in [style.font_file.trim(), style.big_font_file.trim()] {
if file.is_empty() || !file.to_ascii_lowercase().ends_with(".shx") {
continue;
}
let resolved = crate::io::resolve_image_file(file, base).is_some()
|| local_font_file(file).is_some();
if resolved {
continue;
}
let name = file.rsplit(['/', '\\']).next().unwrap_or(file).to_string();
if !missing.iter().any(|m| m.eq_ignore_ascii_case(&name)) {
missing.push(name);
}
}
}
missing.sort_by_key(|name| name.to_ascii_lowercase());
+10 -1
View File
@@ -424,7 +424,16 @@ fn build_pdf_pages(pages: &[PdfPageInput], plot_style: Option<&PlotStyleTable>)
.map_err(|error| format!("Page {}: {error}", index + 1))?;
}
let mut warnings = Vec::new();
Ok(doc.save(&PdfSaveOptions::default(), &mut warnings))
// printpdf 0.9's `optimize` is a no-op (its `doc.compress()` is commented
// out), so every page's content stream — megabytes of vector operators
// for a CAD sheet — went out uncompressed: a 16-sheet set weighed 280 MB.
// Serialise through lopdf ourselves and Flate-compress the streams.
let mut lo = printpdf::to_lopdf_doc(&doc, &PdfSaveOptions::default(), &mut warnings);
lo.compress();
let mut bytes = Vec::new();
lo.save_to(&mut bytes)
.map_err(|error| format!("PDF serialisation failed: {error}"))?;
Ok(bytes)
}
#[cfg(not(target_arch = "wasm32"))]
+25 -15
View File
@@ -67,26 +67,36 @@ impl AlignedDimensionCommand {
let first = self.plane.to_local(first);
let second = self.plane.to_local(second);
let point = self.plane.to_local(point);
let mut dim = DimensionAligned::new(v3(first), v3(second));
// The placement point is stored so commit matches the preview.
dim.definition_point = v3(point);
dim.base.definition_point = v3(point);
let (d1, d2) = dim_line_endpoints(first, second, point);
dim.base.text_middle_point = v3((d1 + d2) * 0.5);
dim.base.insertion_point = dim.base.text_middle_point;
dim.base.actual_measurement = dim.measurement();
crate::entities::dimension::set_dimension_text_override(
&mut dim.base,
self.text_override.clone(),
);
let mut entity = aligned_dimension_entity(first, second, point, self.text_override.clone());
// An explicit text angle overrides the default rotation.
if let Some(angle) = self.text_angle {
dim.base.text_rotation = angle;
if let (Some(angle), EntityType::Dimension(dimension)) = (self.text_angle, &mut entity) {
dimension.base_mut().text_rotation = angle;
}
self.plane.place_entity(EntityType::Dimension(Dimension::Aligned(dim)))
self.plane.place_entity(entity)
}
}
/// An aligned dimension between two points of the working plane, its
/// dimension line through `point` — what DIMALIGNED places and what an
/// aligned dimensional constraint draws.
pub(crate) fn aligned_dimension_entity(
first: DVec3,
second: DVec3,
point: DVec3,
text_override: Option<String>,
) -> EntityType {
let mut dim = DimensionAligned::new(v3(first), v3(second));
// The placement point is stored so commit matches the preview.
dim.definition_point = v3(point);
dim.base.definition_point = v3(point);
let (d1, d2) = dim_line_endpoints(first, second, point);
dim.base.text_middle_point = v3((d1 + d2) * 0.5);
dim.base.insertion_point = dim.base.text_middle_point;
dim.base.actual_measurement = dim.measurement();
crate::entities::dimension::set_dimension_text_override(&mut dim.base, text_override);
EntityType::Dimension(Dimension::Aligned(dim))
}
impl CadCommand for AlignedDimensionCommand {
fn set_working_plane(&mut self, plane: WorkingPlane) {
self.plane = plane;
+27 -15
View File
@@ -15,7 +15,7 @@ use glam::DVec3;
use crate::t;
/// Select the measured axis from where the dimension line clears the points.
fn measure_axis(first: DVec3, second: DVec3, def: DVec3) -> DVec3 {
pub(crate) fn measure_axis(first: DVec3, second: DVec3, def: DVec3) -> DVec3 {
let outside = |value: f64, a: f64, b: f64| {
let (low, high) = if a <= b { (a, b) } else { (b, a) };
(low - value).max(value - high).max(0.0)
@@ -113,26 +113,38 @@ impl LinearDimensionCommand {
let first = self.plane.to_local(first);
let second = self.plane.to_local(second);
let point = self.plane.to_local(point);
let mut dim = DimensionLinear::new(v3(first), v3(second));
let axis = self.axis_mode.axis(first, second, point);
dim.rotation = axis.y.atan2(axis.x);
dim.set_offset(dimension_line_offset(second, point, axis));
dim.base.definition_point = dim.definition_point;
dim.base.text_middle_point = v3(linear_text_pos(first, second, point, axis));
dim.base.insertion_point = dim.base.text_middle_point;
dim.base.actual_measurement = dim.measurement();
crate::entities::dimension::set_dimension_text_override(
&mut dim.base,
self.text_override.clone(),
);
let mut entity =
linear_dimension_entity(first, second, point, axis, self.text_override.clone());
// An explicit text angle overrides the UCS-derived rotation.
if let Some(angle) = self.text_angle {
dim.base.text_rotation = angle;
if let (Some(angle), EntityType::Dimension(dimension)) = (self.text_angle, &mut entity) {
dimension.base_mut().text_rotation = angle;
}
self.plane.place_entity(EntityType::Dimension(Dimension::Linear(dim)))
self.plane.place_entity(entity)
}
}
/// A linear dimension between two points of the working plane, its
/// dimension line through `point` along `axis` — what DIMLINEAR places and
/// what a dynamic dimensional constraint draws.
pub(crate) fn linear_dimension_entity(
first: DVec3,
second: DVec3,
point: DVec3,
axis: DVec3,
text_override: Option<String>,
) -> EntityType {
let mut dim = DimensionLinear::new(v3(first), v3(second));
dim.rotation = axis.y.atan2(axis.x);
dim.set_offset(dimension_line_offset(second, point, axis));
dim.base.definition_point = dim.definition_point;
dim.base.text_middle_point = v3(linear_text_pos(first, second, point, axis));
dim.base.insertion_point = dim.base.text_middle_point;
dim.base.actual_measurement = dim.measurement();
crate::entities::dimension::set_dimension_text_override(&mut dim.base, text_override);
EntityType::Dimension(Dimension::Linear(dim))
}
impl CadCommand for LinearDimensionCommand {
fn name(&self) -> &'static str {
"DIMLINEAR"
+976
View File
@@ -0,0 +1,976 @@
use acadrust::{EntityType, Handle};
use glam::DVec3;
use crate::command::{CadCommand, CmdOption, CmdResult, CoincidentPick};
use crate::scene::parametric_constraints::{
measured_expression, resolve_point, ConstraintKind, ParametricRef,
};
/// Which distance a dimensional constraint measures.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum DimConstraintAxis {
/// Horizontal or vertical, decided by where the dimension line goes.
Linear,
Horizontal,
Vertical,
Aligned,
}
/// A picked line (a line entity or one polyline segment) an Aligned
/// constraint measures perpendicular to.
#[derive(Clone, Copy)]
struct LineTarget {
line: ParametricRef,
/// Unit direction from its start to its end.
dir: DVec3,
/// Its two ends and where they are.
ends: [(ParametricRef, DVec3); 2],
}
impl LineTarget {
/// The end nearest to `point` — the end the reference measures from.
fn nearest_end(&self, point: DVec3) -> (ParametricRef, DVec3) {
let [a, b] = self.ends;
if (b.1 - point).length_squared() < (a.1 - point).length_squared() {
b
} else {
a
}
}
}
#[derive(Clone, Copy)]
enum Step {
/// `Specify first constraint point or [Object] <Object>:`
First,
/// `Select object:`
Object,
/// `Specify second constraint point:`
Second {
first: ParametricRef,
first_point: DVec3,
},
/// Aligned's Point & line: `Specify constraint point or [Line] <Line>:`
PointLinePoint,
/// `Select line:` after the constraint point.
PointLineLine {
point: ParametricRef,
point_pos: DVec3,
},
/// `Select line:` first (the Line option).
LineFirst,
/// `Select constraint point:` after the line.
LinePoint { line: LineTarget },
/// Aligned's 2Lines: `Select first line:`
TwoLinesFirst,
/// `Select second line to make parallel:`
TwoLinesSecond { line: LineTarget },
/// The host is making the second line parallel; `accept_parallel_line`
/// brings its solved ends.
TwoLinesParallel {
first: LineTarget,
second: LineTarget,
/// Where the second line was picked.
pick: DVec3,
},
/// `Specify dimension line location:`
Location {
first: ParametricRef,
second: ParametricRef,
first_point: DVec3,
second_point: DVec3,
/// The line the distance is measured perpendicular to.
direction: Option<LineTarget>,
},
/// `Enter value or name and value <d1=100>:`
Value {
first: ParametricRef,
second: ParametricRef,
first_point: DVec3,
second_point: DVec3,
location: DVec3,
axis: DVec3,
kind: ConstraintKind,
measured: f64,
direction: Option<LineTarget>,
},
}
/// The reference's Linear/Horizontal/Vertical/Aligned dimensional constraint:
/// two constraint points (or one object's ends), a dimension line location,
/// then the parameter name and expression the dynamic dimension carries.
pub struct DimConstraintCommand {
axis: DimConstraintAxis,
step: Step,
picked_entity: Option<EntityType>,
/// The next free `dN` the host reserved for this constraint.
default_name: String,
}
impl DimConstraintCommand {
pub const NO_OBJECT: &'static str = "No object found.";
pub const NO_POINT: &'static str = "No valid constraint point found.";
pub const SAME_POINT: &'static str =
"The object or point is already selected. Select a different object or constraint point.";
pub const INVALID_LINE: &'static str = "Invalid selection for Aligned. Select a line segment, polyline segment, text, MText, major or minor axis of ellipse or elliptical arc.";
pub fn new(axis: DimConstraintAxis, default_name: String) -> Self {
Self {
axis,
step: Step::First,
picked_entity: None,
default_name,
}
}
fn command_name(&self) -> &'static str {
match self.axis {
DimConstraintAxis::Linear => "DCLINEAR",
DimConstraintAxis::Horizontal => "DCHORIZONTAL",
DimConstraintAxis::Vertical => "DCVERTICAL",
DimConstraintAxis::Aligned => "DCALIGNED",
}
}
fn noun(&self) -> &'static str {
match self.axis {
DimConstraintAxis::Linear => "Linear",
DimConstraintAxis::Horizontal => "Horizontal",
DimConstraintAxis::Vertical => "Vertical",
DimConstraintAxis::Aligned => "Aligned",
}
}
fn report(message: &str) -> CmdResult {
CmdResult::ReportError(message.to_string())
}
/// The two end points an object pick constrains: a line's or an arc's
/// ends, the picked polyline segment's vertices.
fn object_ends(
entity: &EntityType,
handle: Handle,
point: DVec3,
) -> Option<(ParametricRef, ParametricRef)> {
match entity {
EntityType::Line(_) | EntityType::Arc(_) => {
Some((ParametricRef::point(handle, 0), ParametricRef::point(handle, 1)))
}
EntityType::LwPolyline(_) | EntityType::Polyline2D(_) => {
let (source, _, _) =
crate::scene::centerline::picked_source(entity, handle, point)?;
let index = source.segment_index;
// A closed polyline's last segment ends at the first vertex.
let end = if resolve_point(entity, index + 1).is_some() {
index + 1
} else {
0
};
Some((ParametricRef::point(handle, index), ParametricRef::point(handle, end)))
}
_ => None,
}
}
fn world(entity: &EntityType, reference: ParametricRef) -> Option<DVec3> {
let point = resolve_point(entity, reference.marker?)?;
Some(DVec3::new(point.x, point.y, point.z))
}
/// The line a Point & line or 2Lines pick measures against: a line
/// entity as a whole, or the picked polyline segment.
fn line_target(entity: &EntityType, handle: Handle, point: DVec3) -> Option<LineTarget> {
let (line, first, second) = match entity {
EntityType::Line(_) => (
ParametricRef::whole(handle),
ParametricRef::point(handle, 0),
ParametricRef::point(handle, 1),
),
EntityType::LwPolyline(_) | EntityType::Polyline2D(_) => {
let (source, _, _) =
crate::scene::centerline::picked_source(entity, handle, point)?;
let index = source.segment_index;
if index < 0 {
return None;
}
let end = if resolve_point(entity, index + 1).is_some() {
index + 1
} else {
0
};
(
ParametricRef::segment(handle, index as usize),
ParametricRef::point(handle, index),
ParametricRef::point(handle, end),
)
}
// A text's baseline or an ellipse's axis is a line whose one
// constraint point is the insertion point / the center.
EntityType::Text(_) | EntityType::MText(_) => {
return Self::axis_target(entity, ParametricRef::text_baseline(handle), ParametricRef::point(handle, 0));
}
EntityType::Ellipse(_) => {
let center = ParametricRef::point(handle, -3);
let major = Self::axis_target(entity, ParametricRef::ellipse_major_axis(handle), center);
let minor = Self::axis_target(entity, ParametricRef::ellipse_minor_axis(handle), center);
let gap = |target: &LineTarget| {
let offset = point - target.ends[0].1;
(offset.x * target.dir.y - offset.y * target.dir.x).abs()
};
return match (major, minor) {
(Some(major), Some(minor)) => Some(if gap(&minor) < gap(&major) { minor } else { major }),
(major, minor) => major.or(minor),
};
}
_ => return None,
};
let start = Self::world(entity, first)?;
let finish = Self::world(entity, second)?;
let dir = (finish - start).try_normalize()?;
Some(LineTarget {
line,
dir,
ends: [(first, start), (second, finish)],
})
}
/// A directional axis (text baseline, ellipse axis) as a line target
/// whose only constraint point is `anchor`.
fn axis_target(entity: &EntityType, axis: ParametricRef, anchor: ParametricRef) -> Option<LineTarget> {
let [start, end] =
crate::scene::parametric_constraints::directional_axis_endpoints(entity, axis)?;
let (start, end) = (
DVec3::new(start.x, start.y, start.z),
DVec3::new(end.x, end.y, end.z),
);
let dir = (end - start).try_normalize()?;
let anchor_point = Self::world(entity, anchor)?;
Some(LineTarget {
line: axis,
dir,
ends: [(anchor, anchor_point), (anchor, anchor_point)],
})
}
/// A picked line for the current line step, or the message to show.
fn pick_line(&mut self, handle: Handle, point: DVec3) -> Result<LineTarget, &'static str> {
if handle.is_null() {
return Err(Self::NO_OBJECT);
}
let Some(entity) = self.picked_entity.take() else {
return Err("");
};
Self::line_target(&entity, handle, point).ok_or(Self::INVALID_LINE)
}
/// The measured kind and axis: Linear reads the dimension line location
/// the way a linear dimension does, the others are fixed; a Point & line
/// or 2Lines distance runs perpendicular to its line.
fn decide(
&self,
first: DVec3,
second: DVec3,
location: DVec3,
direction: Option<LineTarget>,
) -> (ConstraintKind, DVec3) {
if let Some(line) = direction {
return (
ConstraintKind::DistanceDirected,
DVec3::new(line.dir.y, -line.dir.x, 0.0),
);
}
match self.axis {
DimConstraintAxis::Linear => {
let axis =
crate::modules::annotate::linear_dim::measure_axis(first, second, location);
if axis.x.abs() > 0.5 {
(ConstraintKind::DistanceX, DVec3::X)
} else {
(ConstraintKind::DistanceY, DVec3::Y)
}
}
DimConstraintAxis::Horizontal => (ConstraintKind::DistanceX, DVec3::X),
DimConstraintAxis::Vertical => (ConstraintKind::DistanceY, DVec3::Y),
DimConstraintAxis::Aligned => (
ConstraintKind::Distance,
(second - first).normalize_or(DVec3::X),
),
}
}
fn measured(kind: ConstraintKind, first: DVec3, second: DVec3, axis: DVec3) -> f64 {
match kind {
ConstraintKind::DistanceX => (second.x - first.x).abs(),
ConstraintKind::DistanceY => (second.y - first.y).abs(),
ConstraintKind::DistanceDirected => (second - first).dot(axis).abs(),
_ => (second - first).length(),
}
}
fn build(&self, name: String, expression: String) -> Option<CmdResult> {
let Step::Value {
first,
second,
first_point,
second_point,
location,
axis,
kind,
direction,
..
} = self.step
else {
return None;
};
if expression.trim().is_empty() {
return None;
}
Some(CmdResult::AddDimensionalConstraint {
kind,
first,
second,
first_point,
second_point,
location,
axis,
direction: direction.map(|line| line.line),
name,
expression,
label: match self.axis {
DimConstraintAxis::Linear => "Linear constraint",
DimConstraintAxis::Horizontal => "Horizontal distance constraint",
DimConstraintAxis::Vertical => "Vertical distance constraint",
DimConstraintAxis::Aligned => "Aligned constraint",
},
})
}
}
impl CadCommand for DimConstraintCommand {
fn name(&self) -> &'static str {
self.command_name()
}
fn prompt(&self) -> String {
let name = self.command_name();
match self.step {
Step::First if self.axis == DimConstraintAxis::Aligned => format!(
"{name} Specify first constraint point or [Object/Point & line/2Lines] <Object>:"
),
Step::First => {
format!("{name} Specify first constraint point or [Object] <Object>:")
}
Step::Object => format!("{name} Select object:"),
Step::Second { .. } => format!("{name} Specify second constraint point:"),
Step::PointLinePoint => {
format!("{name} Specify constraint point or [Line] <Line>:")
}
Step::PointLineLine { .. } | Step::LineFirst => format!("{name} Select line:"),
Step::LinePoint { .. } => format!("{name} Select constraint point:"),
Step::TwoLinesFirst => format!("{name} Select first line:"),
Step::TwoLinesSecond { .. } | Step::TwoLinesParallel { .. } => {
format!("{name} Select second line to make parallel:")
}
Step::Location { .. } => format!("{name} Specify dimension line location:"),
Step::Value { measured, .. } => format!(
"{name} Enter value or name and value <{}={}>:",
self.default_name,
measured_expression(measured)
),
}
}
fn options(&self) -> Vec<CmdOption> {
match self.step {
Step::First if self.axis == DimConstraintAxis::Aligned => vec![
CmdOption::new("Object", "O"),
CmdOption::new("Point & line", "P"),
CmdOption::new("2Lines", "2L"),
],
Step::First => vec![CmdOption::new("Object", "O")],
Step::PointLinePoint => vec![CmdOption::new("Line", "L")],
_ => Vec::new(),
}
}
fn wants_text_input(&self) -> bool {
true
}
fn point_step_accepts_keywords(&self) -> bool {
matches!(
self.step,
Step::First | Step::PointLinePoint | Step::Value { .. }
)
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
match self.step {
Step::First => {
let keyword = text.trim().trim_start_matches('_').to_ascii_uppercase();
let aligned = self.axis == DimConstraintAxis::Aligned;
match keyword.as_str() {
"O" | "OBJECT" => self.step = Step::Object,
"P" | "POINT" | "POINT & LINE" | "POINT&LINE" if aligned => {
self.step = Step::PointLinePoint
}
"2L" | "2LINES" if aligned => self.step = Step::TwoLinesFirst,
_ => return None,
}
Some(CmdResult::NeedPoint)
}
Step::PointLinePoint => {
let keyword = text.trim().trim_start_matches('_').to_ascii_uppercase();
if matches!(keyword.as_str(), "L" | "LINE") {
self.step = Step::LineFirst;
return Some(CmdResult::NeedPoint);
}
None
}
Step::Value { .. } => {
let text = text.trim();
let (name, expression) = match text.split_once('=') {
Some((name, expression)) => (name.trim().to_string(), expression.trim().to_string()),
None => (self.default_name.clone(), text.to_string()),
};
self.build(name, expression)
}
_ => None,
}
}
fn needs_entity_pick(&self) -> bool {
matches!(
self.step,
Step::First
| Step::Object
| Step::Second { .. }
| Step::PointLinePoint
| Step::PointLineLine { .. }
| Step::LineFirst
| Step::LinePoint { .. }
| Step::TwoLinesFirst
| Step::TwoLinesSecond { .. }
)
}
fn entity_pick_accepts_points(&self) -> bool {
true
}
fn typed_point_picks_entity(&self) -> bool {
true
}
fn entity_pick_highlights_hover(&self) -> bool {
true
}
fn inject_before_entity_pick(&self) -> bool {
true
}
fn inject_picked_entity(&mut self, entity: EntityType) {
self.picked_entity = Some(entity);
}
fn on_entity_pick(&mut self, handle: Handle, point: DVec3) -> CmdResult {
match self.step {
// The host resolves the constraint point and hands it back
// through `accept_constraint_point`.
Step::First | Step::Second { .. } | Step::PointLinePoint | Step::LinePoint { .. } => {
CmdResult::CheckConstraintPoint(CoincidentPick {
handle: (!handle.is_null()).then_some(handle),
point,
whole_curve: false,
})
}
Step::PointLineLine { point: first, point_pos } => {
match self.pick_line(handle, point) {
Ok(line) => {
if line.line.entity == first.entity {
return Self::report(Self::SAME_POINT);
}
let (second, second_point) = line.nearest_end(point_pos);
self.step = Step::Location {
first,
second,
first_point: point_pos,
second_point,
direction: Some(line),
};
CmdResult::NeedPoint
}
Err("") => CmdResult::NeedPoint,
Err(message) => Self::report(message),
}
}
Step::LineFirst => match self.pick_line(handle, point) {
Ok(line) => {
self.step = Step::LinePoint { line };
CmdResult::NeedPoint
}
Err("") => CmdResult::NeedPoint,
Err(message) => Self::report(message),
},
Step::TwoLinesFirst => match self.pick_line(handle, point) {
Ok(line) => {
self.step = Step::TwoLinesSecond { line };
CmdResult::NeedPoint
}
Err("") => CmdResult::NeedPoint,
Err(message) => Self::report(message),
},
Step::TwoLinesSecond { line: first_line } => match self.pick_line(handle, point) {
Ok(second_line) => {
if second_line.line == first_line.line {
return Self::report(Self::SAME_POINT);
}
// The host makes the second line parallel first; the
// distance is measured on the solved geometry.
self.step = Step::TwoLinesParallel {
first: first_line,
second: second_line,
pick: point,
};
CmdResult::MakeParallel {
first_line: first_line.line,
first_ends: [first_line.ends[0].0, first_line.ends[1].0],
second_line: second_line.line,
second_ends: [second_line.ends[0].0, second_line.ends[1].0],
}
}
Err("") => CmdResult::NeedPoint,
Err(message) => Self::report(message),
},
Step::TwoLinesParallel { .. } => CmdResult::NeedPoint,
Step::Object => {
if handle.is_null() {
return Self::report(Self::NO_OBJECT);
}
let Some(entity) = self.picked_entity.take() else {
return CmdResult::NeedPoint;
};
let Some((first, second)) = Self::object_ends(&entity, handle, point) else {
return Self::report(&format!(
"Invalid selection for {}. Select a line, polyline segment or arc.",
self.noun()
));
};
let (Some(first_point), Some(second_point)) =
(Self::world(&entity, first), Self::world(&entity, second))
else {
return Self::report(Self::NO_POINT);
};
self.step = Step::Location {
first,
second,
first_point,
second_point,
direction: None,
};
CmdResult::NeedPoint
}
Step::Location { .. } | Step::Value { .. } => self.on_point(point),
}
}
fn accept_parallel_line(&mut self, ends: [(ParametricRef, DVec3); 2]) -> CmdResult {
let Step::TwoLinesParallel { first, second, pick } = self.step else {
return CmdResult::NeedPoint;
};
let Some(dir) = (ends[1].1 - ends[0].1).try_normalize() else {
return Self::report(Self::NO_POINT);
};
let second = LineTarget {
line: second.line,
dir,
ends,
};
// The first line's end nearest the second pick, then the second
// line's end nearest that end.
let (first_ref, first_point) = first.nearest_end(pick);
let (second_ref, second_point) = second.nearest_end(first_point);
self.step = Step::Location {
first: first_ref,
second: second_ref,
first_point,
second_point,
direction: Some(first),
};
CmdResult::NeedPoint
}
fn accept_constraint_point(&mut self, reference: ParametricRef, point: DVec3) -> CmdResult {
match self.step {
Step::First => {
self.step = Step::Second {
first: reference,
first_point: point,
};
CmdResult::NeedPoint
}
Step::Second { first, first_point } => {
if first == reference {
return Self::report(Self::SAME_POINT);
}
self.step = Step::Location {
first,
second: reference,
first_point,
second_point: point,
direction: None,
};
CmdResult::NeedPoint
}
Step::PointLinePoint => {
self.step = Step::PointLineLine {
point: reference,
point_pos: point,
};
CmdResult::NeedPoint
}
Step::LinePoint { line } => {
if line.line.entity == reference.entity {
return Self::report(Self::SAME_POINT);
}
let (first, first_point) = line.nearest_end(point);
self.step = Step::Location {
first,
second: reference,
first_point,
second_point: point,
direction: Some(line),
};
CmdResult::NeedPoint
}
_ => CmdResult::NeedPoint,
}
}
fn on_point(&mut self, point: DVec3) -> CmdResult {
match self.step {
Step::First | Step::Second { .. } | Step::PointLinePoint | Step::LinePoint { .. } => {
CmdResult::CheckConstraintPoint(CoincidentPick {
handle: None,
point,
whole_curve: false,
})
}
Step::Object
| Step::PointLineLine { .. }
| Step::LineFirst
| Step::TwoLinesFirst
| Step::TwoLinesSecond { .. } => Self::report(Self::NO_OBJECT),
Step::Location {
first,
second,
first_point,
second_point,
direction,
} => {
let (kind, axis) = self.decide(first_point, second_point, point, direction);
let measured = Self::measured(kind, first_point, second_point, axis);
self.step = Step::Value {
first,
second,
first_point,
second_point,
location: point,
axis,
kind,
measured,
direction,
};
CmdResult::ReportMeasurement(format!("Dimension text = {measured:.4}"))
}
Step::TwoLinesParallel { .. } | Step::Value { .. } => CmdResult::NeedPoint,
}
}
fn on_enter(&mut self) -> CmdResult {
match self.step {
Step::First => {
self.step = Step::Object;
CmdResult::NeedPoint
}
Step::PointLinePoint => {
self.step = Step::LineFirst;
CmdResult::NeedPoint
}
Step::Value { measured, .. } => self
.build(self.default_name.clone(), measured_expression(measured))
.unwrap_or(CmdResult::Cancel),
_ => CmdResult::Cancel,
}
}
fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel
}
}
/// `DIMCONSTRAINT`: the reference's option front end for the dimensional
/// constraint family; each choice runs the focused command.
pub struct DimConstraintMenuCommand {
/// The option Enter takes: the dimensional constraint used last.
default: &'static str,
}
impl DimConstraintMenuCommand {
pub fn new(default: &'static str) -> Self {
Self { default }
}
fn dispatch(keyword: &str) -> Option<&'static str> {
Some(match keyword {
"L" | "LINEAR" => "DCLINEAR",
"H" | "HORIZONTAL" => "DCHORIZONTAL",
"V" | "VERTICAL" => "DCVERTICAL",
"A" | "ALIGNED" => "DCALIGNED",
"AN" | "ANGULAR" => "DCANGULAR",
"R" | "RADIAL" | "RADIUS" => "DCRADIUS",
"D" | "DIAMETER" => "DCDIAMETER",
"F" | "FORM" => "DCFORM",
"C" | "CONVERT" => "DCCONVERT",
_ => return None,
})
}
fn command_for_label(label: &str) -> &'static str {
match label {
"Linear" => "DCLINEAR",
"Horizontal" => "DCHORIZONTAL",
"Vertical" => "DCVERTICAL",
"ANgular" => "DCANGULAR",
"Radius" => "DCRADIUS",
"Diameter" => "DCDIAMETER",
"Convert" => "DCCONVERT",
_ => "DCALIGNED",
}
}
}
impl CadCommand for DimConstraintMenuCommand {
fn name(&self) -> &'static str {
"DIMCONSTRAINT"
}
fn prompt(&self) -> String {
format!(
"DIMCONSTRAINT Enter a dimensional constraint option [Linear/Horizontal/Vertical/Aligned/ANgular/Radius/Diameter/Form/Convert] <{}>:",
self.default
)
}
fn options(&self) -> Vec<CmdOption> {
vec![
CmdOption::new("Linear", "L"),
CmdOption::new("Horizontal", "H"),
CmdOption::new("Vertical", "V"),
CmdOption::new("Aligned", "A"),
CmdOption::new("ANgular", "AN"),
CmdOption::new("Radius", "R"),
CmdOption::new("Diameter", "D"),
CmdOption::new("Form", "F"),
CmdOption::new("Convert", "C"),
]
}
fn wants_text_input(&self) -> bool {
true
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let keyword = text.trim().trim_start_matches('_').to_ascii_uppercase();
Self::dispatch(&keyword).map(|command| CmdResult::Dispatch(command.to_string()))
}
fn on_point(&mut self, _point: DVec3) -> CmdResult {
CmdResult::NeedPoint
}
fn on_enter(&mut self) -> CmdResult {
CmdResult::Dispatch(Self::command_for_label(self.default).to_string())
}
fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel
}
}
/// `DCFORM`: `Enter constraint form [Annotational/Dynamic] <current>:`;
/// the host records the answer and continues into DIMCONSTRAINT's option
/// prompt, as the reference does.
pub struct ConstraintFormCommand {
annotational: bool,
}
impl ConstraintFormCommand {
pub fn new(annotational: bool) -> Self {
Self { annotational }
}
fn current(&self) -> &'static str {
if self.annotational {
"Annotational"
} else {
"Dynamic"
}
}
}
impl CadCommand for ConstraintFormCommand {
fn name(&self) -> &'static str {
"DCFORM"
}
fn prompt(&self) -> String {
format!(
"DCFORM Enter constraint form [Annotational/Dynamic] <{}>:",
self.current()
)
}
fn options(&self) -> Vec<CmdOption> {
vec![
CmdOption::new("Annotational", "A"),
CmdOption::new("Dynamic", "D"),
]
}
fn wants_text_input(&self) -> bool {
true
}
fn point_step_accepts_keywords(&self) -> bool {
true
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let keyword = text.trim().trim_start_matches('_').to_ascii_uppercase();
let form = match keyword.as_str() {
"A" | "ANNOTATIONAL" => "Annotational",
"D" | "DYNAMIC" => "Dynamic",
_ => return None,
};
Some(CmdResult::Dispatch(format!("DCFORM_SET {form}")))
}
fn on_point(&mut self, _point: DVec3) -> CmdResult {
CmdResult::NeedPoint
}
fn on_enter(&mut self) -> CmdResult {
CmdResult::Dispatch(format!("DCFORM_SET {}", self.current()))
}
fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel
}
}
/// The value prompt a double-clicked dynamic dimension opens, standing in
/// for the reference's in-place editor; `name=expression` also renames.
pub struct DimensionValueCommand {
name: String,
current: String,
}
impl DimensionValueCommand {
pub fn new(name: String, current: String) -> Self {
Self { name, current }
}
}
impl CadCommand for DimensionValueCommand {
fn name(&self) -> &'static str {
"DCVALUE"
}
fn prompt(&self) -> String {
format!(
"DCVALUE Enter value or name and value <{}={}>:",
self.name, self.current
)
}
fn wants_text_input(&self) -> bool {
true
}
fn point_step_accepts_keywords(&self) -> bool {
true
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let text = text.trim();
(!text.is_empty()).then(|| CmdResult::Dispatch(format!("DCVALUE {} {text}", self.name)))
}
fn on_point(&mut self, _point: DVec3) -> CmdResult {
CmdResult::NeedPoint
}
fn on_enter(&mut self) -> CmdResult {
CmdResult::Cancel
}
fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel
}
}
#[cfg(test)]
mod tests {
use super::*;
use acadrust::entities::Line;
use acadrust::types::Vector3;
#[test]
fn object_pick_takes_the_line_ends_then_asks_for_the_location() {
let mut command = DimConstraintCommand::new(DimConstraintAxis::Linear, "d1".into());
assert!(matches!(command.on_enter(), CmdResult::NeedPoint));
command.inject_picked_entity(EntityType::Line(Line::from_points(
Vector3::ZERO,
Vector3::new(100.0, 50.0, 0.0),
)));
assert!(matches!(
command.on_entity_pick(Handle::new(7), DVec3::new(50.0, 25.0, 0.0)),
CmdResult::NeedPoint
));
// Below the pair: horizontal, so the measured value is the X span.
let CmdResult::ReportMeasurement(text) = command.on_point(DVec3::new(50.0, -30.0, 0.0))
else {
panic!("the location must report the dimension text");
};
assert_eq!(text, "Dimension text = 100.0000");
assert_eq!(
command.prompt(),
"DCLINEAR Enter value or name and value <d1=100>:"
);
let CmdResult::AddDimensionalConstraint {
kind, name, expression, ..
} = command.on_text_input("w=d1*2").expect("a value builds the constraint")
else {
panic!("the value must build the constraint");
};
assert_eq!(kind, ConstraintKind::DistanceX);
assert_eq!(name, "w");
assert_eq!(expression, "d1*2");
}
#[test]
fn a_location_beside_the_pair_measures_vertically() {
let mut command = DimConstraintCommand::new(DimConstraintAxis::Linear, "d1".into());
let first = ParametricRef::point(Handle::new(7), 0);
let second = ParametricRef::point(Handle::new(7), 1);
command.accept_constraint_point(first, DVec3::ZERO);
command.accept_constraint_point(second, DVec3::new(100.0, 50.0, 0.0));
let CmdResult::ReportMeasurement(text) = command.on_point(DVec3::new(140.0, 25.0, 0.0))
else {
panic!("the location must report the dimension text");
};
assert_eq!(text, "Dimension text = 50.0000");
}
}
+5 -1
View File
@@ -31,8 +31,12 @@ impl EqualConstraintCommand {
pub const NO_OBJECT: &'static str = "No object found.";
pub const INVALID_OBJECT: &'static str =
"Invalid selection for Equal. Select a line, polyline segment, circle or arc.";
pub const INVALID_LENGTH_OBJECT: &'static str =
"Invalid selection for Equal. Select a line segment, polyline segment, text, MText, major or minor axis of ellipse or elliptical arc.";
pub const INVALID_RADIUS_OBJECT: &'static str =
"Invalid selection for Equal. Select a circle or arc.";
pub const SAME_OBJECT: &'static str =
"The object or point is already selected. Select a different object or constraint point.";
"The object or point is already selected. Select a different object or constraint point.";
pub fn new() -> Self {
Self {
+4
View File
@@ -104,6 +104,10 @@ impl CadCommand for FixConstraintCommand {
true
}
fn typed_point_picks_entity(&self) -> bool {
matches!(self.step, Step::Object)
}
fn entity_pick_highlights_hover(&self) -> bool {
true
}
+5 -1
View File
@@ -200,7 +200,7 @@ impl HorizontalConstraintCommand {
if first.handle == second.handle && (first.point - second.point).length() <= 1.0e-9 {
self.step = Step::SecondPoint(first);
return CmdResult::ReportError(
"The object or point is already selected. Select a different object or constraint point."
"The object or point is already selected. Select a different object or constraint point."
.to_string(),
);
}
@@ -269,6 +269,10 @@ impl CadCommand for HorizontalConstraintCommand {
true
}
fn typed_point_picks_entity(&self) -> bool {
matches!(self.step, Step::ObjectOrTwoPoints)
}
fn entity_pick_highlights_hover(&self) -> bool {
true
}
+8 -1
View File
@@ -4,6 +4,8 @@
mod coincident;
mod concentric;
mod dim_constraint;
mod parameters_cli;
mod constraint_bar;
mod equal_distance;
#[path = "equal.rs"]
@@ -25,6 +27,11 @@ mod tools;
mod value;
pub use coincident::{coincident_tool, CoincidentConstraintCommand};
pub use concentric::ConcentricConstraintCommand;
pub use dim_constraint::{
ConstraintFormCommand, DimConstraintAxis, DimConstraintCommand, DimConstraintMenuCommand,
DimensionValueCommand,
};
pub use parameters_cli::ParametersCliCommand;
pub use constraint_bar::ConstraintBarOptionCommand;
pub use equal_command::EqualConstraintCommand;
pub use equal_distance::{equal_distance_tool, EqualDistanceConstraintCommand};
@@ -156,7 +163,7 @@ inventory::submit!(crate::command::CommandRegistration {
names: &[
"AUTOCONSTRAIN", "CONSTRAINTSETTINGS", "GCSMOOTH", "GCSHOW", "GCHIDE", "GCRESET",
"GCSHOWALL", "GCHIDEALL", "DCSHOW", "DCHIDE", "DCSHOWALL", "DCHIDEALL",
"DCCONVERT", "DELCONSTRAINT",
"DCCONVERT", "DELCONSTRAINT", "DIMCONSTRAINT", "DCFORM", "-PARAMETERS",
]
});
+146
View File
@@ -0,0 +1,146 @@
use glam::DVec3;
use crate::command::{CadCommand, CmdOption, CmdResult};
/// `-PARAMETERS`: the reference's command-line parameter manager. One run
/// performs one operation; the host answers the `LIST`, `NEW`, `EDIT`,
/// `SET`, `RENAME` and `DELETE` dispatches.
pub struct ParametersCliCommand {
step: Step,
}
#[derive(Clone)]
enum Step {
/// `Enter a parameter option [New/Edit/Rename/Delete/?]:`
Option,
/// `Enter name for new user parameter:`
NewName,
/// `Enter expression:`
NewExpression { name: String },
/// `Enter parameter name:`
EditName,
/// `Enter expression:` after the host printed the old expression.
EditExpression { name: String },
/// `Enter old parameter name:`
RenameOld,
/// `Enter new parameter name:`
RenameNew { old: String },
/// `Enter parameter name to delete:`
DeleteName,
}
impl ParametersCliCommand {
pub fn new() -> Self {
Self { step: Step::Option }
}
/// The expression prompt the host opens after printing the old expression.
pub fn edit_expression(name: String) -> Self {
Self {
step: Step::EditExpression { name },
}
}
}
impl Default for ParametersCliCommand {
fn default() -> Self {
Self::new()
}
}
impl CadCommand for ParametersCliCommand {
fn name(&self) -> &'static str {
"-PARAMETERS"
}
fn prompt(&self) -> String {
let text = match &self.step {
Step::Option => "Enter a parameter option [New/Edit/Rename/Delete/?]:",
Step::NewName => "Enter name for new user parameter:",
Step::NewExpression { .. } | Step::EditExpression { .. } => "Enter expression:",
Step::EditName => "Enter parameter name:",
Step::RenameOld => "Enter old parameter name:",
Step::RenameNew { .. } => "Enter new parameter name:",
Step::DeleteName => "Enter parameter name to delete:",
};
format!("-PARAMETERS {text}")
}
fn options(&self) -> Vec<CmdOption> {
match self.step {
Step::Option => vec![
CmdOption::new("New", "N"),
CmdOption::new("Edit", "E"),
CmdOption::new("Rename", "R"),
CmdOption::new("Delete", "D"),
CmdOption::new("?", "?"),
],
_ => Vec::new(),
}
}
fn wants_text_input(&self) -> bool {
true
}
fn point_step_accepts_keywords(&self) -> bool {
true
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let text = text.trim();
if text.is_empty() {
return None;
}
match self.step.clone() {
Step::Option => {
let keyword = text.trim_start_matches('_').to_ascii_uppercase();
let next = match keyword.as_str() {
"N" | "NEW" => Step::NewName,
"E" | "EDIT" => Step::EditName,
"R" | "RENAME" => Step::RenameOld,
"D" | "DELETE" => Step::DeleteName,
"?" => return Some(CmdResult::Dispatch("-PARAMETERS LIST".to_string())),
_ => return None,
};
self.step = next;
Some(CmdResult::NeedPoint)
}
Step::NewName => {
self.step = Step::NewExpression {
name: text.to_string(),
};
Some(CmdResult::NeedPoint)
}
Step::NewExpression { name } => {
Some(CmdResult::Dispatch(format!("-PARAMETERS NEW {name} {text}")))
}
Step::EditName => Some(CmdResult::Dispatch(format!("-PARAMETERS EDIT {text}"))),
Step::EditExpression { name } => {
Some(CmdResult::Dispatch(format!("-PARAMETERS SET {name} {text}")))
}
Step::RenameOld => {
self.step = Step::RenameNew {
old: text.to_string(),
};
Some(CmdResult::NeedPoint)
}
Step::RenameNew { old } => {
Some(CmdResult::Dispatch(format!("-PARAMETERS RENAME {old} {text}")))
}
Step::DeleteName => Some(CmdResult::Dispatch(format!("-PARAMETERS DELETE {text}"))),
}
}
fn on_point(&mut self, _point: DVec3) -> CmdResult {
CmdResult::NeedPoint
}
fn on_enter(&mut self) -> CmdResult {
CmdResult::Cancel
}
fn on_escape(&mut self) -> CmdResult {
CmdResult::Cancel
}
}
+12 -1
View File
@@ -1566,5 +1566,16 @@ impl Scene {
self.camera_generation += 1;
}
pub fn update(&mut self, _dt: Duration) {}
pub fn update(&mut self, _dt: Duration) {
// The glyph atlas grew or re-baked since this scene last built its
// caches (a CJK sheet set bakes thousands of tiles on first sight):
// every quad laid out before that samples the wrong tile — scrambled
// text in the affected blocks / viewports. One structural bump
// re-lays-out all text against the settled atlas.
let atlas_gen = crate::scene::text::sdf_atlas::generation();
if self.last_atlas_generation.get() != atlas_gen {
self.last_atlas_generation.set(atlas_gen);
self.bump_geometry();
}
}
}
+160 -6
View File
@@ -2,7 +2,8 @@
use acadrust::objects::{
Assoc2dConstraintGroup, AssocAction, AssocActionDependency, AssocConstraintNode,
AssocConstraintNodeData, AssocDependency, AssocEvalValue, AssocEvalVariant,
AssocConstraintNodeData, AssocDependency, AssocDimDependencyBody, AssocEvalValue,
AssocEvalVariant,
AssocGeomDependency, AssocNetwork, AssocPersistentSubentId, AssocValueDependency,
AssocVariable, AssociativeData, AssociativeObject, ObjectType,
};
@@ -1216,6 +1217,7 @@ impl Allocator<'_> {
name: &str,
formula: &str,
resolved: f64,
description: &str,
) -> Handle {
if let Some(&handle) = self.variables.get(name) {
if let Some(ObjectType::Associative(object)) = self.document.objects.get_mut(&handle) {
@@ -1243,7 +1245,7 @@ impl Allocator<'_> {
name: name.to_string(),
expression: formula.to_string(),
evaluator: "AcDbCalc:1.0".to_string(),
description: String::new(),
description: description.to_string(),
value: Self::numeric_eval(resolved),
has_cached_value: false,
cached_value: String::new(),
@@ -1291,6 +1293,67 @@ impl Allocator<'_> {
}
handle
}
/// The `AcDbAssocDependency` + `AcDbAssocDimDependencyBody` pair that
/// ties a dimensional constraint to the dynamic dimension showing it.
fn dimension_dependency(
&mut self,
group_handle: Handle,
dimension: Handle,
name: &str,
dependency_id: i32,
) -> Handle {
let handle = self.document.allocate_handle();
let body = self.insert_associative(
handle,
"ACDBASSOCDIMDEPENDENCYBODY",
"AcDbAssocDimDependencyBody",
AssociativeData::DimDependencyBody(AssocDimDependencyBody {
dependency_body_version: 1,
base_version: 1,
name: name.to_string(),
class_version: 1,
}),
);
self.insert_associative_at(
handle,
group_handle,
"ACDBASSOCDEPENDENCY",
"AcDbAssocDependency",
AssociativeData::Dependency(AssocDependency {
class_version: 2,
is_read_dependency: true,
is_write_dependency: true,
is_attached_to_object: true,
is_delegating_to_owning_action: true,
dependent_on: dimension,
dependency_body: body,
dependency_body_id: dependency_id,
..Default::default()
}),
);
// Earlier materializations' dependencies are gone by now; drop
// their reactor entries before adding this one.
let live: Vec<Handle> = self
.document
.get_entity(dimension)
.map(|entity| {
entity
.common()
.reactors
.iter()
.copied()
.filter(|reactor| self.document.objects.contains_key(reactor))
.collect()
})
.unwrap_or_default();
if let Some(entity) = self.document.get_entity_mut(dimension) {
let reactors = &mut entity.common_mut().reactors;
*reactors = live;
reactors.push(handle);
}
handle
}
}
/// Sets the dependency on whole-geometry nodes. Implicit points retain a
@@ -1355,6 +1418,27 @@ fn set_value_dependency(data: &mut AssocConstraintNodeData, handle: Handle) {
}
}
/// Patches a dimensional-constraint node's `dimension_dependency` field.
fn set_dimension_dependency(data: &mut AssocConstraintNodeData, handle: Handle) {
match data {
AssocConstraintNodeData::Distance {
dimension_dependency,
..
}
| AssocConstraintNodeData::Angle {
dimension_dependency,
..
}
| AssocConstraintNodeData::RadiusDiameter {
dimension_dependency,
..
} => {
*dimension_dependency = handle;
}
_ => {}
}
}
/// Associative classes that must have real class numbers in DWG/DXF.
const ASSOC_CLASSES: &[(&str, &str, i32)] = &[
(
@@ -1366,6 +1450,8 @@ const ASSOC_CLASSES: &[(&str, &str, i32)] = &[
("ACDBASSOCVARIABLE", "AcDbAssocVariable", 45),
("ACDBASSOCGEOMDEPENDENCY", "AcDbAssocGeomDependency", 29),
("ACDBASSOCVALUEDEPENDENCY", "AcDbAssocValueDependency", 29),
("ACDBASSOCDEPENDENCY", "AcDbAssocDependency", 29),
("ACDBASSOCDIMDEPENDENCYBODY", "AcDbAssocDimDependencyBody", 45),
];
fn ensure_associative_classes_registered(document: &mut CadDocument) {
@@ -1798,6 +1884,7 @@ fn driving_value(
variable.expression.clone()
};
if parameters.set(&variable.name, &source).is_ok() {
parameters.set_description(&variable.name, &variable.description);
Some(DrivingValue::Named(variable.name.clone()))
} else {
literal
@@ -1806,6 +1893,19 @@ fn driving_value(
}
}
/// The dynamic dimension an `AcDbAssocDependency` ties to a dimensional
/// constraint node.
fn dependency_dimension(document: &CadDocument, dependency: Handle) -> Option<Handle> {
let ObjectType::Associative(object) = document.objects.get(&dependency)? else {
return None;
};
let AssociativeData::Dependency(dependency) = &object.data else {
return None;
};
let target = dependency.dependent_on;
matches!(document.get_entity(target)?, EntityType::Dimension(_)).then_some(target)
}
fn owning_composite(data: &AssocConstraintNodeData) -> Option<i32> {
match data {
AssocConstraintNodeData::Geometrical { owner_id, .. }
@@ -2250,7 +2350,25 @@ pub(super) fn native_constraint_set(
{
continue;
}
set.add(kind, targets, driving);
let dimension = match &node.data {
AssocConstraintNodeData::Angle {
dimension_dependency,
..
}
| AssocConstraintNodeData::Distance {
dimension_dependency,
..
}
| AssocConstraintNodeData::RadiusDiameter {
dimension_dependency,
..
} => dependency_dimension(document, *dimension_dependency),
_ => None,
};
let id = set.add(kind, targets, driving);
if let Some(dimension) = dimension {
set.dimensions.insert(id, dimension);
}
if let Some(constraint) = set.constraints.last_mut() {
constraint.enabled = enabled;
constraint.rigid_points = rigid_points;
@@ -2429,15 +2547,21 @@ fn materialize_scope(
// Pass 1 (read-only): constraint-node shapes.
let mut needs_value_dependency = Vec::new();
let mut dimension_links = Vec::new();
let (mut nodes, entities, referenced_entities) = {
let mut builder = GroupBuilder::new(document);
for constraint in &set.constraints {
constraint_node(
let node_id = constraint_node(
&mut builder,
document,
constraint,
&mut needs_value_dependency,
);
if let Some((node_id, dimension)) = node_id.zip(set.dimensions.get(&constraint.id)) {
if document.get_entity(*dimension).is_some() {
dimension_links.push((node_id, *dimension));
}
}
}
let GroupBuilder {
nodes,
@@ -2488,6 +2612,7 @@ fn materialize_scope(
variable.action.action_index = index as i32 + 1;
if let Some(parameter) = parameters.get(&variable.name) {
variable.expression = parameter.source.clone();
variable.description = parameters.description(&parameter.name).to_string();
if let Ok(resolved) = parameters.resolve(&parameter.name) {
variable.value = Allocator::numeric_eval(resolved);
}
@@ -2499,7 +2624,13 @@ fn materialize_scope(
if materialize_all_parameters {
for parameter in parameters.iter() {
let resolved = parameters.resolve(&parameter.name).unwrap_or(0.0);
allocator.variable(network_handle, &parameter.name, &parameter.source, resolved);
allocator.variable(
network_handle,
&parameter.name,
&parameter.source,
resolved,
parameters.description(&parameter.name),
);
}
}
@@ -2527,6 +2658,7 @@ fn materialize_scope(
}
let mut group_dependencies = geometry_dependencies;
let mut node_names: FxHashMap<i32, String> = FxHashMap::default();
for (node_id, driving) in needs_value_dependency {
let Some(driving) = driving else { continue };
let Ok(resolved) = driving.resolve(parameters) else {
@@ -2542,7 +2674,14 @@ fn materialize_scope(
}
DrivingValue::Literal(_) => (format!("d{node_id}"), resolved.to_string()),
};
let variable_handle = allocator.variable(network_handle, &name, &formula, resolved);
node_names.insert(node_id, name.clone());
let variable_handle = allocator.variable(
network_handle,
&name,
&formula,
resolved,
parameters.description(&name),
);
let dependency_id = group_dependencies.len() as i32 + 1;
let dep_handle =
allocator.value_dependency(group_handle?, variable_handle, resolved, dependency_id);
@@ -2551,6 +2690,18 @@ fn materialize_scope(
set_value_dependency(&mut node.data, dep_handle);
}
}
for (node_id, dimension) in dimension_links {
let Some(name) = node_names.get(&node_id) else {
continue;
};
let dependency_id = group_dependencies.len() as i32 + 1;
let dep_handle =
allocator.dimension_dependency(group_handle?, dimension, name, dependency_id);
group_dependencies.push(dep_handle);
if let Some(node) = nodes.iter_mut().find(|n| n.node_id == node_id) {
set_dimension_dependency(&mut node.data, dep_handle);
}
}
if let Some(group_handle) = group_handle {
let group = Assoc2dConstraintGroup {
@@ -2674,6 +2825,9 @@ impl Scene {
pub(crate) fn load_parametric_constraints_from_document(&mut self) {
self.parametric_constraints.clear();
// Dynamic dimensions read from the file get their screen size on the
// first tick after the view is known.
self.dynamic_dimension_camera_gen = None;
let owners: Vec<_> = self
.document
.block_records
+3
View File
@@ -1405,6 +1405,9 @@ impl Scene {
}
if self.object_isolation.hides(common.handle)
|| (!include_preview_hidden && self.preview_hidden.contains(&common.handle))
|| self.hidden_dynamic_dimensions.contains(&common.handle)
// A dynamic dimension never plots.
|| (plot_only && self.is_dynamic_dimension(common.handle))
{
return false;
}
+50
View File
@@ -2295,6 +2295,15 @@ pub struct Scene {
has_associative_centers: std::cell::Cell<Option<bool>>,
/// Runtime parametric constraint sets decoded from standard graph scopes.
pub(crate) parametric_constraints: Vec<parametric_constraints::ParametricConstraintSet>,
/// CONSTRAINTNAMEFORMAT: what a dynamic dimension's text shows —
/// 0 the parameter name, 1 the value, 2 `name=value`.
pub constraint_name_format: u8,
/// DYNCONSTRAINTDISPLAY: whether dynamic dimensions draw at all.
pub dynamic_constraint_display: bool,
/// Dynamic dimensions DCHIDE or DYNCONSTRAINTDISPLAY 0 keep off screen.
hidden_dynamic_dimensions: HashSet<Handle>,
/// The camera generation the dynamic dimensions were last scaled for.
dynamic_dimension_camera_gen: Option<u64>,
/// Session-only visibility overrides for constraint glyphs.
hidden_parametric_constraints: HashSet<(
parametric_constraints::ParametricScope,
@@ -2348,6 +2357,11 @@ pub struct Scene {
/// (re)built, otherwise the held value. `build_primitive` reads it right
/// after the call to gate GPU wire re-upload. 0 = none yet.
pub(crate) last_model_wire_gen: std::cell::Cell<u64>,
/// SDF glyph-atlas generation this scene's caches were built against.
/// When the atlas grows or re-bakes, every cached glyph quad (resident
/// sets, block caches, projected viewport copies) addresses the wrong
/// tile; `update` compares against the live generation and rebuilds.
pub(crate) last_atlas_generation: std::cell::Cell<u64>,
/// Interaction-LOD state: `camera_generation` seen on the previous frame and
/// the wall time it last changed. Used to detect "the view is actively being
/// panned / zoomed / orbited" so the expensive per-pixel hatch pass can be
@@ -2625,6 +2639,10 @@ impl Scene {
dependency_index_cache: RefCell::new(None),
associative_hatch_source_cache: RefCell::new(None),
parametric_constraints: Vec::new(),
constraint_name_format: 2,
dynamic_constraint_display: true,
hidden_dynamic_dimensions: HashSet::default(),
dynamic_dimension_camera_gen: None,
hidden_parametric_constraints: HashSet::default(),
shown_parametric_constraints: HashSet::default(),
named_parameters: named_parameters::ParameterTable::new(),
@@ -2638,6 +2656,7 @@ impl Scene {
last_tess_ms: std::cell::Cell::new(0.0),
last_tess_wires: std::cell::Cell::new(0),
last_model_wire_gen: std::cell::Cell::new(0),
last_atlas_generation: std::cell::Cell::new(crate::scene::text::sdf_atlas::generation()),
nav_last_gen: std::cell::Cell::new(0),
nav_changed_at: std::cell::Cell::new(None),
nav_perf_pending: std::cell::Cell::new(None),
@@ -3319,6 +3338,7 @@ impl Scene {
}
}
if !self.parametric_constraints.is_empty() || !self.named_parameters.is_empty() {
self.refresh_dynamic_dimension_texts();
self.sync_native_parametric_graph();
}
if !changes.is_empty() {
@@ -5533,6 +5553,7 @@ impl Scene {
self.object_isolation.hides(handle)
|| self.preview_hidden.contains(&handle)
|| self.command_preview_hidden.contains(&handle)
|| self.hidden_dynamic_dimensions.contains(&handle)
}
/// Replace the command-owned source hide set and refresh only handles whose
@@ -6105,6 +6126,7 @@ impl Scene {
// Build once: full tessellation, no cull, no zoom LOD — the resident
// set is zoom-independent (GPU analytical circles/arcs/ellipses).
let t_tess = iced::time::Instant::now();
let mut atlas_gen = crate::scene::text::sdf_atlas::generation();
let mut wires = self.wires_for_block_culled(
block,
None,
@@ -6115,6 +6137,34 @@ impl Scene {
all_visible,
style_viewport,
);
// Baking a drawing's glyphs for the first time can grow the SDF atlas
// part-way through this pass (a CJK sheet set bakes thousands of
// tiles). Text tessellated before the growth carries UVs that now
// address the wrong tiles — visible as scrambled glyphs — and this
// set is cached by geometry epoch, so nothing would ever re-lay it
// out. The memo guard already folds the generation in, so rebuilding
// once the atlas has settled re-tessellates only the stale text.
for _ in 0..3 {
let now = crate::scene::text::sdf_atlas::generation();
if now == atlas_gen {
break;
}
atlas_gen = now;
// Block definitions tessellated earlier in this pass hold the
// stale quads too, and their cache is keyed by block epoch (which
// has not moved), so drop them or the rebuild would reuse them.
self.block_defn_cache.borrow_mut().clear();
wires = self.wires_for_block_culled(
block,
None,
None,
frozen_layers,
anno_scale_override,
annotation_scale_handle,
all_visible,
style_viewport,
);
}
let perf = crate::perf::enabled();
let t_post = perf.then(iced::time::Instant::now);
// Synthesized nonprint markers (geo-location daisy) live in model space
+21
View File
@@ -474,6 +474,9 @@ pub struct Parameter {
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ParameterTable {
parameters: Vec<Parameter>,
/// The free-text description a parameter carries (the reference's
/// Description column), by name.
descriptions: HashMap<String, String>,
}
impl ParameterTable {
@@ -481,6 +484,23 @@ impl ParameterTable {
Self::default()
}
pub fn description(&self, name: &str) -> &str {
self.descriptions.get(name).map_or("", String::as_str)
}
/// Stores `description` for an existing parameter; an empty text clears it.
pub fn set_description(&mut self, name: &str, description: &str) {
if !self.contains(name) {
return;
}
if description.trim().is_empty() {
self.descriptions.remove(name);
} else {
self.descriptions
.insert(name.to_string(), description.trim().to_string());
}
}
pub fn is_empty(&self) -> bool {
self.parameters.is_empty()
}
@@ -549,6 +569,7 @@ impl ParameterTable {
/// need to invent (a future UI stage may still want to warn before
/// deleting — that's a UI-layer decision, not a data-layer one).
pub fn remove(&mut self, name: &str) -> bool {
self.descriptions.remove(name);
match self.index_of(name) {
Some(i) => {
self.parameters.remove(i);
+562 -13
View File
@@ -351,6 +351,213 @@ pub(crate) fn equal_size_follower(
Some(entity)
}
/// The first free `d1`, `d2`, … name a new dimensional constraint takes.
pub(crate) fn next_dimensional_parameter_name(
table: &super::named_parameters::ParameterTable,
) -> String {
(1..)
.map(|n| format!("d{n}"))
.find(|name| !table.contains(name))
.unwrap_or_else(|| "d1".to_string())
}
/// The constraint-bar label that draws a dynamic dimension's lock mark.
pub const DYNAMIC_DIMENSION_GLYPH: &str = "\u{1F512}";
/// The on-screen text height of a dynamic dimension, in pixels.
pub const DYNAMIC_DIMENSION_TEXT_PX: f32 = 12.0;
/// The layer the reference keeps dynamic dimensions on; an annotational
/// dimensional constraint sits on an ordinary layer instead.
pub(crate) const DYNAMIC_DIMENSION_LAYER: &str = "*ADSK_CONSTRAINTS";
/// The text a dynamic dimension shows: CONSTRAINTNAMEFORMAT 0 = name,
/// 1 = value, 2 = name=value, the value with its trailing zeros dropped
/// (`d9=80`, `d1=111.8034`). A parameter defined by a formula shows the
/// formula behind `fx:` (`fx: len=d1*2`); a reference (driven) constraint's
/// text sits in parentheses; an annotational one keeps dimension precision.
pub(crate) fn dynamic_dimension_text(
name: &str,
value: f64,
format: u8,
reference: bool,
expression: Option<&str>,
annotational: bool,
) -> String {
let formula = expression
.map(str::trim)
.filter(|source| !source.is_empty() && source.parse::<f64>().is_err());
let shown = match formula {
Some(source) => source.to_string(),
// An annotational dimension shows the value at dimension precision
// (`d1=100.0000`), as the reference does.
None if annotational => format!("{value:.4}"),
None => {
let text = format!("{value:.4}");
let text = text.trim_end_matches('0').trim_end_matches('.');
if text.is_empty() || text == "-" {
"0".to_string()
} else {
text.to_string()
}
}
};
let text = match format {
0 => name.to_string(),
1 => shown,
_ => format!("{name}={shown}"),
};
let text = if formula.is_some() {
format!("fx: {text}")
} else {
text
};
if reference {
format!("({text})")
} else {
text
}
}
/// The expression a measured value seeds a parameter with: twelve
/// significant digits with the trailing zeros dropped (`100`,
/// `111.803398875`), as the reference stores it.
pub(crate) fn measured_expression(value: f64) -> String {
if !value.is_finite() {
return "0".to_string();
}
// Solver noise (24.9999999047) reads as the round value it means.
let snapped = (value * 1.0e4).round() / 1.0e4;
let value = if (value - snapped).abs() < 1.0e-6 {
snapped
} else {
value
};
let magnitude = value.abs();
let integer_digits = if magnitude < 1.0 {
1
} else {
magnitude.log10().floor() as i32 + 1
};
let decimals = (12 - integer_digits).clamp(0, 12) as usize;
let text = format!("{value:.decimals$}");
let text = if text.contains('.') {
text.trim_end_matches('0').trim_end_matches('.')
} else {
text.as_str()
};
if text.is_empty() || text == "-" || text == "-0" {
"0".to_string()
} else {
text.to_string()
}
}
/// Moves a dynamic dimension's extension origins to `first`/`second`,
/// keeping its dimension line where it was; true when anything moved.
fn dynamic_dimension_follow_points(
dimension: &mut acadrust::entities::Dimension,
first: Vector3,
second: Vector3,
) -> bool {
use acadrust::entities::Dimension;
let same = |a: Vector3, b: Vector3| (a - b).length_squared() < 1.0e-16;
let to_dvec = |p: Vector3| glam::DVec3::new(p.x, p.y, p.z);
let (current_first, current_second, definition, axis) = match dimension {
Dimension::Aligned(d) => (d.first_point, d.second_point, d.definition_point, None),
Dimension::Linear(d) => (
d.first_point,
d.second_point,
d.definition_point,
Some(glam::DVec3::new(d.rotation.cos(), d.rotation.sin(), 0.0)),
),
_ => return false,
};
if same(current_first, first) && same(current_second, second) {
return false;
}
let text = dimension.base().user_text.clone();
let rebuilt = match axis {
None => crate::modules::annotate::aligned_dim::aligned_dimension_entity(
to_dvec(first),
to_dvec(second),
to_dvec(definition),
text,
),
Some(axis) => crate::modules::annotate::linear_dim::linear_dimension_entity(
to_dvec(first),
to_dvec(second),
to_dvec(definition),
axis,
text,
),
};
let acadrust::EntityType::Dimension(mut rebuilt) = rebuilt else {
return false;
};
// Everything but the geometry stays: identity, layer, color, style, xdata.
let base = dimension.base();
let target = rebuilt.base_mut();
target.common = base.common.clone();
target.style_name = base.style_name.clone();
target.text_rotation = base.text_rotation;
target.attachment_point = base.attachment_point;
target.line_spacing_style = base.line_spacing_style;
target.line_spacing_factor = base.line_spacing_factor;
*dimension = rebuilt;
true
}
/// What stays put when a dimensional constraint's value changes: its first
/// point, and — when the distance runs perpendicular to a line that owns
/// that point (2Lines, line-first Point & line) — the whole line, so the
/// other object moves, as in the reference.
pub(crate) fn dimensional_anchor_refs(
document: &acadrust::CadDocument,
refs: &[ParametricRef],
) -> Vec<ParametricRef> {
let Some(&first) = refs.first() else {
return Vec::new();
};
let mut anchors = vec![first];
if let Some(&line) = refs.get(2).filter(|line| line.entity == first.entity) {
let ends = match line.segment_index() {
Some(index) => {
let index = index as i32;
let end = document
.get_entity(line.entity)
.and_then(|entity| resolve_point(entity, index + 1))
.map_or(0, |_| index + 1);
[
ParametricRef::point(line.entity, index),
ParametricRef::point(line.entity, end),
]
}
None => [
ParametricRef::point(line.entity, 0),
ParametricRef::point(line.entity, 1),
],
};
anchors.extend(ends.into_iter().filter(|end| *end != first));
}
anchors
}
/// The constraint a dynamic dimension shows, with the table its parameter
/// lives in.
pub(crate) fn dynamic_dimension_constraint(
sets: &[ParametricConstraintSet],
handle: Handle,
) -> Option<(&ParametricConstraintSet, &ParametricConstraint)> {
sets.iter().find_map(|set| {
let id = set
.dimensions
.iter()
.find_map(|(id, dimension)| (*dimension == handle).then_some(*id))?;
Some((set, set.get(id)?))
})
}
/// Grabbed points are exact kernel inputs; the solver anchors the remaining
/// endpoint coordinates according to the line's directional constraints.
pub(crate) fn grip_solve_anchor_refs(
@@ -585,6 +792,8 @@ pub struct ParametricConstraintSet {
/// parameter names may legitimately occur in different blocks.
pub(crate) local_parameters: super::named_parameters::ParameterTable,
pub(crate) retained_standard_groups: Vec<Handle>,
/// The dynamic dimension that shows a dimensional constraint, by id.
pub(crate) dimensions: std::collections::HashMap<ConstraintId, Handle>,
/// Cached total remaining degrees of freedom, summed across every
/// independent solve partition in this scope — updated by
/// `parametric_solve::solve_scope` each time this set is resolved. `None`
@@ -611,6 +820,7 @@ impl ParametricConstraintSet {
next_id: 0,
local_parameters: super::named_parameters::ParameterTable::new(),
retained_standard_groups: Vec::new(),
dimensions: std::collections::HashMap::new(),
dof: None,
conflicts: Vec::new(),
}
@@ -702,6 +912,7 @@ impl ParametricConstraintSet {
/// Removes a constraint by id. Returns whether one was actually removed.
pub fn remove(&mut self, id: ConstraintId) -> bool {
self.dimensions.remove(&id);
let before = self.constraints.len();
self.constraints.retain(|c| c.id != id);
self.constraints.len() != before
@@ -1420,6 +1631,117 @@ impl super::Scene {
!self.hidden_parametric_constraints.contains(&(scope, id))
}
/// Rewrites every dynamic dimension's text from its constraint's
/// parameter name and current value, in the CONSTRAINTNAMEFORMAT
/// reading `constraint_name_format` selects, and moves its extension
/// origins to where the solve left the constraint points.
pub(crate) fn refresh_dynamic_dimension_texts(&mut self) {
let format = self.constraint_name_format;
let mut updates: Vec<(Handle, String, Option<(Vector3, Vector3)>)> = Vec::new();
// A reference (driven) constraint's parameter follows the geometry.
let mut followed: Vec<(String, f64)> = Vec::new();
for set in &self.parametric_constraints {
let model_table = set.local_parameters.is_empty();
let table = if model_table {
&self.named_parameters
} else {
&set.local_parameters
};
for (id, dimension) in &set.dimensions {
let Some(constraint) = set.get(*id) else {
continue;
};
let world = |reference: &ParametricRef| {
let entity = self.document.get_entity(reference.entity)?;
resolve_point(entity, reference.marker?)
};
let points = match constraint.refs.as_slice() {
[first, second, ..] => world(first).zip(world(second)),
_ => None,
};
let reference = !constraint.enabled;
let measured = points.map(|(first, second)| {
let delta = second - first;
let axis = match self.document.get_entity(*dimension) {
Some(acadrust::EntityType::Dimension(
acadrust::entities::Dimension::Linear(linear),
)) => Some((linear.rotation.cos(), linear.rotation.sin())),
_ => None,
};
match (constraint.kind, axis) {
(ConstraintKind::DistanceX, _) => delta.x.abs(),
(ConstraintKind::DistanceY, _) => delta.y.abs(),
(ConstraintKind::DistanceDirected, Some((ax, ay))) => {
(delta.x * ax + delta.y * ay).abs()
}
_ => delta.length(),
}
});
let (name, value, source) = match &constraint.driving_param {
Some(DrivingValue::Named(name)) => {
let value = match (reference, measured) {
(true, Some(measured)) => {
if model_table {
followed.push((name.clone(), measured));
}
measured
}
_ => table.resolve(name).unwrap_or(f64::NAN),
};
let source = (!reference)
.then(|| table.get(name).map(|parameter| parameter.source.clone()))
.flatten();
(name.clone(), value, source)
}
Some(DrivingValue::Literal(value)) => (String::new(), *value, None),
None => continue,
};
let annotational = self.dimension_is_annotational(*dimension);
updates.push((
*dimension,
dynamic_dimension_text(
&name,
value,
format,
reference,
source.as_deref(),
annotational,
),
points,
));
}
}
for (name, measured) in followed {
let source = measured_expression(measured);
if self.named_parameters.get(&name).map(|p| p.source.as_str()) != Some(source.as_str()) {
let _ = self.named_parameters.set(&name, &source);
}
}
for (handle, text, points) in updates {
let Some(acadrust::EntityType::Dimension(mut dimension)) =
self.document.get_entity(handle).cloned()
else {
continue;
};
let mut changed = false;
if let Some((first, second)) = points {
if dynamic_dimension_follow_points(&mut dimension, first, second) {
changed = true;
}
}
if dimension.base().user_text.as_deref() != Some(text.as_str()) {
crate::entities::dimension::set_dimension_text_override(
dimension.base_mut(),
Some(text),
);
changed = true;
}
if changed {
self.update_entity(acadrust::EntityType::Dimension(dimension));
}
}
}
pub fn should_display_parametric_constraint(
&self,
scope: ParametricScope,
@@ -1483,9 +1805,163 @@ impl super::Scene {
self.shown_parametric_constraints.remove(&(scope, *id));
}
}
self.refresh_hidden_dynamic_dimensions();
ids.len()
}
/// Makes sure the reference's dynamic dimension layer exists: hidden
/// from the layer lists (the `*` prefix) and never plotted.
pub(crate) fn ensure_dynamic_dimension_layer(&mut self) {
if self.document.layers.contains(DYNAMIC_DIMENSION_LAYER) {
return;
}
let mut layer = acadrust::tables::Layer::new(DYNAMIC_DIMENSION_LAYER);
layer.color = acadrust::types::Color::from_index(7);
layer.is_plottable = false;
layer.handle = self.document.allocate_handle();
let _ = self.document.layers.add(layer);
}
/// True for a dynamic dimension of a dimensional constraint (not an
/// annotational one, which is an ordinary plotted dimension).
pub(crate) fn is_dynamic_dimension(&self, handle: Handle) -> bool {
self.parametric_constraints
.iter()
.any(|set| set.dimensions.values().any(|dimension| *dimension == handle))
&& !self.dimension_is_annotational(handle)
}
/// True when a dimensional constraint's dimension uses the annotational
/// form: on an ordinary layer instead of the reference's constraints
/// layer, drawn with its style, plotted, never rescaled to the screen.
pub(crate) fn dimension_is_annotational(&self, handle: Handle) -> bool {
self.document.get_entity(handle).is_some_and(|entity| {
!entity
.common()
.layer
.eq_ignore_ascii_case(DYNAMIC_DIMENSION_LAYER)
})
}
/// The document as written to a file: a dynamic dimension's screen-size
/// overrides are a display matter and stay out of the file.
pub(crate) fn document_for_save(&self) -> acadrust::CadDocument {
use crate::entities::dim_override as ov;
let mut document = self.document.clone();
for set in &self.parametric_constraints {
for handle in set.dimensions.values().copied() {
if self.dimension_is_annotational(handle) {
continue;
}
for code in [ov::DIMSCALE, ov::DIMTIH, ov::DIMTOH] {
ov::set(&mut document, handle, code, None);
}
}
}
document
}
/// Gives every dynamic dimension a DIMSCALE override that keeps its
/// text, arrows and offsets at a screen size (the reference draws them
/// at a constant pixel size whatever the zoom). Runs whenever the camera
/// changed; `force` re-applies after a dimension was created or loaded.
pub fn refresh_dynamic_dimension_scales(&mut self, force: bool) {
let Some(wpp) = self.world_per_pixel() else {
return;
};
if !force && self.dynamic_dimension_camera_gen == Some(self.camera_generation) {
return;
}
self.dynamic_dimension_camera_gen = Some(self.camera_generation);
let handles: Vec<Handle> = self
.parametric_constraints
.iter()
.flat_map(|set| set.dimensions.values().copied())
.collect();
let mut changes = Vec::new();
for handle in handles {
let Some(acadrust::EntityType::Dimension(dimension)) = self.document.get_entity(handle)
else {
continue;
};
// An annotational dimension keeps its style's size.
if self.dimension_is_annotational(handle) {
continue;
}
let style_name = dimension.base().style_name.clone();
let text_height = self
.document
.dim_styles
.iter()
.find(|style| {
style.name.eq_ignore_ascii_case(&style_name)
|| (style_name.trim().is_empty()
&& style.name.eq_ignore_ascii_case("Standard"))
})
.map(|style| style.dimtxt)
.filter(|height| *height > 1e-9)
.unwrap_or(0.18);
let scale = f64::from(wpp) * f64::from(DYNAMIC_DIMENSION_TEXT_PX) / text_height;
use crate::entities::dim_override as ov;
let xdata = &dimension.base().common.extended_data;
let current = ov::real(xdata, ov::DIMSCALE);
// The reference draws dynamic dimension text horizontally.
let horizontal = ov::int(xdata, ov::DIMTIH) == Some(1) && ov::int(xdata, ov::DIMTOH) == Some(1);
if current.is_some_and(|value| (value - scale).abs() < 1e-9) && horizontal {
continue;
}
ov::set(
&mut self.document,
handle,
ov::DIMSCALE,
Some(acadrust::xdata::XDataValue::Real(scale)),
);
if !horizontal {
for code in [ov::DIMTIH, ov::DIMTOH] {
ov::set(
&mut self.document,
handle,
code,
Some(acadrust::xdata::XDataValue::Integer16(1)),
);
}
}
changes.push((handle, super::ChangeKind::Modified));
}
if !changes.is_empty() {
self.bump_entities(&changes);
}
}
/// Re-derives which dynamic dimensions stay off screen (DCHIDE, or
/// DYNCONSTRAINTDISPLAY 0 for all of them) and redraws the ones that
/// changed.
pub(crate) fn refresh_hidden_dynamic_dimensions(&mut self) {
let desired: rustc_hash::FxHashSet<Handle> = self
.parametric_constraints
.iter()
.flat_map(|set| {
set.dimensions.iter().filter_map(|(id, dimension)| {
(!self.dimension_is_annotational(*dimension)
&& (!self.dynamic_constraint_display
|| !self.is_parametric_constraint_visible(set.scope, *id)))
.then_some(*dimension)
})
})
.collect();
if desired == self.hidden_dynamic_dimensions {
return;
}
let changes: Vec<_> = self
.hidden_dynamic_dimensions
.symmetric_difference(&desired)
.copied()
.map(|handle| (handle, super::ChangeKind::Modified))
.collect();
self.hidden_dynamic_dimensions = desired;
self.bump_entities(&changes);
}
/// Infers relations already present in the selected geometry.
pub fn inferred_parametric_constraints(
&self,
@@ -1859,9 +2335,11 @@ impl super::Scene {
let cam = self.camera.borrow();
(cam.view_proj_rte(bounds), cam.eye())
};
set.constraints
let mut placements = set
.constraints
.iter()
.filter(|c| c.enabled)
// A dynamic dimension is its constraint's whole display.
.filter(|c| c.enabled && !set.dimensions.contains_key(&c.id))
.filter(|c| {
let selected = c
.refs
@@ -1952,7 +2430,76 @@ impl super::Scene {
})
.collect::<Vec<_>>()
})
.collect()
.collect::<Vec<_>>();
// The reference marks an object once for Equal however many
// relations of that kind it carries; the stacked duplicates only
// repeated the same badge.
let mut equal_anchors: Vec<iced::Point> = Vec::new();
placements.retain(|(_, point, _, label, _, _)| {
if label != "=" {
return true;
}
if equal_anchors
.iter()
.any(|seen| (seen.x - point.x).abs() < 0.5 && (seen.y - point.y).abs() < 0.5)
{
return false;
}
equal_anchors.push(*point);
true
});
// A dynamic dimension carries the reference's lock mark at the
// start of its text instead of a constraint bar.
for (id, dimension) in &set.dimensions {
let Some(constraint) = set.get(*id) else {
continue;
};
if !constraint.enabled || self.entity_temporarily_hidden(*dimension) {
continue;
}
let Some(acadrust::EntityType::Dimension(entity)) = self.document.get_entity(*dimension)
else {
continue;
};
let Some((anchor, outward)) = crate::entities::dimension::dynamic_dimension_lock_anchor(
&self.document,
entity,
self.annotation_scale as f64,
) else {
continue;
};
let project = |point: Vector3| {
crate::scene::pick::grip::project_rte(
glam::DVec3::new(point.x, point.y, point.z),
view_rot,
eye,
bounds,
)
};
let (Some(screen), Some(outward_screen)) = (
project(anchor),
project(Vector3::new(
anchor.x + outward.x,
anchor.y + outward.y,
anchor.z + outward.z,
)),
) else {
continue;
};
let direction = (outward_screen - screen).normalize_or(glam::Vec2::NEG_X);
let point = iced::Point::new(bounds.x + screen.x, bounds.y + screen.y);
if point.x.is_finite() && point.y.is_finite() {
placements.push((
*id,
point,
direction.to_array(),
DYNAMIC_DIMENSION_GLYPH.to_string(),
false,
Vec::new(),
));
}
}
placements
}
/// Hit-tests screen point `p` (same coordinate space as `p_full` in the
@@ -2371,7 +2918,10 @@ mod tests {
}
#[test]
fn arc_grips_drive_center_start_and_end_but_not_midpoint() {
/// The center grip drives the center; the start, end and midpoint grips
/// reshape the arc, so they drive the whole curve and a paired Equal or
/// Symmetric follows the new radius and sweep.
fn arc_grips_drive_center_or_the_whole_arc() {
let handle = h(8);
let arc = acadrust::EntityType::Arc(acadrust::entities::Arc::from_coords(
0.0,
@@ -2386,15 +2936,14 @@ mod tests {
grip_solve_anchor_refs(&arc, handle, 0),
vec![ParametricRef::center(handle)]
);
assert_eq!(
grip_solve_anchor_refs(&arc, handle, 1),
vec![ParametricRef::point(handle, 0)]
);
assert_eq!(
grip_solve_anchor_refs(&arc, handle, 2),
vec![ParametricRef::point(handle, 1)]
);
assert_eq!(grip_solve_anchor_refs(&arc, handle, 3), Vec::new());
for grip in 1..=3 {
assert_eq!(
grip_solve_anchor_refs(&arc, handle, grip),
vec![ParametricRef::whole(handle)],
"grip {grip}"
);
}
assert_eq!(grip_solve_anchor_refs(&arc, handle, 4), Vec::new());
}
#[test]
+109 -18
View File
@@ -993,9 +993,15 @@ fn resolved_target(params: &ParameterTable, constraint: &ParametricConstraint) -
if !value.is_finite() {
return None;
}
// A dimensional distance keeps its sign in the parameter but measures
// its magnitude, as the reference does (`d1=-50` shortens the line to
// 50; `0` folds the points together).
if constraint.kind == ConstraintKind::Distance {
return Some(value.abs());
}
let must_be_positive = matches!(
constraint.kind,
ConstraintKind::Distance | ConstraintKind::Radius | ConstraintKind::Diameter
ConstraintKind::Radius | ConstraintKind::Diameter
);
(!must_be_positive || value > 0.0).then_some(value)
}
@@ -1466,13 +1472,23 @@ fn build_constraint(
let Some(mirror) = whole_line(sys, cache, *m) else {
return Vec::new();
};
// The connecting direction is held perpendicular to the axis as a
// projection onto the axis direction being zero: a polynomial
// residual the solver can follow from any start. The angle form
// (`Perpendicular`) locks its turn side at construction and stalls
// when the pair has to swing through the axis direction — a point
// starting far from its mirror image never arrived.
let across_axis = |sys: &mut System, pa: GPoint, pb: GPoint| -> Rc<dyn Constraint> {
let zero = sys.add_param(0.0, true);
Rc::new(ProjectedDistanceAlongLine::new(pa, pb, zero, mirror, false))
};
if let (Some(pa), Some(pb)) =
(point_ref(sys, cache, *a), point_ref(sys, cache, *b))
{
let pair = GLine { p1: pa, p2: pb };
return vec![
Rc::new(MidpointOnLine::new(pair, mirror)),
Rc::new(PerpendicularConstraint::new(sys.store(), pair, mirror)),
across_axis(sys, pa, pb),
];
}
let (Some(first), Some(second)) = (
@@ -1500,11 +1516,7 @@ fn build_constraint(
};
vec![
Rc::new(MidpointOnLine::new(centers, mirror)),
Rc::new(PerpendicularConstraint::new(
sys.store(),
centers,
mirror,
)),
across_axis(sys, first.center, second.center),
Rc::new(Equal::new(second.rad, first.rad, 1.0)),
]
}
@@ -1523,11 +1535,7 @@ fn build_constraint(
};
vec![
Rc::new(MidpointOnLine::new(centers, mirror)),
Rc::new(PerpendicularConstraint::new(
sys.store(),
centers,
mirror,
)),
across_axis(sys, first.center, second.center),
Rc::new(SymmetricLineDirections::new(
sys.store(),
first_axis,
@@ -1590,6 +1598,14 @@ fn build_constraint(
let Some(resolved) = resolved_target(params, c) else {
return Vec::new();
};
// A zero distance folds the points together; the distance
// residual has no gradient there, the coordinate equalities do.
if resolved.abs() <= f64::EPSILON {
return vec![
Rc::new(Equal::new(pa.x, pb.x, 1.0)),
Rc::new(Equal::new(pa.y, pb.y, 1.0)),
];
}
let target = sys.add_param(resolved, true);
vec![Rc::new(P2PDistance::new(pa, pb, target))]
}
@@ -1611,10 +1627,13 @@ fn build_constraint(
distance_direction_type::PARALLEL_TO_LINE
| distance_direction_type::PERPENDICULAR_TO_LINE
) {
if let Some(line) = direction_ref
.first()
.and_then(|reference| whole_line(sys, cache, *reference))
{
if let Some(line) = direction_ref.first().and_then(|reference| {
// A text baseline or an ellipse axis directs the
// distance the same way a line does.
whole_line(sys, cache, *reference).or_else(|| {
directional_line(sys, cache, *reference).map(|(line, _)| line)
})
}) {
let current = {
let store = sys.store();
let delta = [
@@ -2441,7 +2460,32 @@ fn solve_scope(
})
})
.flatten();
let pinned = axis_pin.unwrap_or(*reference);
// A transformed entity whose own end points a driving dimension
// measures keeps only its second constraint point where the transform
// put it; the dimension pulls the first end back to its value (a line
// scaled 2x about its start keeps its d1 by moving that start), as in
// the reference.
let dimension_pin = (reference.marker.is_none() && axis_pin.is_none())
.then(|| {
constraints.iter().find_map(|c| {
(c.enabled
&& c.driving_param.is_some()
&& matches!(
c.kind,
ConstraintKind::Distance
| ConstraintKind::DistanceX
| ConstraintKind::DistanceY
| ConstraintKind::DistanceDirected
)
&& c.refs.len() >= 2
&& c.refs
.iter()
.all(|r| r.entity == reference.entity && r.marker.is_some()))
.then(|| c.refs[1])
})
})
.flatten();
let pinned = axis_pin.or(dimension_pin).unwrap_or(*reference);
// The re-aligned entity keeps the length the transform gave it (a
// scaled vertical line stays scaled), not its pre-edit length.
if axis_pin.is_some() {
@@ -2741,6 +2785,21 @@ fn solve_scope(
constraint.refs.get(2).is_some_and(|axis| axis.entity == *handle)
})
|| equal_followers.iter().any(|follower| follower.entity == *handle)
// A driving dimension between an entity's own ends is its size;
// a retained length would contradict a new value.
|| constraints.iter().any(|c| {
c.enabled
&& c.driving_param.is_some()
&& matches!(
c.kind,
ConstraintKind::Distance
| ConstraintKind::DistanceX
| ConstraintKind::DistanceY
| ConstraintKind::DistanceDirected
)
&& c.refs.len() >= 2
&& c.refs.iter().all(|r| r.entity == *handle && r.marker.is_some())
})
{
continue;
}
@@ -3770,6 +3829,24 @@ impl Scene {
self.parametric_constraints[i].dof = None;
self.parametric_constraints[i].conflicts.clear();
}
// A dimensional constraint set to zero means the collapse.
let zero_collapse: HashSet<Handle> = {
let set = &self.parametric_constraints[i];
let params = if set.local_parameters.is_empty() {
&self.named_parameters
} else {
&set.local_parameters
};
set.constraints
.iter()
.filter(|c| {
c.enabled
&& c.kind == ConstraintKind::Distance
&& resolved_target(params, c).is_some_and(|v| v.abs() <= f64::EPSILON)
})
.flat_map(|c| c.refs.iter().map(|r| r.entity))
.collect()
};
for (handle, new_entity) in solved {
// An edit the constraints can only satisfy by collapsing the
// entity (a rotated line whose start is fixed and direction
@@ -3778,7 +3855,8 @@ impl Scene {
let new_entity = match originals.get(&handle) {
Some(original)
if collapsed_by_solve(&new_entity)
&& !collapsed_by_solve(original.as_ref()) =>
&& !collapsed_by_solve(original.as_ref())
&& !zero_collapse.contains(&handle) =>
{
original.as_ref().clone()
}
@@ -4062,12 +4140,25 @@ mod tests {
Vector3::new(1.0, 0.0, 0.0),
)));
let refs = [ParametricRef::point(line, 0), ParametricRef::point(line, 1)];
// A distance keeps a negative value's sign in the parameter and
// measures its magnitude (`d1=-50`); a radius has no such reading.
assert!(scene
.validate_parametric_constraint(
ConstraintKind::Distance,
&refs,
Some(&DrivingValue::Literal(-1.0)),
)
.is_ok());
let circle = scene.add_entity(EntityType::Circle(acadrust::entities::Circle::from_center_radius(
Vector3::new(0.0, 0.0, 0.0),
1.0,
)));
assert!(scene
.validate_parametric_constraint(
ConstraintKind::Radius,
&[ParametricRef::whole(circle)],
Some(&DrivingValue::Literal(-1.0)),
)
.is_err());
assert!(scene
.validate_parametric_constraint(
+16
View File
@@ -321,6 +321,22 @@ impl Scene {
}
let mut out = wire.clone();
out.points = clipped;
// A wide polyline's band width is a geometric width in model
// units; the points were just projected into paper units, so
// the band (and any per-point taper) must follow the same
// scale or the PDF / print exporter strokes a 15-unit bus bar
// as 15 mm of paper. Negative values are fixed pixel widths
// and zero means "use the lineweight" — both stay as they are.
if out.world_width > 0.0 {
out.world_width = wire.world_width * scale;
if wire.taper_widths.len() == out.points.len() {
out.taper_widths = wire.taper_widths.iter().map(|w| w * scale).collect();
} else {
// Clipping changed the vertex count, so the per-point
// widths no longer line up; fall back to a constant band.
out.taper_widths.clear();
}
}
if let Some(source_length) = source_length {
for station in &mut clipped_stations {
*station *= scale;
+62 -2
View File
@@ -47,12 +47,37 @@ pub enum Face {
/// Space advance (9-unit).
word: f32,
},
/// A face paired with an Asian big font (`chineset.shx`, `hztxt.shx`, …):
/// double-byte characters come from the big font, everything else from
/// `primary` — exactly how a STYLE with a "Big Font" behaves in AutoCAD.
WithBig {
primary: Box<Face>,
/// Absolute path of the `AutoCAD-86 bigfont 1.0` file.
big: String,
},
}
/// Separator between a style's primary font name and its big-font path in
/// the single `font_name` string the text pipeline passes around (see
/// [`Face::with_big_font`]); a control character no font name contains.
pub const BIG_FONT_SEP: char = '\u{1}';
impl Face {
/// Resolve a style's font name to a concrete face. Embedded stroke fonts
/// take priority; only otherwise-unknown names try the system fonts.
pub fn resolve(font_name: &str) -> Face {
// `primary<SEP>big` pairs a big font with the primary face.
if let Some((primary, big)) = font_name.split_once(BIG_FONT_SEP) {
let face = Face::resolve(primary);
return if !big.is_empty() && shx::is_bigfont(big) {
Face::WithBig {
primary: Box::new(face),
big: big.to_string(),
}
} else {
face
};
}
// A resolvable on-disk .SHX font renders its real stroke glyphs; the
// LFF substitutes only cover names we can't load.
if font_name.to_ascii_lowercase().ends_with(".shx")
@@ -87,6 +112,13 @@ impl Face {
Face::Lff(lff::get_font(font_name))
}
/// The `font_name` string that pairs `primary` with the big font at
/// `big_path` for [`Face::resolve`]. Every text entry point takes a font
/// name, so the pairing rides along in that string.
pub fn with_big_font(primary: &str, big_path: &str) -> String {
format!("{primary}{BIG_FONT_SEP}{big_path}")
}
/// Look up a glyph. A stroke (LFF) font uses its own glyphs; anything it
/// lacks falls back to a system TrueType font chosen by cosmic-text (filled
/// outline, so covers scripts no stroke font provides). The TTF path mirrors
@@ -103,6 +135,14 @@ impl Face {
Face::Shx { path, .. } => shx::font_glyph(path, ch as u16)
.map(GlyphRef::Owned)
.or_else(|| ttf_glyph::fallback_glyph(ch).map(GlyphRef::Owned)),
Face::WithBig { primary, big } => {
if ttf_glyph::is_full_width(ch) {
if let Some(g) = shx::bigfont_glyph(big, ch) {
return Some(GlyphRef::Owned(g));
}
}
primary.glyph(ch)
}
}
}
@@ -114,6 +154,7 @@ impl Face {
Face::Ttf { .. } => 0.0,
// SHX advances already include the trailing gap by design.
Face::Shx { .. } => 0.0,
Face::WithBig { primary, .. } => primary.letter_spacing(),
}
}
@@ -122,6 +163,7 @@ impl Face {
match self {
Face::Lff(f) => f.word_spacing,
Face::Ttf { word, .. } | Face::Shx { word, .. } => *word,
Face::WithBig { primary, .. } => primary.word_spacing(),
}
}
@@ -132,6 +174,7 @@ impl Face {
match self {
Face::Ttf { family, .. } => Some(family),
Face::Lff(_) | Face::Shx { .. } => None,
Face::WithBig { primary, .. } => primary.ttf_family(),
}
}
@@ -142,6 +185,7 @@ impl Face {
Face::Lff(f) => f.line_spacing,
Face::Ttf { .. } => 1.0,
Face::Shx { line, .. } => *line,
Face::WithBig { primary, .. } => primary.line_spacing(),
}
}
}
@@ -236,11 +280,27 @@ mod tests {
// they should resolve to Ttf. If they aren't installed, Lff fallback is accepted.
eprintln!("Font {} resolved to Lff (probably not installed)", test_name);
}
Face::Shx { .. } => {
// A system TTF name never resolves to an SHX shape font.
Face::Shx { .. } | Face::WithBig { .. } => {
// A system TTF name never resolves to an SHX shape font
// (nor gains a big font without the pairing separator).
eprintln!("Font {} resolved to Shx (unexpected)", test_name);
}
}
}
}
#[test]
fn big_font_pairing_routes_ideographs_and_leaves_latin_alone() {
// Without a real big-font file the pairing is dropped: the primary
// face is used as-is.
assert!(matches!(
Face::resolve(&Face::with_big_font("txt", "C:/nowhere/chineset.shx")),
Face::Lff(_)
));
// A stroke face still renders Latin from its own glyphs when paired.
let face = Face::resolve("txt");
assert!(face.glyph('A').is_some());
}
}
+251 -21
View File
@@ -50,7 +50,20 @@ const DIRS: [[f64; 2]; 16] = [
[1.0, -0.5],
];
/// Which `AutoCAD-86` container a file is; decides how glyphs are keyed and
/// how a `7` (subshape) opcode is encoded.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum ShxKind {
/// `shapes 1.0/1.1`: keyed by 8-bit code (or shape number).
Shapes,
/// `unifont 1.0`: keyed by Unicode code point.
Unifont,
/// `bigfont 1.0`: keyed by a double-byte legacy code (Big5, GBK, …).
Bigfont,
}
struct ShxFile {
kind: ShxKind,
/// shape number → (name, spec bytes)
shapes: HashMap<u16, (String, Vec<u8>)>,
}
@@ -81,39 +94,160 @@ fn load_file(path: &str) -> Option<Arc<ShxFile>> {
fn parse_file(path: &str) -> Option<ShxFile> {
let bytes = std::fs::read(path).ok()?;
// Header line: `AutoCAD-86 shapes 1.0`/`1.1` + CR LF SUB. Reject fonts.
// Header line: `AutoCAD-86 <kind> 1.x` + CR LF SUB.
let head_end = bytes.iter().position(|&b| b == 0x1A)?;
let header = String::from_utf8_lossy(&bytes[..head_end]);
if !header.contains("shapes") {
let kind = if header.contains("shapes") {
ShxKind::Shapes
} else if header.contains("unifont") {
ShxKind::Unifont
} else if header.contains("bigfont") {
ShxKind::Bigfont
} else {
return None;
}
};
let mut p = head_end + 1;
let rd_u16 = |p: &mut usize| -> Option<u16> {
let v = u16::from_le_bytes([*bytes.get(*p)?, *bytes.get(*p + 1)?]);
*p += 2;
Some(v)
};
let _first = rd_u16(&mut p)?;
let _last = rd_u16(&mut p)?;
let count = rd_u16(&mut p)? as usize;
let mut dir: Vec<(u16, usize)> = Vec::with_capacity(count);
for _ in 0..count {
let num = rd_u16(&mut p)?;
let len = rd_u16(&mut p)? as usize;
dir.push((num, len));
}
let mut shapes = HashMap::new();
for (num, len) in dir {
let end = p.checked_add(len)?.min(bytes.len());
let blob = &bytes[p..end];
p = end;
// Name = leading NUL-terminated ASCII, spec follows.
let rd_u32 = |p: &mut usize| -> Option<u32> {
let v = u32::from_le_bytes([
*bytes.get(*p)?,
*bytes.get(*p + 1)?,
*bytes.get(*p + 2)?,
*bytes.get(*p + 3)?,
]);
*p += 4;
Some(v)
};
// Glyph data = leading NUL-terminated ASCII name, spec follows.
let split_named = |blob: &[u8]| -> (String, Vec<u8>) {
let name_end = blob.iter().position(|&b| b == 0).unwrap_or(0);
let name = String::from_utf8_lossy(&blob[..name_end]).into_owned();
let spec = blob.get(name_end + 1..).unwrap_or(&[]).to_vec();
shapes.insert(num, (name, spec));
(name, spec)
};
let mut shapes = HashMap::new();
match kind {
ShxKind::Shapes => {
let _first = rd_u16(&mut p)?;
let _last = rd_u16(&mut p)?;
let count = rd_u16(&mut p)? as usize;
let mut dir: Vec<(u16, usize)> = Vec::with_capacity(count);
for _ in 0..count {
let num = rd_u16(&mut p)?;
let len = rd_u16(&mut p)? as usize;
dir.push((num, len));
}
for (num, len) in dir {
let end = p.checked_add(len)?.min(bytes.len());
let blob = &bytes[p..end];
p = end;
shapes.insert(num, split_named(blob));
}
}
ShxKind::Unifont => {
// u32 glyph count (including the font header), then the header
// entry (u16 length + name NUL above below modes encoding
// embedded 0), then `count - 1` × (u16 code, u16 length, data).
let count = rd_u32(&mut p)? as usize;
let head_len = rd_u16(&mut p)? as usize;
let end = p.checked_add(head_len)?.min(bytes.len());
shapes.insert(0, split_named(&bytes[p..end]));
p = end;
for _ in 1..count {
let Some(code) = rd_u16(&mut p) else { break };
let Some(len) = rd_u16(&mut p) else { break };
let end = p.checked_add(len as usize)?.min(bytes.len());
shapes.insert(code, split_named(&bytes[p..end]));
p = end;
}
}
ShxKind::Bigfont => {
// u16 bytes per table entry (8), u16 table entries, u16 range
// count, ranges × (u16 lo, u16 hi) of lead bytes, then the entry
// table — a sparse hash table of (u16 code, u16 length, u32 file
// offset); empty slots are all zero. Shape 0 (name NUL above
// below modes) is the font header.
let entry_size = rd_u16(&mut p)? as usize;
let entries = rd_u16(&mut p)? as usize;
let ranges = rd_u16(&mut p)? as usize;
p = p.checked_add(ranges * 4)?;
if entry_size < 8 {
return None;
}
for i in 0..entries {
let mut q = p.checked_add(i * entry_size)?;
let Some(code) = rd_u16(&mut q) else { break };
let Some(len) = rd_u16(&mut q) else { break };
let Some(off) = rd_u32(&mut q) else { break };
if off == 0 && len == 0 {
continue;
}
let start = off as usize;
let end = start.saturating_add(len as usize).min(bytes.len());
if start >= end {
continue;
}
shapes.insert(code, split_named(&bytes[start..end]));
}
}
}
Some(ShxFile { shapes })
Some(ShxFile { kind, shapes })
}
/// True when the file is an Asian big font (`AutoCAD-86 bigfont 1.0`).
pub fn is_bigfont(path: &str) -> bool {
load_file(path).is_some_and(|f| f.kind == ShxKind::Bigfont)
}
/// The code pages a big font's double-byte codes may be in, most likely
/// first, guessed from the file name. The codec maps a name to its encoding
/// (`ansi_950` Big5, `ansi_936` GBK, `ansi_932` Shift_JIS, `ansi_949` EUC-KR).
fn bigfont_code_pages(path: &str) -> [&'static str; 4] {
let name = path
.rsplit(['/', '\\'])
.next()
.unwrap_or(path)
.to_ascii_lowercase();
if name.contains("gb") || name.contains("hz") || name.contains("china") {
["ansi_936", "ansi_950", "ansi_932", "ansi_949"]
} else if name.contains("bigfont") || name.contains("extfont") || name.contains("jp") {
["ansi_932", "ansi_950", "ansi_936", "ansi_949"]
} else if name.contains("whg") || name.contains("kor") || name.contains("han") {
["ansi_949", "ansi_950", "ansi_936", "ansi_932"]
} else {
// `chineset.shx` and most Taiwanese fonts.
["ansi_950", "ansi_936", "ansi_932", "ansi_949"]
}
}
/// Look up `ch` in a big font: encode it to the font's double-byte code and
/// fetch that shape, normalised like [`font_glyph`]. `None` when the file is
/// not a big font or has no glyph for the character.
pub fn bigfont_glyph(path: &str, ch: char) -> Option<Arc<crate::scene::text::lff::Glyph>> {
let file = load_file(path)?;
if file.kind != ShxKind::Bigfont {
return None;
}
let mut buf = [0u8; 4];
let s = ch.encode_utf8(&mut buf);
for page in bigfont_code_pages(path) {
let Some(enc) = acadrust::io::dxf::code_page::encoding_from_code_page(page) else {
continue;
};
let (bytes, _, unmappable) = enc.encode(s);
if unmappable || bytes.len() != 2 {
continue;
}
let code = u16::from_be_bytes([bytes[0], bytes[1]]);
if file.shapes.contains_key(&code) {
return font_glyph(path, code);
}
}
None
}
/// Tessellate the shape named `name` (case-insensitive) from the SHX at
@@ -410,8 +544,16 @@ fn interpret(
}
}
7 => {
let sub = *b.get(i).unwrap_or(&0) as u16;
let mut sub = *b.get(i).unwrap_or(&0) as u16;
i += 1;
// A big font references subshapes by a two-byte code:
// `7, 0, hi, lo`.
if file.kind == ShxKind::Bigfont && sub == 0 {
let hi = *b.get(i).unwrap_or(&0) as u16;
let lo = *b.get(i + 1).unwrap_or(&0) as u16;
i += 2;
sub = (hi << 8) | lo;
}
if !skipping && sub != 0 && sub != shape_number {
interpret(file, sub, st, cur, out, depth + 1);
}
@@ -510,3 +652,91 @@ fn interpret(
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Shape bytecode for a glyph: a horizontal 6-unit stroke, then end.
/// (`0x60` = length 6, direction 0.)
const BAR: [u8; 2] = [0x60, 0x00];
fn tmp(name: &str, bytes: &[u8]) -> String {
let dir = std::env::temp_dir().join(format!("ocs-shx-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(name);
std::fs::write(&path, bytes).unwrap();
path.to_string_lossy().into_owned()
}
/// A one-glyph `bigfont 1.0` keyed by the Big5 code of `中` (0xA4A4),
/// laid out like `chineset.shx`: header, 8-byte entries in a sparse
/// table, font header shape 0 (`name NUL above below modes`).
fn synthetic_bigfont() -> Vec<u8> {
let mut b = b"AutoCAD-86 bigfont 1.0\r\n\x1a".to_vec();
let entries: u16 = 4;
b.extend_from_slice(&8u16.to_le_bytes());
b.extend_from_slice(&entries.to_le_bytes());
b.extend_from_slice(&1u16.to_le_bytes()); // one lead-byte range
b.extend_from_slice(&0x80u16.to_le_bytes());
b.extend_from_slice(&0xFFu16.to_le_bytes());
let table_at = b.len();
b.resize(table_at + 8 * entries as usize, 0);
let head_data: Vec<u8> = [b"BIG\x00".as_slice(), &[48u8, 0, 2]].concat();
let glyph_data: Vec<u8> = [b"\x00".as_slice(), &BAR].concat();
let head_off = b.len() as u32;
b.extend_from_slice(&head_data);
let glyph_off = b.len() as u32;
b.extend_from_slice(&glyph_data);
// Slot 0 = shape 0; slot 2 = the glyph (slots 1 and 3 stay empty).
let mut entry = |slot: usize, code: u16, len: u16, off: u32| {
let at = table_at + slot * 8;
b[at..at + 2].copy_from_slice(&code.to_le_bytes());
b[at + 2..at + 4].copy_from_slice(&len.to_le_bytes());
b[at + 4..at + 8].copy_from_slice(&off.to_le_bytes());
};
entry(0, 0, head_data.len() as u16, head_off);
entry(2, 0xA4A4, glyph_data.len() as u16, glyph_off);
b
}
/// A `unifont 1.0` with the font header and one glyph for `A` (U+0041).
fn synthetic_unifont() -> Vec<u8> {
let mut b = b"AutoCAD-86 unifont 1.0\r\n\x1a".to_vec();
b.extend_from_slice(&2u32.to_le_bytes()); // header + 1 glyph
let head: Vec<u8> = [b"UNI\x00".as_slice(), &[6u8, 2, 2, 0, 0, 0]].concat();
b.extend_from_slice(&(head.len() as u16).to_le_bytes());
b.extend_from_slice(&head);
let glyph: Vec<u8> = [b"\x00".as_slice(), &BAR].concat();
b.extend_from_slice(&0x41u16.to_le_bytes());
b.extend_from_slice(&(glyph.len() as u16).to_le_bytes());
b.extend_from_slice(&glyph);
b
}
#[test]
fn bigfont_glyph_is_found_by_big5_code_and_scaled_to_cap_height() {
let path = tmp("cjk.shx", &synthetic_bigfont());
assert!(is_bigfont(&path));
let (above, below) = font_metrics(&path).expect("font header");
assert_eq!((above, below), (48.0, 0.0));
// `中` → Big5 A4A4 → the glyph; `A` is not in a big font.
let g = bigfont_glyph(&path, '中').expect("glyph for 中");
// 6 font units at above = 48 → 6 * 9 / 48 = 1.125 text units.
assert!((g.advance - 1.125).abs() < 1e-4, "advance {}", g.advance);
assert_eq!(g.strokes.len(), 1);
assert!(bigfont_glyph(&path, 'A').is_none());
assert!(bigfont_glyph(&path, '國').is_none(), "unknown code must miss, not alias");
}
#[test]
fn unifont_glyph_is_keyed_by_unicode() {
let path = tmp("uni.shx", &synthetic_unifont());
assert!(!is_bigfont(&path));
assert_eq!(font_metrics(&path), Some((6.0, 2.0)));
let g = font_glyph(&path, 'A' as u16).expect("glyph for A");
assert!((g.advance - 9.0).abs() < 1e-4, "6 units at above 6 → 9: {}", g.advance);
assert!(font_glyph(&path, 'B' as u16).is_none());
}
}
+79 -1
View File
@@ -176,6 +176,42 @@ pub fn glyph(family: &str, ch: char) -> Option<Arc<Glyph>> {
built
}
/// Font-unit → 9-unit-em-box factor: the whole em square maps onto the text
/// height. This is how an SHX big font (`chineset.shx`, `hztxt.shx`, …)
/// sizes its ideographs — a CJK glyph is as tall as the text height and one
/// text height wide — so a TrueType glyph standing in for a missing big-font
/// glyph must use the same box, not the Latin cap height. Cap-height scaling
/// makes ideographs ~1.3–1.5× too big (1 em ≈ 1.3–1.5 cap heights in CJK
/// fonts), so every line of substituted Chinese text ran past its frame.
fn em_scale(face: &ttf_parser::Face) -> f32 {
CAP_UNITS / face.units_per_em().max(1) as f32
}
/// Ideographic / full-width characters: the ones a big font would supply and
/// that sit on an em box rather than the Latin cap height.
pub(crate) fn is_full_width(ch: char) -> bool {
matches!(
ch as u32,
0x1100..=0x11FF // Hangul Jamo
| 0x2E80..=0x2FDF // CJK / Kangxi radicals
| 0x2FF0..=0x303F // ideographic description, CJK symbols & punctuation
| 0x3040..=0x30FF // Hiragana, Katakana
| 0x3100..=0x312F // Bopomofo
| 0x3130..=0x318F // Hangul compatibility Jamo
| 0x3190..=0x31FF // Kanbun, Bopomofo ext., CJK strokes, Katakana ext.
| 0x3200..=0x33FF // enclosed CJK, CJK compatibility
| 0x3400..=0x4DBF // CJK ext. A
| 0x4E00..=0x9FFF // CJK unified ideographs
| 0xA960..=0xA97F // Hangul Jamo ext. A
| 0xAC00..=0xD7FF // Hangul syllables, Jamo ext. B
| 0xF900..=0xFAFF // CJK compatibility ideographs
| 0xFE30..=0xFE4F // CJK compatibility forms
| 0xFF01..=0xFF60 // full-width ASCII variants
| 0xFFE0..=0xFFE6 // full-width symbols
| 0x20000..=0x3FFFF // CJK ext. B–H
)
}
/// Font-unit → 9-unit-cap-height factor for a parsed face. Cap height comes
/// from the OS/2 table; absent, we approximate it as 0.7 × units-per-em.
fn cap_scale(face: &ttf_parser::Face) -> f32 {
@@ -379,7 +415,16 @@ fn build_fallback(ch: char) -> Option<Arc<Glyph>> {
let face_index = fs.db_mut().face(g.font_id).map(|f| f.index).unwrap_or(0);
let font = fs.get_font(g.font_id, g.font_weight)?;
let face = ttf_parser::Face::parse(font.data(), face_index).ok()?;
let k = cap_scale(&face);
// A fallback glyph stands in for a stroke / SHX font that lacks
// the character. For an ideograph that font would have been a
// big font, whose glyphs fill the text height — so size the
// substitute by its em box. Everything else keeps the cap-height
// normalisation that lines it up with the Latin stroke glyphs.
let k = if is_full_width(ch) {
em_scale(&face)
} else {
cap_scale(&face)
};
let gid = ttf_parser::GlyphId(g.glyph_id);
let advance = face.glyph_hor_advance(gid).unwrap_or(0) as f32 * k;
let mut fl = OutlineFlattener::new(k);
@@ -609,6 +654,39 @@ mod tests {
assert!(!run.glyphs.is_empty());
}
#[test]
fn fallback_ideograph_fills_the_text_height_like_a_big_font() {
// An SHX big font draws an ideograph one text height tall and one
// text height wide. The TrueType stand-in must match that box (9
// units), not the ~12–14 units the Latin cap-height normalisation
// gives a CJK em square — that overrun pushed every substituted
// Chinese line past its frame. Tolerated when the machine has no
// CJK font at all.
let Some(g) = fallback_glyph('中') else {
eprintln!("no CJK system font; skipping");
return;
};
assert!(
(g.advance - CAP_UNITS).abs() < 0.6,
"ideograph advance must be about one text height: got {}",
g.advance
);
let ink_h = g
.strokes
.iter()
.flatten()
.map(|p| p[1])
.fold((f32::MAX, f32::MIN), |(lo, hi), y| (lo.min(y), hi.max(y)));
assert!(
ink_h.1 - ink_h.0 <= CAP_UNITS * 1.05,
"ideograph ink must fit the text height: {:?}",
ink_h
);
// Latin fallback keeps the cap-height convention (shares a baseline
// with the stroke glyphs around it).
assert!(!is_full_width('A') && is_full_width('中') && is_full_width(','));
}
#[test]
fn shape_run_positions_glyphs() {
let fams = sysfont::families();
+26 -15
View File
@@ -39,6 +39,11 @@ fn is_fixed_glyph(label: &str) -> bool {
label == "F" || label == crate::scene::parametric_constraints::FIXED_POINT_GLYPH
}
/// The lock mark a dynamic dimension carries (`DYNAMIC_DIMENSION_GLYPH`).
fn is_dynamic_dimension_glyph(label: &str) -> bool {
label == crate::scene::parametric_constraints::DYNAMIC_DIMENSION_GLYPH
}
/// Vertical's two axis-mark labels (`parametric_constraints::vertical_glyph_label`).
fn is_vertical_glyph(label: &str) -> bool {
label == "│" || label == crate::scene::parametric_constraints::VERTICAL_POINTS_GLYPH
@@ -48,7 +53,11 @@ fn constraint_glyph_size(label: &str) -> Size {
if is_compact_coincident_glyph(label) {
return Size::new(COINCIDENT_GLYPH_SIZE, COINCIDENT_GLYPH_SIZE);
}
if label == "G²" || is_fixed_glyph(label) || is_vertical_glyph(label) {
if label == "G²"
|| is_fixed_glyph(label)
|| is_vertical_glyph(label)
|| is_dynamic_dimension_glyph(label)
{
let side = CONSTRAINT_GLYPH_SIZE + CONSTRAINT_GLYPH_PAD_Y * 2.0;
return Size::new(side, side);
}
@@ -109,15 +118,10 @@ fn draw_concentric_constraint_glyph(
fn draw_fixed_constraint_glyph(
frame: &mut canvas::Frame,
center: Point,
text: Color,
lock: Color,
badge: Color,
point_marker: bool,
) {
let lock = if point_marker {
text
} else {
Color::from_rgb8(214, 76, 76)
};
let shackle = canvas::Path::new(|builder| {
let shackle_center = Point::new(center.x, center.y - 1.6);
for step in 0..=12 {
@@ -2114,7 +2118,10 @@ impl canvas::Program<Message> for SelectionCanvas {
})
.into(),
);
frame.fill(&pill, bg);
// A dynamic dimension's lock sits bare on the drawing.
if !is_dynamic_dimension_glyph(label) {
frame.fill(&pill, bg);
}
if *is_selected {
frame.stroke(
&pill,
@@ -2132,14 +2139,18 @@ impl canvas::Program<Message> for SelectionCanvas {
draw_smooth_constraint_glyph(&mut frame, glyph_center, fg);
} else if label == "◎" {
draw_concentric_constraint_glyph(&mut frame, glyph_center, fg);
} else if is_dynamic_dimension_glyph(label) {
draw_fixed_constraint_glyph(&mut frame, glyph_center, fg, bg, false);
} else if is_fixed_glyph(label) {
draw_fixed_constraint_glyph(
&mut frame,
glyph_center,
fg,
bg,
label == crate::scene::parametric_constraints::FIXED_POINT_GLYPH,
);
let point =
label == crate::scene::parametric_constraints::FIXED_POINT_GLYPH;
// The object-mode lock reads red, the point-mode one white.
let lock = if point {
fg
} else {
Color::from_rgb8(214, 76, 76)
};
draw_fixed_constraint_glyph(&mut frame, glyph_center, lock, bg, point);
} else if is_vertical_glyph(label) {
draw_vertical_constraint_glyph(
&mut frame,
+2
View File
@@ -206,6 +206,8 @@ impl LayerPanel {
self.layers = doc_layers
.iter()
// The reference's hidden system layers (`*ADSK_CONSTRAINTS`) stay out.
.filter(|l| !l.name.starts_with('*'))
.map(|l| {
let layer_handle = l.handle;
let vp_frozen = vp_info
@@ -0,0 +1,60 @@
//! A wide polyline seen through a paper-space viewport must carry its band
//! width in PAPER units once projected for plotting. `viewport_content_wires`
//! scales the points and the linetype stations by the viewport scale but used
//! to leave `world_width` in model units, so the PDF / print exporter stroked a
//! 15-unit-wide bus bar at 15 mm of paper (a 1:100 viewport should give
//! 0.15 mm): every single-line diagram plotted as black blobs.
#![cfg(not(target_arch = "wasm32"))]
use acadrust::entities::{LwPolyline, LwVertex, Viewport};
use acadrust::types::{Vector2, Vector3};
use acadrust::EntityType;
use OpenCADStudio::scene::Scene;
#[test]
fn viewport_projection_scales_wide_polyline_band_width() {
// A fresh `Scene::new()` has no paper layout block yet; reuse the small
// fixture the image-export test plots through, which defines `Layout1`.
let mut scene = Scene::new();
scene.document = OpenCADStudio::io::load_bytes(
"embedded-image-print.dxf",
include_bytes!("fixtures/embedded-image-print.dxf").to_vec(),
)
.expect("fixture DXF with a Layout1");
scene.set_current_layout("Model".into());
// Model space: a 15-unit-wide bus bar 100 units long.
let mut bar = LwPolyline::new();
bar.vertices = vec![
LwVertex::new(Vector2::new(0.0, 0.0)),
LwVertex::new(Vector2::new(100.0, 0.0)),
];
bar.constant_width = 15.0;
scene.add_entity(EntityType::LwPolyline(bar));
// Paper space: a 20 x 20 mm viewport showing 200 model units of height,
// i.e. a 1:10 view (0.1 paper units per model unit).
scene.set_current_layout("Layout1".into());
let mut viewport = Viewport::new();
viewport.center = Vector3::new(50.0, 50.0, 0.0);
viewport.width = 20.0;
viewport.height = 20.0;
viewport.view_height = 200.0;
viewport.view_center = Vector3::new(50.0, 0.0, 0.0);
viewport.id = 2;
viewport.status.is_on = true;
scene.add_entity(EntityType::Viewport(viewport));
let (_paper, model) = scene.plot_wire_groups(None);
let band = model
.iter()
.find(|w| w.world_width > 0.0)
.expect("the wide polyline must reach the plot wire set with a band width");
// 15 model units * 0.1 = 1.5 paper units. Before the fix this stayed 15.0.
assert!(
(band.world_width - 1.5).abs() < 1e-3,
"band width must be scaled into paper units: got {}",
band.world_width
);
}