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

+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();