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

# Conflicts:
#	crates/ocs_plugin_api/src/process/v4.rs
#	src/app/update/mod.rs
This commit is contained in:
Felix Riestra committed 2026-09-19 23:58:01 +02:00
commit 8648225ac5
309 files changed
+81166 -4157

No files matched your search

+32 -14
View File
@@ -3,8 +3,8 @@ use crate::t;
use crate::command::EntityTransform;
use crate::entities::common::{
center_grip, edit_angle_prop as edit_angle, edit_prop as edit, parse_f64, ro_prop as ro,
square_grip,
center_grip, edit_angle_prop as edit_angle, edit_prop as edit, format_angle, format_area,
format_length, parse_f64, ro_prop as ro, square_grip,
};
use crate::entities::traits::RenderConvertible;
use crate::scene::convert::acad_to_render::{extrusion_wall_tris, RenderEntity, RenderObject};
@@ -197,24 +197,24 @@ fn properties(arc: &Arc) -> Vec<PropSection> {
vec![PropSection {
title: t!("Geometry").into_owned(),
props: vec![
ro(t!("Start X").as_ref(), "start_x", format!("{sx:.4}")),
ro(t!("Start Y").as_ref(), "start_y", format!("{sy:.4}")),
ro(t!("Start Z").as_ref(), "start_z", format!("{sz:.4}")),
ro(t!("Start X").as_ref(), "start_x", format_length(sx)),
ro(t!("Start Y").as_ref(), "start_y", format_length(sy)),
ro(t!("Start Z").as_ref(), "start_z", format_length(sz)),
edit(t!("Center X").as_ref(), "center_x", cwx),
edit(t!("Center Y").as_ref(), "center_y", cwy),
edit(t!("Center Z").as_ref(), "center_z", cwz),
ro(t!("End X").as_ref(), "end_x", format!("{ex:.4}")),
ro(t!("End Y").as_ref(), "end_y", format!("{ey:.4}")),
ro(t!("End Z").as_ref(), "end_z", format!("{ez:.4}")),
ro(t!("End X").as_ref(), "end_x", format_length(ex)),
ro(t!("End Y").as_ref(), "end_y", format_length(ey)),
ro(t!("End Z").as_ref(), "end_z", format_length(ez)),
edit(t!("Radius").as_ref(), "radius", arc.radius),
edit_angle(t!("Start angle").as_ref(), "start_angle", sa.to_degrees()),
edit_angle(t!("End angle").as_ref(), "end_angle", ea.to_degrees()),
ro(t!("Total angle").as_ref(), "total_angle", format!("{total_angle:.2}")),
ro(t!("Arc length").as_ref(), "arc_length", format!("{arc_length:.4}")),
ro(t!("Area").as_ref(), "area", format!("{area:.4}")),
ro(t!("Normal X").as_ref(), "normal_x", format!("{:.4}", arc.normal.x)),
ro(t!("Normal Y").as_ref(), "normal_y", format!("{:.4}", arc.normal.y)),
ro(t!("Normal Z").as_ref(), "normal_z", format!("{:.4}", arc.normal.z)),
ro(t!("Total angle").as_ref(), "total_angle", format_angle(total_angle.to_radians())),
ro(t!("Arc length").as_ref(), "arc_length", format_length(arc_length)),
ro(t!("Area").as_ref(), "area", format_area(area)),
edit(t!("Normal X").as_ref(), "normal_x", arc.normal.x),
edit(t!("Normal Y").as_ref(), "normal_y", arc.normal.y),
edit(t!("Normal Z").as_ref(), "normal_z", arc.normal.z),
],
}]
}
@@ -244,6 +244,24 @@ fn apply_geom_prop(arc: &mut Arc, field: &str, value: &str) {
"radius" if v > 0.0 => arc.radius = v,
"start_angle" => arc.start_angle = v.to_radians(),
"end_angle" => arc.end_angle = v.to_radians(),
"normal_x" | "normal_y" | "normal_z" => {
let center = arc.center_wcs();
let mut normal = cadkernel::space::Vec3::new(arc.normal.x, arc.normal.y, arc.normal.z);
match field {
"normal_x" => normal.x = v,
"normal_y" => normal.y = v,
"normal_z" => normal.z = v,
_ => {}
}
if let Some(normal) = normal.normalize() {
arc.normal = acadrust::types::Vector3::new(normal.x, normal.y, normal.z);
let (x, y, z) = crate::scene::view::transform::wcs_point_to_ocs(
(center.x, center.y, center.z),
(normal.x, normal.y, normal.z),
);
arc.center = acadrust::types::Vector3::new(x, y, z);
}
}
_ => {}
}
}
+39 -4
View File
@@ -3,7 +3,7 @@ use crate::t;
use crate::command::EntityTransform;
use crate::entities::common::{
center_grip, edit_prop as edit, parse_f64, ro_prop as ro, square_grip,
center_grip, edit_prop as edit, parse_f64, square_grip,
};
use crate::entities::traits::RenderConvertible;
use crate::scene::convert::acad_to_render::{extrusion_wall_tris, RenderEntity, RenderObject};
@@ -169,9 +169,9 @@ fn properties(circle: &Circle) -> Vec<PropSection> {
edit(t!("Diameter").as_ref(), "diameter", r * 2.0),
edit(t!("Circumference").as_ref(), "circumference", 2.0 * PI * r),
edit(t!("Area").as_ref(), "area", PI * r * r),
ro(t!("Normal X").as_ref(), "normal_x", format!("{:.4}", circle.normal.x)),
ro(t!("Normal Y").as_ref(), "normal_y", format!("{:.4}", circle.normal.y)),
ro(t!("Normal Z").as_ref(), "normal_z", format!("{:.4}", circle.normal.z)),
edit(t!("Normal X").as_ref(), "normal_x", circle.normal.x),
edit(t!("Normal Y").as_ref(), "normal_y", circle.normal.y),
edit(t!("Normal Z").as_ref(), "normal_z", circle.normal.z),
],
}]
}
@@ -202,6 +202,28 @@ fn apply_geom_prop(circle: &mut Circle, field: &str, value: &str) {
"diameter" if v > 0.0 => circle.radius = v / 2.0,
"circumference" if v > 0.0 => circle.radius = v / (2.0 * PI),
"area" if v > 0.0 => circle.radius = (v / PI).sqrt(),
"normal_x" | "normal_y" | "normal_z" => {
let center = circle.center_wcs();
let mut normal = cadkernel::space::Vec3::new(
circle.normal.x,
circle.normal.y,
circle.normal.z,
);
match field {
"normal_x" => normal.x = v,
"normal_y" => normal.y = v,
"normal_z" => normal.z = v,
_ => {}
}
if let Some(normal) = normal.normalize() {
circle.normal = acadrust::types::Vector3::new(normal.x, normal.y, normal.z);
let (x, y, z) = crate::scene::view::transform::wcs_point_to_ocs(
(center.x, center.y, center.z),
(normal.x, normal.y, normal.z),
);
circle.center = acadrust::types::Vector3::new(x, y, z);
}
}
_ => {}
}
}
@@ -358,6 +380,19 @@ impl crate::entities::traits::MassPropsCalc for Circle {
mod tests {
use super::*;
#[test]
fn editing_normal_preserves_world_center() {
let mut circle = Circle::from_coords(2.0, 3.0, 4.0, 5.0);
let center = circle.center_wcs();
apply_geom_prop(&mut circle, "normal_x", "1");
assert!((circle.center_wcs() - center).length() < 1.0e-9);
let normal_length =
(circle.normal.x.powi(2) + circle.normal.y.powi(2) + circle.normal.z.powi(2)).sqrt();
assert!((normal_length - 1.0).abs() < 1.0e-12);
}
#[test]
fn quadrant_grips_offer_interactive_radius() {
use crate::entities::traits::Grippable;
+41
View File
@@ -283,6 +283,20 @@ fn surveyor(value_rad: f64, prec: usize) -> String {
/// A leading `-` is a sign, but the `-` inside `5'-9"` is a separator — after
/// feet there is nothing left to subtract from.
pub fn parse_length(text: &str) -> Option<f64> {
parse_length_unbounded(text).filter(|value| typed_value_in_range(*value))
}
/// Largest magnitude a typed length or angle may have. Rust's float parser
/// accepts `inf`, `nan` and overflowing exponents; geometry built from those,
/// or from values whose differences overflow, breaks downstream tolerance and
/// intersection math.
pub const MAX_TYPED_MAGNITUDE: f64 = 1e15;
pub fn typed_value_in_range(value: f64) -> bool {
value.is_finite() && value.abs() <= MAX_TYPED_MAGNITUDE
}
fn parse_length_unbounded(text: &str) -> Option<f64> {
let text = text.trim();
let (sign, rest) = match text.strip_prefix('-') {
Some(rest) => (-1.0, rest.trim_start()),
@@ -339,6 +353,10 @@ fn parse_inches(text: &str) -> Option<f64> {
/// number is read in whatever convention the drawing is set to, so what the
/// readout shows can be typed straight back.
pub fn parse_angle(text: &str) -> Option<f64> {
parse_angle_unbounded(text).filter(|value| typed_value_in_range(*value))
}
fn parse_angle_unbounded(text: &str) -> Option<f64> {
let text = text.trim();
if text.is_empty() {
return None;
@@ -679,11 +697,16 @@ pub fn parse_f64(value: &str) -> Option<f64> {
.ok()
.or_else(|| parse_length(t))
.or_else(|| parse_angle_deg(t))
.filter(|value| typed_value_in_range(*value))
}
/// Parse an angle string the panel displayed via AUNITS back to DEGREES:
/// "30", "30°"/"30d", DMS "30°15'20.5\"", grads "33.33g", radians "0.52r".
pub fn parse_angle_deg(value: &str) -> Option<f64> {
parse_angle_deg_unbounded(value).filter(|value| typed_value_in_range(*value))
}
fn parse_angle_deg_unbounded(value: &str) -> Option<f64> {
let s = value.trim();
if s.is_empty() {
return None;
@@ -1161,6 +1184,24 @@ pub(crate) fn polyline_segment_fill(
}
}
#[cfg(test)]
mod typed_range_tests {
use super::*;
#[test]
fn non_finite_and_huge_typed_values_are_rejected() {
for text in ["inf", "-inf", "nan", "1e400", "1e308"] {
assert_eq!(parse_length(text), None, "length {text}");
assert_eq!(parse_angle(text), None, "angle {text}");
assert_eq!(parse_f64(text), None, "property {text}");
assert_eq!(parse_angle_deg(text), None, "angle degrees {text}");
}
assert_eq!(parse_length("1e16"), None);
assert_eq!(parse_length("1e15"), Some(1e15));
assert_eq!(parse_length("-5'-6\""), Some(-66.0));
}
}
#[cfg(test)]
mod length_format_tests {
use super::*;
+59
View File
@@ -235,6 +235,65 @@ pub fn set(doc: &mut CadDocument, handle: Handle, code: i16, value: Option<XData
write_pairs(doc, handle, kept);
}
/// Set — or, with `value: None`, clear — a single override on an entity that
/// is **not yet in the document** (a command's pending commit), leaving the
/// other overrides intact.
///
/// The document-backed [`set`] additionally registers the `ACAD` APPID; that
/// APPID is present in every drawing OCS writes, and committing the entity
/// goes through the normal add path, so an uncommitted write needs no
/// document. Keeping the override on the entity means it is part of the single
/// committed object and rides the ordinary undo/redo history.
pub fn set_on_entity(entity: &mut EntityType, code: i16, value: Option<XDataValue>) {
let common = entity.common_mut();
let mut kept: Vec<(i16, XDataValue)> = pairs(&common.extended_data)
.into_iter()
.filter(|(c, _)| *c != code)
.collect();
if let Some(v) = value {
kept.push((code, v));
}
let legacy = common.extended_data.get_record("ACAD_DSTYLE").is_some()
&& common.extended_data.get_record("ACAD").is_some_and(
|rec| !matches!(rec.values.first(), Some(XDataValue::String(n)) if n == "DSTYLE"),
);
let app = if legacy { "ACAD_DSTYLE" } else { "ACAD" };
let values = if kept.is_empty() {
None
} else {
let mut vals = Vec::new();
if !legacy {
vals.push(XDataValue::String("DSTYLE".to_string()));
}
vals.push(XDataValue::ControlString("{".to_string()));
for (code, value) in kept {
vals.push(XDataValue::Integer16(code));
vals.push(value);
}
vals.push(XDataValue::ControlString("}".to_string()));
Some(vals)
};
let mut rebuilt = ExtendedData::new();
for record in common.extended_data.records() {
if record.application_name != app && !(!legacy && record.application_name == "ACAD_DSTYLE")
{
rebuilt.add_record(record.clone());
}
}
if let Some(vals) = values {
let mut record = acadrust::xdata::ExtendedDataRecord::new(app);
for v in vals {
record.add_value(v);
}
rebuilt.add_record(record);
}
rebuilt.raw_dwg_eed = common.extended_data.raw_dwg_eed.clone();
common.extended_data = rebuilt;
}
pub fn property_real_code(field: &str) -> Option<i16> {
Some(match field {
"dim_arrow_size" => DIMASZ,
+1135 -156
View File
File diff suppressed because it is too large. Load diff
+52 -19
View File
@@ -9,8 +9,8 @@ use crate::t;
use crate::command::EntityTransform;
use crate::entities::common::{
center_grip, edit_prop as edit, oriented_triangle_grip, parse_f64, ro_prop as ro,
square_grip,
center_grip, edit_prop as edit, format_area, format_length, oriented_triangle_grip, parse_f64,
ro_prop as ro, square_grip,
};
use crate::entities::traits::RenderConvertible;
use crate::scene::convert::acad_to_render::{RenderEntity, RenderObject};
@@ -163,33 +163,33 @@ fn properties(ell: &Ellipse) -> Vec<PropSection> {
vec![PropSection {
title: t!("Geometry").into_owned(),
props: vec![
ro(t!("Start X").as_ref(), "start_x", format!("{:.4}", start.x)),
ro(t!("Start Y").as_ref(), "start_y", format!("{:.4}", start.y)),
ro(t!("Start Z").as_ref(), "start_z", format!("{:.4}", start.z)),
ro(t!("Start X").as_ref(), "start_x", format_length(start.x)),
ro(t!("Start Y").as_ref(), "start_y", format_length(start.y)),
ro(t!("Start Z").as_ref(), "start_z", format_length(start.z)),
edit(t!("Center X").as_ref(), "center_x", ell.center.x),
edit(t!("Center Y").as_ref(), "center_y", ell.center.y),
edit(t!("Center Z").as_ref(), "center_z", ell.center.z),
ro(t!("End X").as_ref(), "end_x", format!("{:.4}", end.x)),
ro(t!("End Y").as_ref(), "end_y", format!("{:.4}", end.y)),
ro(t!("End Z").as_ref(), "end_z", format!("{:.4}", end.z)),
ro(t!("End X").as_ref(), "end_x", format_length(end.x)),
ro(t!("End Y").as_ref(), "end_y", format_length(end.y)),
ro(t!("End Z").as_ref(), "end_z", format_length(end.z)),
edit(t!("Major radius").as_ref(), "major_r", r_major),
edit(t!("Minor radius").as_ref(), "minor_r", r_minor),
edit(t!("Radius ratio").as_ref(), "ratio", ell.minor_axis_ratio),
edit(t!("Start angle").as_ref(), "start_angle", start_angle),
edit(t!("End angle").as_ref(), "end_angle", end_angle),
ro(t!("Major axis vector X").as_ref(), "major_x", format!("{:.4}", major_vec.x)),
ro(t!("Major axis vector Y").as_ref(), "major_y", format!("{:.4}", major_vec.y)),
ro(t!("Major axis vector Z").as_ref(), "major_z", format!("{:.4}", major_vec.z)),
ro(t!("Minor axis vector X").as_ref(), "minor_x", format!("{:.4}", minor_vec.x)),
ro(t!("Minor axis vector Y").as_ref(), "minor_y", format!("{:.4}", minor_vec.y)),
ro(t!("Minor axis vector Z").as_ref(), "minor_z", format!("{:.4}", minor_vec.z)),
ro(t!("Area").as_ref(), "area", format!("{:.4}", props.area)),
ro(t!("Major axis vector X").as_ref(), "major_x", format_length(major_vec.x)),
ro(t!("Major axis vector Y").as_ref(), "major_y", format_length(major_vec.y)),
ro(t!("Major axis vector Z").as_ref(), "major_z", format_length(major_vec.z)),
ro(t!("Minor axis vector X").as_ref(), "minor_x", format_length(minor_vec.x)),
ro(t!("Minor axis vector Y").as_ref(), "minor_y", format_length(minor_vec.y)),
ro(t!("Minor axis vector Z").as_ref(), "minor_z", format_length(minor_vec.z)),
ro(t!("Area").as_ref(), "area", format_area(props.area)),
ro(t!("Start parameter").as_ref(), "start_param", format!("{:.4}", ell.start_parameter)),
ro(t!("End parameter").as_ref(), "end_param", format!("{:.4}", ell.end_parameter)),
ro(t!("Length").as_ref(), "length", format!("{:.4}", props.perimeter)),
ro(t!("Normal X").as_ref(), "normal_x", format!("{:.4}", ell.normal.x)),
ro(t!("Normal Y").as_ref(), "normal_y", format!("{:.4}", ell.normal.y)),
ro(t!("Normal Z").as_ref(), "normal_z", format!("{:.4}", ell.normal.z)),
ro(t!("Length").as_ref(), "length", format_length(props.perimeter)),
edit(t!("Normal X").as_ref(), "normal_x", ell.normal.x),
edit(t!("Normal Y").as_ref(), "normal_y", ell.normal.y),
edit(t!("Normal Z").as_ref(), "normal_z", ell.normal.z),
],
}]
}
@@ -220,6 +220,26 @@ fn apply_geom_prop(ell: &mut Ellipse, field: &str, value: &str) {
"ratio" if v > 0.0 => ell.minor_axis_ratio = v,
"start_angle" => ell.start_parameter = v.to_radians(),
"end_angle" => ell.end_parameter = v.to_radians(),
"normal_x" | "normal_y" | "normal_z" => {
let mut normal = [ell.normal.x, ell.normal.y, ell.normal.z];
match field {
"normal_x" => normal[0] = v,
"normal_y" => normal[1] = v,
"normal_z" => normal[2] = v,
_ => {}
}
let Some(normal) = cadkernel::space::Vec3::from(normal).normalize() else {
return;
};
let Some(major) = cadkernel::space::reorient_axis_to_plane(
[ell.major_axis.x, ell.major_axis.y, ell.major_axis.z],
normal.to_array(),
) else {
return;
};
ell.normal = acadrust::types::Vector3::new(normal.x, normal.y, normal.z);
ell.major_axis = acadrust::types::Vector3::new(major[0], major[1], major[2]);
}
_ => {}
}
}
@@ -424,6 +444,19 @@ mod grip_tests {
(v.major_axis.x * v.major_axis.x + v.major_axis.y * v.major_axis.y).sqrt()
}
#[test]
fn editing_normal_reorients_major_axis_in_kernel() {
let mut e = ell(10.0, 0.5);
apply_geom_prop(&mut e, "normal_x", "1");
let normal = DVec3::new(e.normal.x, e.normal.y, e.normal.z);
let major = DVec3::new(e.major_axis.x, e.major_axis.y, e.major_axis.z);
assert!((normal.length() - 1.0).abs() < 1.0e-12);
assert!((major.length() - 10.0).abs() < 1.0e-9);
assert!(normal.dot(major).abs() < 1.0e-9);
}
#[test]
fn minor_grip_below_major_updates_ratio_only() {
let mut e = ell(10.0, 0.5);
+4 -4
View File
@@ -428,8 +428,8 @@ fn properties(h: &Hatch) -> Vec<PropSection> {
title: t!("Geometry").into_owned(),
props: vec![
edit(t!("Elevation").as_ref(), "elevation", h.elevation),
ro(t!("Area").as_ref(), "area", format!("{:.4}", area)),
ro(t!("Cumulative area").as_ref(), "cumulative_area", format!("{:.4}", area)),
ro(t!("Area").as_ref(), "area", crate::entities::common::format_area(area)),
ro(t!("Cumulative area").as_ref(), "cumulative_area", crate::entities::common::format_area(area)),
],
},
PropSection {
@@ -537,8 +537,8 @@ fn properties(h: &Hatch) -> Vec<PropSection> {
title: t!("Geometry").into_owned(),
props: vec![
edit(t!("Elevation").as_ref(), "elevation", h.elevation),
ro(t!("Area").as_ref(), "area", format!("{:.4}", area)),
ro(t!("Cumulative area").as_ref(), "cumulative_area", format!("{:.4}", area)),
ro(t!("Area").as_ref(), "area", crate::entities::common::format_area(area)),
ro(t!("Cumulative area").as_ref(), "cumulative_area", crate::entities::common::format_area(area)),
],
},
];
+9 -9
View File
@@ -3,8 +3,8 @@ use crate::t;
use crate::command::EntityTransform;
use crate::entities::common::{
center_grip, edit_prop as edit, oriented_triangle_grip, parse_f64, ro_prop as ro,
square_grip,
center_grip, edit_prop as edit, format_direction, format_length, oriented_triangle_grip,
parse_f64, ro_prop as ro, square_grip,
};
use crate::entities::traits::RenderConvertible;
use crate::scene::convert::acad_to_render::{extrusion_wall_tris, RenderEntity, RenderObject};
@@ -187,7 +187,7 @@ fn properties(line: &Line) -> Vec<PropSection> {
"centerline_end_extension",
association.end_extension,
),
ro(t!("Length").as_ref(), "length", format!("{:.4}", line.length())),
ro(t!("Length").as_ref(), "length", format_length(line.length())),
ro(
"Associative",
"centerline_associative",
@@ -199,7 +199,7 @@ fn properties(line: &Line) -> Vec<PropSection> {
let dx = line.end.x - line.start.x;
let dy = line.end.y - line.start.y;
let dz = line.end.z - line.start.z;
let angle = dy.atan2(dx).to_degrees().rem_euclid(360.0);
let angle = dy.atan2(dx);
vec![PropSection {
title: t!("Geometry").into_owned(),
props: vec![
@@ -209,11 +209,11 @@ fn properties(line: &Line) -> Vec<PropSection> {
edit(t!("End X").as_ref(), "end_x", line.end.x),
edit(t!("End Y").as_ref(), "end_y", line.end.y),
edit(t!("End Z").as_ref(), "end_z", line.end.z),
ro(t!("Delta X").as_ref(), "delta_x", format!("{dx:.4}")),
ro(t!("Delta Y").as_ref(), "delta_y", format!("{dy:.4}")),
ro(t!("Delta Z").as_ref(), "delta_z", format!("{dz:.4}")),
ro(t!("Length").as_ref(), "length", format!("{:.4}", line.length())),
ro(t!("Angle").as_ref(), "angle", format!("{angle:.2}")),
ro(t!("Delta X").as_ref(), "delta_x", format_length(dx)),
ro(t!("Delta Y").as_ref(), "delta_y", format_length(dy)),
ro(t!("Delta Z").as_ref(), "delta_z", format_length(dz)),
ro(t!("Length").as_ref(), "length", format_length(line.length())),
ro(t!("Angle").as_ref(), "angle", format_direction(angle)),
edit(t!("Normal X").as_ref(), "normal_x", line.normal.x),
edit(t!("Normal Y").as_ref(), "normal_y", line.normal.y),
edit(t!("Normal Z").as_ref(), "normal_z", line.normal.z),
+1
View File
@@ -143,6 +143,7 @@ pub fn dxf_name(e: &EntityType) -> &'static str {
EntityType::MLine(_) => "MLINE",
EntityType::RasterImage(_) => "RASTERIMAGE",
EntityType::Wipeout(_) => "WIPEOUT",
EntityType::Ole2Frame(_) => "OLE2FRAME",
EntityType::Underlay(_) => "UNDERLAY",
EntityType::AttributeDefinition(_) => "ATTDEF",
EntityType::AttributeEntity(_) => "ATTRIB",
+3 -3
View File
@@ -3,7 +3,7 @@ use crate::t;
use crate::command::EntityTransform;
use crate::entities::common::{
edit_prop as edit, format_length, parse_f64, ro_prop as ro, square_grip,
edit_prop as edit, format_area, format_length, parse_f64, ro_prop as ro, square_grip,
stepper_prop as stepper, VARIES_LABEL,
};
use crate::entities::traits::{Grippable, PropertyEditable, Transformable, RenderConvertible};
@@ -705,8 +705,8 @@ impl PropertyEditable for Polyline2D {
edit(t!("End segment width").as_ref(), "pl2_seg_end_w", seg_end_w),
global_width_prop,
edit(t!("Elevation").as_ref(), "pl2_elevation", self.elevation),
ro(t!("Area").as_ref(), "pl2_area", format!("{area:.4}")),
ro(t!("Length").as_ref(), "pl2_length", format!("{length:.4}")),
ro(t!("Area").as_ref(), "pl2_area", format_area(area)),
ro(t!("Length").as_ref(), "pl2_length", format_length(length)),
],
},
PropSection {
+3 -3
View File
@@ -301,9 +301,9 @@ impl PropertyEditable for RasterImage {
edit(t!("Position X").as_ref(), "ri_ox", self.insertion_point.x),
edit(t!("Position Y").as_ref(), "ri_oy", self.insertion_point.y),
edit(t!("Position Z").as_ref(), "ri_oz", self.insertion_point.z),
ro(t!("Rotation").as_ref(), "ri_rotation", format!("{:.4}", rotation_deg)),
ro(t!("Width").as_ref(), "ri_width", format!("{:.4}", self.width())),
ro(t!("Height").as_ref(), "ri_height", format!("{:.4}", self.height())),
ro(t!("Rotation").as_ref(), "ri_rotation", crate::entities::common::format_angle(rotation_deg.to_radians())),
ro(t!("Width").as_ref(), "ri_width", crate::entities::common::format_length(self.width())),
ro(t!("Height").as_ref(), "ri_height", crate::entities::common::format_length(self.height())),
ro(t!("Scale").as_ref(), "ri_scale", format!("{:.4}", scale)),
],
},
+28 -1
View File
@@ -80,7 +80,14 @@ fn boundary_edges(points: &[[f64; 3]]) -> Vec<Edge> {
}
fn kernel_tolerance(points: &[[f64; 3]]) -> f64 {
cadkernel::space::coplanarity_tolerance(points).max(1.0e-12)
let tolerance = cadkernel::space::coplanarity_tolerance(points);
// Corners whose extent overflows f64 give an infinite tolerance, which
// `Tolerance::new` rejects by panicking.
if tolerance.is_finite() {
tolerance.max(1.0e-12)
} else {
1.0e-12
}
}
fn extrusion_body(
@@ -366,3 +373,23 @@ impl Transformable for Solid {
);
}
}
#[cfg(test)]
mod overflow_tests {
use super::*;
use acadrust::types::Vector3;
/// Corners 1e308 apart overflow the coplanarity tolerance to infinity,
/// which the kernel's `Tolerance::new` rejects by panicking.
#[test]
fn corners_with_overflowing_extent_do_not_panic() {
let solid = Solid::new(
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(1e308, 0.0, 0.0),
Vector3::new(0.0, 1e308, 0.0),
Vector3::new(1e308, 1e308, 0.0),
);
let _ = wcs_bounds(&solid);
let _ = solid_geometry(&solid);
}
}
+245 -19
View File
@@ -18,7 +18,13 @@ pub(crate) fn uses_fit_method(spline: &Spline) -> bool {
}
pub(crate) fn shows_fit_points(spline: &Spline) -> bool {
uses_fit_method(spline) && (!spline.cv_frame_visible || spline.flags.periodic)
!spline.fit_points.is_empty() && (!spline.cv_frame_visible || spline.flags.periodic)
}
pub(crate) fn shows_control_vertices(spline: &Spline) -> bool {
!shows_fit_points(spline)
&& !(uses_fit_method(spline) && spline.flags.periodic)
&& control_vertices(spline).len() >= 2
}
pub(crate) fn fit_nurbs3(spline: &Spline) -> Option<NurbsCurve3> {
@@ -67,13 +73,59 @@ pub(crate) fn nurbs3(spline: &Spline) -> Option<NurbsCurve3> {
Some(curve.with_periodicity(spline.flags.closed || spline.flags.periodic))
}
/// NURBS knot locations on a control-point spline, evaluated on its own
/// curve: distinct interior knot values (the clamped ends already snap as
/// endpoints). Fit-method splines invent their knots at the fit nodes, which
/// already snap as endpoints, so they contribute nothing — re-offering them
/// would double every marker.
pub(crate) fn spline_knot_points(spline: &Spline) -> Vec<glam::DVec3> {
const MAX_KNOT_SNAPS: usize = 1024;
if uses_fit_method(spline) {
return Vec::new();
}
let Some(curve) = nurbs3(spline) else {
return Vec::new();
};
let (from, to) = curve.domain();
if !(from.is_finite() && to.is_finite()) || !(to > from) {
return Vec::new();
}
let span = to - from;
let mut knots: Vec<f64> = curve
.knots()
.iter()
.copied()
.filter(|k| k.is_finite() && *k > from + span * 1e-9 && *k < to - span * 1e-9)
.collect();
knots.sort_by(f64::total_cmp);
knots.dedup();
if knots.len() > MAX_KNOT_SNAPS {
let step = knots.len().div_ceil(MAX_KNOT_SNAPS);
knots = knots.into_iter().step_by(step).collect();
}
knots
.into_iter()
.map(|k| glam::DVec3::from_array(curve.point_at_knot(k)))
.filter(|p| p.is_finite())
.collect()
}
pub(crate) fn replace_with_nurbs(spline: &mut Spline, curve: &NurbsCurve3) {
let mut fit_points = spline.fit_points.clone();
spline.degree = curve.degree() as i32;
spline.knots = curve.knots().to_vec();
spline.control_points = curve.control_points().iter()
.map(|point| acadrust::types::Vector3::new(point[0], point[1], point[2])).collect();
spline.weights = curve.weights().to_vec();
spline.fit_points.clear();
if !fit_points.is_empty() {
if let (Some(first), Some(point)) = (fit_points.first_mut(), spline.control_points.first()) {
*first = *point;
}
if let (Some(last), Some(point)) = (fit_points.last_mut(), spline.control_points.last()) {
*last = *point;
}
}
spline.fit_points = fit_points;
spline.begin_tangent = acadrust::types::Vector3::ZERO;
spline.end_tangent = acadrust::types::Vector3::ZERO;
spline.flags.rational = spline.weights.windows(2)
@@ -134,6 +186,11 @@ fn to_render(spl: &Spline) -> RenderEntity {
crate::scene::model::wire_model::SnapHint::Endpoint,
)
}));
snap_pts.extend(
spline_knot_points(spl)
.into_iter()
.map(|p| (p, crate::scene::model::wire_model::SnapHint::Knot)),
);
let key_vertices = if planar.is_some() {
Vec::new()
} else {
@@ -179,6 +236,11 @@ fn to_render(spl: &Spline) -> RenderEntity {
)
}));
}
points.extend(
spline_knot_points(spl)
.into_iter()
.map(|p| (p, crate::scene::model::wire_model::SnapHint::Knot)),
);
(points, Vec::new())
}
// A spline through points in space is not a planar curve, so the
@@ -568,7 +630,15 @@ fn index_prop(label: &str, field: &'static str, index: usize, count: usize) -> P
fn convert_to_control_method(spline: &mut Spline) -> bool {
if !uses_fit_method(spline) {
return true;
let valid = spline.control_points.len() >= 2;
if valid {
spline.fit_points.clear();
spline.begin_tangent = acadrust::types::Vector3::ZERO;
spline.end_tangent = acadrust::types::Vector3::ZERO;
spline.dwg_flags1 &= !1;
spline.dxf_flags &= !(32 | 1024);
}
return valid;
}
let Some(planar) = crate::entities::curve::spline_curve(spline) else {
return false;
@@ -600,18 +670,74 @@ fn convert_to_control_method(spline: &mut Spline) -> bool {
}
fn convert_to_fit_method(spline: &mut Spline) -> bool {
uses_fit_method(spline)
if uses_fit_method(spline) {
return true;
}
if spline.fit_points.len() < 2 {
return false;
}
spline.control_points.clear();
spline.knots.clear();
spline.weights.clear();
spline.flags.rational = false;
spline.dwg_flags1 |= 1;
spline.dxf_flags |= 32 | 1024;
true
}
fn derived_fit_points(spline: &Spline) -> Vec<acadrust::types::Vector3> {
if spline.flags.closed || spline.flags.periodic {
return Vec::new();
}
let Some(curve) = nurbs3(spline) else {
return Vec::new();
};
let (start, end) = curve.domain();
if !(start.is_finite() && end.is_finite() && end > start) {
return Vec::new();
}
let tolerance = (end - start).abs().max(1.0) * 1.0e-12;
let mut parameters = vec![start];
for knot in curve.knots().iter().copied() {
if knot > start + tolerance
&& knot < end - tolerance
&& parameters
.last()
.is_none_or(|previous| (knot - *previous).abs() > tolerance)
{
parameters.push(knot);
}
}
parameters.push(end);
parameters
.into_iter()
.map(|parameter| {
let point = curve.point_at_knot(parameter);
acadrust::types::Vector3::new(point[0], point[1], point[2])
})
.collect()
}
fn can_show_fit_points(spline: &Spline) -> bool {
spline.fit_points.len() >= 2 || derived_fit_points(spline).len() >= 2
}
fn prepare_fit_point_view(spline: &mut Spline) -> bool {
if spline.fit_points.len() < 2 {
spline.fit_points = derived_fit_points(spline);
}
spline.fit_points.len() >= 2
}
fn grips(spline: &Spline) -> Vec<GripDef> {
let fit_method = uses_fit_method(spline);
let derived_control = control_vertices(spline);
let show_control_vertices = (spline.cv_frame_visible || !fit_method)
&& !(fit_method && spline.flags.periodic)
let show_fit = shows_fit_points(spline);
let show_control_vertices = !show_fit
&& !(uses_fit_method(spline) && spline.flags.periodic)
&& derived_control.len() >= 2;
let source = if show_control_vertices {
derived_control
} else if fit_method {
} else if show_fit {
spline.fit_points.clone()
} else {
spline.control_points.clone()
@@ -838,7 +964,7 @@ fn apply_geom_prop(spline: &mut Spline, field: &str, value: &str) {
match field {
"spline_method" => {
if value == "Fit" {
if convert_to_fit_method(spline) {
if prepare_fit_point_view(spline) {
spline.cv_frame_visible = false;
}
} else if value == "Control Vertices" {
@@ -849,6 +975,12 @@ fn apply_geom_prop(spline: &mut Spline, field: &str, value: &str) {
return;
}
"knot_param" => {
if shows_fit_points(spline)
&& !uses_fit_method(spline)
&& !convert_to_fit_method(spline)
{
return;
}
spline.knot_parameterization = match value {
"Chord" => 0,
"Square Root" => 1,
@@ -883,9 +1015,30 @@ fn apply_geom_prop(spline: &mut Spline, field: &str, value: &str) {
{
return;
}
if matches!(field, "ctrl_pt_x" | "ctrl_pt_y" | "ctrl_pt_z" | "weight")
&& uses_fit_method(spline)
&& !convert_to_control_method(spline)
if matches!(field, "ctrl_pt_x" | "ctrl_pt_y" | "ctrl_pt_z" | "weight") {
if !shows_fit_points(spline) && !spline.fit_points.is_empty() {
if !convert_to_control_method(spline) {
return;
}
} else if uses_fit_method(spline) && !convert_to_control_method(spline) {
return;
}
}
if shows_fit_points(spline)
&& !uses_fit_method(spline)
&& matches!(
field,
"fit_pt_x"
| "fit_pt_y"
| "fit_pt_z"
| "start_tan_x"
| "start_tan_y"
| "start_tan_z"
| "end_tan_x"
| "end_tan_y"
| "end_tan_z"
)
&& !convert_to_fit_method(spline)
{
return;
}
@@ -968,16 +1121,23 @@ fn apply_grip(spline: &mut Spline, grip_id: usize, apply: GripApply) {
return;
}
// First CV edit makes the displayed kernel controls authoritative.
let fit_method = uses_fit_method(spline);
if spline.cv_frame_visible && fit_method && !spline.flags.periodic {
let editing_fit = shows_fit_points(spline);
if editing_fit && !uses_fit_method(spline) {
if !convert_to_fit_method(spline) {
return;
}
} else if !editing_fit
&& (uses_fit_method(spline) || !spline.fit_points.is_empty())
&& !spline.flags.periodic
{
if !convert_to_control_method(spline) {
return;
}
}
let target = if (spline.cv_frame_visible && !spline.flags.periodic) || !fit_method {
spline.control_points.get_mut(grip_id)
} else {
let target = if editing_fit {
spline.fit_points.get_mut(grip_id)
} else {
spline.control_points.get_mut(grip_id)
};
if let Some(cp) = target {
match apply {
@@ -1041,9 +1201,10 @@ impl crate::entities::traits::Grippable for Spline {
let fit_method = uses_fit_method(self);
let can_show_control = control_vertices(self).len() >= 2
&& !(fit_method && self.flags.periodic);
let can_show_fit = can_show_fit_points(self);
return if !shows_fit_points(self) {
vec![
(can_show_control && fit_method).then_some(GripMenuItem {
can_show_fit.then_some(GripMenuItem {
label: "Fit",
action: GripMenuAction::ShowFit,
}),
@@ -1084,7 +1245,7 @@ impl crate::entities::traits::Grippable for Spline {
use crate::scene::model::object::GripMenuAction as A;
match action {
A::ShowFit => {
if convert_to_fit_method(self) {
if prepare_fit_point_view(self) {
self.cv_frame_visible = false;
return;
}
@@ -1120,6 +1281,45 @@ impl crate::entities::traits::Transformable for Spline {
mod tests {
use super::*;
#[test]
fn knot_points_evaluate_distinct_interior_knots() {
let mut spline = Spline::default();
spline.degree = 3;
spline.control_points = vec![
acadrust::types::Vector3::new(0.0, 0.0, 0.0),
acadrust::types::Vector3::new(1.0, 2.0, 0.0),
acadrust::types::Vector3::new(3.0, 3.0, 1.0),
acadrust::types::Vector3::new(5.0, 2.0, 0.0),
acadrust::types::Vector3::new(6.0, 0.0, 1.0),
];
spline.knots = vec![0.0, 0.0, 0.0, 0.0, 0.5, 1.0, 1.0, 1.0, 1.0];
let pts = spline_knot_points(&spline);
assert_eq!(pts.len(), 1, "one interior knot value: {pts:?}");
let curve = nurbs3(&spline).expect("control-point spline builds");
let expected = curve.point_at_knot(0.5);
let got = [pts[0].x, pts[0].y, pts[0].z];
let err = ((got[0] - expected[0]).powi(2)
+ (got[1] - expected[1]).powi(2)
+ (got[2] - expected[2]).powi(2))
.sqrt();
assert!(err < 1e-9, "knot point off the curve: {err}");
}
#[test]
fn fit_method_splines_emit_no_knot_snaps() {
// Interpolated curves invent their own knots at the fit nodes, which
// already snap as endpoints — re-offering them as knots would double
// every marker.
let mut spline = Spline::default();
spline.degree = 3;
spline.fit_points = vec![
acadrust::types::Vector3::new(0.0, 0.0, 0.0),
acadrust::types::Vector3::new(4.0, 0.0, 1.0),
acadrust::types::Vector3::new(4.0, 3.0, 2.0),
];
assert!(spline_knot_points(&spline).is_empty());
}
#[test]
fn fit_bounds_accept_closed_curves_with_unset_tangents() {
let mut spline = Spline::default();
@@ -1190,4 +1390,30 @@ mod tests {
acadrust::types::Vector3::new(0.0, 2.5, 0.0)
);
}
#[test]
fn showing_derived_fit_points_does_not_change_the_curve() {
let mut spline = Spline::default();
spline.degree = 3;
spline.control_points = vec![
acadrust::types::Vector3::new(0.0, 0.0, 0.0),
acadrust::types::Vector3::new(1.0, 3.0, 0.0),
acadrust::types::Vector3::new(4.0, 3.0, 0.0),
acadrust::types::Vector3::new(5.0, 0.0, 0.0),
];
spline.knots = cadkernel::space::clamped_uniform_knots(3, 4);
let before = nurbs3(&spline).unwrap().point_at(0.37);
assert!(prepare_fit_point_view(&mut spline));
spline.cv_frame_visible = false;
assert!(shows_fit_points(&spline));
assert!(!uses_fit_method(&spline));
let after = nurbs3(&spline).unwrap().point_at(0.37);
assert!(
cadkernel::space::Vec3::from(before)
.distance(cadkernel::space::Vec3::from(after))
< 1.0e-12
);
}
}
+18 -4
View File
@@ -39,6 +39,12 @@ pub fn resolve_text_style(style_name: &str, document: &CadDocument) -> ResolvedT
.map(std::path::Path::new)
.and_then(|p| p.parent());
crate::io::resolve_image_file(file, base)
// Fonts fetched from the community repository live in
// the per-user fonts folder — search it last.
.or_else(|| {
crate::io::font_repo::local_font_file(file)
.map(|path| path.to_string_lossy().into_owned())
})
})
.flatten();
if let Some(p) = shx_path {
@@ -191,7 +197,7 @@ pub fn text_local_bounds(
/// Expand DXF `%%x` special-character sequences that appear in both TEXT and MTEXT values:
/// - `%%d` / `%%D` → `°`
/// - `%%p` / `%%P` → `±`
/// - `%%c` / `%%C` → `⌀`
/// - `%%c` / `%%C` → `∅` (U+2205, the native diameter sign)
/// - `%%u` / `%%U` → underline toggle (stripped — not renderable with stroke fonts)
/// - `%%o` / `%%O` → overline toggle (stripped)
/// - `%%%%` → `%`
@@ -218,7 +224,7 @@ pub fn resolve_dxf_special_chars(s: &str) -> String {
}
Some('c') => {
chars.next();
out.push('⌀');
out.push('∅');
}
Some('u') | Some('o') => {
chars.next();
@@ -792,9 +798,11 @@ pub fn reorder_line_atoms(atoms: Vec<LayoutAtom>, is_rtl: bool) -> Vec<LayoutAto
.iter()
.map(|r| {
let mut lvl = None;
for i in r.clone() {
// `bidi.levels` is indexed by byte, so walk characters rather than
// bytes to avoid slicing inside a multi-byte character.
for (offset, c) in line_text[r.clone()].char_indices() {
let i = r.start + offset;
if i < bidi.levels.len() {
let c = line_text[i..].chars().next().unwrap_or(' ');
match unicode_bidi::bidi_class(c) {
unicode_bidi::BidiClass::L
| unicode_bidi::BidiClass::R
@@ -2960,6 +2968,12 @@ mod v_anchor_tests {
reordered_b,
vec!["2026 ", "is ", "here!", "שנה ", "שלום ", "مرحبا! ", "ہے۔ ", "اردو ", "یہ "]
);
// Case 6: a word starting with multi-byte neutral punctuation must
// not be sliced inside that character.
let words6 = ["שלום", " ", "«x"];
let reordered6 = reorder_atoms(&words6, false);
assert_eq!(reordered6, vec!["שלום", " ", "«x"]);
}
#[test]
+2 -2
View File
@@ -234,8 +234,8 @@ impl PropertyEditable for Underlay {
edit(t!("Scale X").as_ref(), "ul_sx", self.x_scale),
edit(t!("Scale Y").as_ref(), "ul_sy", self.y_scale),
edit(t!("Scale Z").as_ref(), "ul_sz", self.z_scale),
ro(t!("Width").as_ref(), "ul_width", format!("{:.4}", width)),
ro(t!("Height").as_ref(), "ul_height", format!("{:.4}", height)),
ro(t!("Width").as_ref(), "ul_width", crate::entities::common::format_length(width)),
ro(t!("Height").as_ref(), "ul_height", crate::entities::common::format_length(height)),
edit_angle(t!("Rotation").as_ref(), "ul_rot", self.rotation.to_degrees()),
],
},