forked from 1b-agent/opencadstudio-upstream
feat(ucs): integrate coordinate systems
Route coordinate input, geometry creation, transforms, properties,\nviews, and viewport persistence through a shared working plane.\n\nRefs #484
This commit is contained in:
1 parent
b1d55c6a7e
commit
657d023fb7
61 files changed
+2218
-747
No files matched your search
@@ -505,6 +505,59 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translated_and_rotated_ucs_resolves_absolute_relative_and_polar_input() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
app.automation_op(r#"{"op":"run","cmd":"UCS ORIGIN 100,200,300"}"#);
|
||||
app.automation_op(r#"{"op":"run","cmd":"UCS Z 90"}"#);
|
||||
app.automation_op(r#"{"op":"run","cmd":"LINE 2,3 @5<0"}"#);
|
||||
|
||||
let line = app.tabs[app.active_tab]
|
||||
.scene
|
||||
.document
|
||||
.entities()
|
||||
.find_map(|entity| match entity {
|
||||
acadrust::EntityType::Line(line) => Some(line),
|
||||
_ => None,
|
||||
})
|
||||
.expect("LINE should create one segment");
|
||||
let close = |a: f64, b: f64| (a - b).abs() < 1e-9;
|
||||
assert!(close(line.start.x, 97.0));
|
||||
assert!(close(line.start.y, 202.0));
|
||||
assert!(close(line.start.z, 300.0));
|
||||
assert!(close(line.end.x, 97.0));
|
||||
assert!(close(line.end.y, 207.0));
|
||||
assert!(close(line.end.z, 300.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tilted_ucs_places_planar_entities_with_the_plane_normal() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
app.automation_op(
|
||||
r#"{"op":"run","cmd":"UCS 3POINT 0,0,0 1,0,0 0,0,1"}"#,
|
||||
);
|
||||
app.automation_op(r#"{"op":"run","cmd":"CIRCLE 2,3 1"}"#);
|
||||
|
||||
let circle = app.tabs[app.active_tab]
|
||||
.scene
|
||||
.document
|
||||
.entities()
|
||||
.find_map(|entity| match entity {
|
||||
acadrust::EntityType::Circle(circle) => Some(circle),
|
||||
_ => None,
|
||||
})
|
||||
.expect("CIRCLE should create one entity");
|
||||
let close = |a: f64, b: f64| (a - b).abs() < 1e-9;
|
||||
assert!(close(circle.center.x, 2.0));
|
||||
assert!(close(circle.center.y, 0.0));
|
||||
assert!(close(circle.center.z, 3.0));
|
||||
assert!(close(circle.normal.x, 0.0));
|
||||
assert!(close(circle.normal.y, -1.0));
|
||||
assert!(close(circle.normal.z, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn value_prompt_commands_inline_args() {
|
||||
// A single-value setting command entered with its value on one line
|
||||
|
||||
@@ -1888,6 +1888,7 @@ impl OpenCADStudio {
|
||||
&handles,
|
||||
&crate::command::EntityTransform::Rotate {
|
||||
center: glam::DVec3::ZERO,
|
||||
axis: glam::DVec3::Z,
|
||||
angle_rad,
|
||||
},
|
||||
);
|
||||
|
||||
+32
-12
@@ -610,7 +610,12 @@ impl OpenCADStudio {
|
||||
if nums.len() < 3 {
|
||||
return Some(Task::none());
|
||||
}
|
||||
let place = glam::DVec3::new(nums[0], nums[1], nums[2]);
|
||||
let plane = if self.tabs[i].editing_model_space() {
|
||||
self.tabs[i].ucs_xform().working_plane()
|
||||
} else {
|
||||
crate::command::WorkingPlane::default()
|
||||
};
|
||||
let place = plane.to_local(glam::DVec3::new(nums[0], nums[1], nums[2]));
|
||||
let handles: Vec<acadrust::Handle> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
@@ -623,6 +628,9 @@ impl OpenCADStudio {
|
||||
qdim_collect_points(e, &mut pts);
|
||||
}
|
||||
}
|
||||
for point in &mut pts {
|
||||
*point = plane.to_local(*point);
|
||||
}
|
||||
if pts.len() < 2 {
|
||||
self.command_line
|
||||
.push_error(crate::t!("QDIM: no dimensionable endpoints in the selection.").as_ref());
|
||||
@@ -671,9 +679,10 @@ impl OpenCADStudio {
|
||||
dim.definition_point = v(def);
|
||||
dim.base.definition_point = v(def);
|
||||
dim.base.actual_measurement = dim.measurement();
|
||||
self.commit_entity(acadrust::EntityType::Dimension(
|
||||
let entity = acadrust::EntityType::Dimension(
|
||||
acadrust::entities::Dimension::Linear(dim),
|
||||
));
|
||||
);
|
||||
self.commit_entity(plane.place_entity(entity));
|
||||
made += 1;
|
||||
}
|
||||
self.tabs[i].dirty = true;
|
||||
@@ -1325,6 +1334,13 @@ fn find_last_linear_dim(
|
||||
fn qdim_collect_points(e: &acadrust::EntityType, out: &mut Vec<glam::DVec3>) {
|
||||
use acadrust::EntityType as ET;
|
||||
let p = |v: &acadrust::types::Vector3| glam::DVec3::new(v.x, v.y, v.z);
|
||||
let ocs = |point: (f64, f64, f64), normal: acadrust::types::Vector3| {
|
||||
let world = crate::scene::view::transform::ocs_point_to_wcs(
|
||||
point,
|
||||
(normal.x, normal.y, normal.z),
|
||||
);
|
||||
glam::DVec3::new(world.0, world.1, world.2)
|
||||
};
|
||||
match e {
|
||||
ET::Line(l) => {
|
||||
out.push(p(&l.start));
|
||||
@@ -1332,11 +1348,12 @@ fn qdim_collect_points(e: &acadrust::EntityType, out: &mut Vec<glam::DVec3>) {
|
||||
}
|
||||
ET::LwPolyline(pl) => {
|
||||
for v in &pl.vertices {
|
||||
out.push(glam::DVec3::new(
|
||||
v.location.x,
|
||||
v.location.y,
|
||||
pl.elevation,
|
||||
));
|
||||
out.push(ocs((v.location.x, v.location.y, pl.elevation), pl.normal));
|
||||
}
|
||||
}
|
||||
ET::Polyline2D(pl) => {
|
||||
for v in &pl.vertices {
|
||||
out.push(ocs((v.location.x, v.location.y, pl.elevation), pl.normal));
|
||||
}
|
||||
}
|
||||
ET::Polyline(pl) => {
|
||||
@@ -1346,10 +1363,13 @@ fn qdim_collect_points(e: &acadrust::EntityType, out: &mut Vec<glam::DVec3>) {
|
||||
}
|
||||
ET::Arc(a) => {
|
||||
for &ang in &[a.start_angle, a.end_angle] {
|
||||
out.push(glam::DVec3::new(
|
||||
a.center.x + a.radius * ang.cos(),
|
||||
a.center.y + a.radius * ang.sin(),
|
||||
a.center.z,
|
||||
out.push(ocs(
|
||||
(
|
||||
a.center.x + a.radius * ang.cos(),
|
||||
a.center.y + a.radius * ang.sin(),
|
||||
a.center.z,
|
||||
),
|
||||
a.normal,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,7 +220,7 @@ impl OpenCADStudio {
|
||||
self.command_line.push_error(crate::t!("Usage: UCS SAVE <name>").as_ref());
|
||||
} else {
|
||||
// Save the current active UCS under this name.
|
||||
let ucs = match &self.tabs[i].active_ucs {
|
||||
let mut ucs = match &self.tabs[i].active_ucs {
|
||||
Some(u) => {
|
||||
let mut saved = u.clone();
|
||||
saved.name = name.clone();
|
||||
@@ -228,7 +228,23 @@ impl OpenCADStudio {
|
||||
}
|
||||
None => Ucs::new(&name), // save WCS (identity)
|
||||
};
|
||||
self.tabs[i].scene.document.ucss.add_or_replace(ucs);
|
||||
ucs.handle = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.ucss
|
||||
.get(&name)
|
||||
.map(|existing| existing.handle)
|
||||
.filter(|handle| !handle.is_null())
|
||||
.unwrap_or_else(|| {
|
||||
self.tabs[i].scene.document.allocate_handle()
|
||||
});
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.ucss
|
||||
.add_or_replace(ucs.clone());
|
||||
self.tabs[i].active_ucs = Some(ucs);
|
||||
active_changed = true;
|
||||
self.tabs[i].dirty = true;
|
||||
self.command_line
|
||||
.push_output(crate::tf!("UCS '{}' saved.", name).as_ref());
|
||||
@@ -238,7 +254,30 @@ impl OpenCADStudio {
|
||||
let name = parts.get(2).map(|s| s.trim()).unwrap_or("").to_string();
|
||||
if name.is_empty() {
|
||||
self.command_line.push_error(crate::t!("Usage: UCS DELETE <name>").as_ref());
|
||||
} else if self.tabs[i].scene.document.ucss.remove(&name).is_some() {
|
||||
} else if let Some(removed) =
|
||||
self.tabs[i].scene.document.ucss.remove(&name)
|
||||
{
|
||||
let removed_handle = removed.handle;
|
||||
for entity in self.tabs[i].scene.document.entities_mut() {
|
||||
if let acadrust::EntityType::Viewport(viewport) = entity {
|
||||
if viewport.ucs_handle == removed_handle {
|
||||
viewport.ucs_handle = acadrust::Handle::NULL;
|
||||
}
|
||||
}
|
||||
}
|
||||
let active_matches = self.tabs[i].active_ucs.as_ref().is_some_and(|ucs| {
|
||||
(!removed_handle.is_null() && ucs.handle == removed_handle)
|
||||
|| ucs.name.eq_ignore_ascii_case(&name)
|
||||
});
|
||||
if active_matches {
|
||||
if let Some(active) = self.tabs[i].active_ucs.as_mut() {
|
||||
active.name = "*ACTIVE*".to_string();
|
||||
active.handle = acadrust::Handle::NULL;
|
||||
active.named_ucs_handle = acadrust::Handle::NULL;
|
||||
active.base_ucs_handle = acadrust::Handle::NULL;
|
||||
}
|
||||
active_changed = true;
|
||||
}
|
||||
self.tabs[i].dirty = true;
|
||||
self.command_line
|
||||
.push_output(crate::tf!("UCS '{}' deleted.", name).as_ref());
|
||||
@@ -253,14 +292,73 @@ impl OpenCADStudio {
|
||||
self.command_line
|
||||
.push_output(crate::t!("UCS reset to World Coordinate System.").as_ref());
|
||||
}
|
||||
"VIEW" | "V" => {
|
||||
let rotation = self.tabs[i].scene.active_camera_rotation();
|
||||
let origin = self.tabs[i]
|
||||
.active_ucs
|
||||
.as_ref()
|
||||
.map(|ucs| ucs.origin)
|
||||
.unwrap_or(Vector3::ZERO);
|
||||
let x = rotation * glam::Vec3::X;
|
||||
let y = rotation * glam::Vec3::Y;
|
||||
let mut ucs = Ucs::new("*VIEW*");
|
||||
ucs.origin = origin;
|
||||
ucs.x_axis = Vector3::new(x.x as f64, x.y as f64, x.z as f64);
|
||||
ucs.y_axis = Vector3::new(y.x as f64, y.y as f64, y.z as f64);
|
||||
self.tabs[i].active_ucs = Some(ucs);
|
||||
active_changed = true;
|
||||
self.command_line
|
||||
.push_output(crate::t!("UCS aligned to the current view.").as_ref());
|
||||
}
|
||||
"3POINTW" => {
|
||||
let raw = parts.get(2).copied().unwrap_or("");
|
||||
let points: Vec<glam::DVec3> = raw
|
||||
.split('|')
|
||||
.filter_map(|value| {
|
||||
super::super::helpers::parse_coord(value).map(|(point, _)| point)
|
||||
})
|
||||
.collect();
|
||||
if points.len() == 3 {
|
||||
let x = (points[1] - points[0]).normalize_or_zero();
|
||||
let toward_y = points[2] - points[0];
|
||||
let z = x.cross(toward_y).normalize_or_zero();
|
||||
let y = z.cross(x).normalize_or_zero();
|
||||
if x.length_squared() > 1e-12
|
||||
&& y.length_squared() > 1e-12
|
||||
&& z.length_squared() > 1e-12
|
||||
{
|
||||
let mut ucs = Ucs::new("*ACTIVE*");
|
||||
ucs.origin = Vector3::new(points[0].x, points[0].y, points[0].z);
|
||||
ucs.x_axis = Vector3::new(x.x, x.y, x.z);
|
||||
ucs.y_axis = Vector3::new(y.x, y.y, y.z);
|
||||
self.tabs[i].active_ucs = Some(ucs);
|
||||
active_changed = true;
|
||||
self.command_line.push_output(
|
||||
crate::t!("UCS defined from three points.").as_ref(),
|
||||
);
|
||||
} else {
|
||||
self.command_line.push_error(
|
||||
crate::t!("UCS points must define two non-collinear axes.")
|
||||
.as_ref(),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
self.command_line.push_error(
|
||||
crate::t!("UCS requires origin, X-axis point and XY-plane point.")
|
||||
.as_ref(),
|
||||
);
|
||||
}
|
||||
}
|
||||
// UCS ORIGIN x,y,z — shift the active UCS origin, keep axes
|
||||
"ORIGIN" | "O" => {
|
||||
"ORIGIN" | "O" | "ORIGINW" => {
|
||||
let coord_str = parts.get(2).copied().unwrap_or("");
|
||||
if let Some((pt, _)) = super::super::helpers::parse_coord(coord_str) {
|
||||
// `pt` is in current UCS space; convert to WCS.
|
||||
// The @/# relative-coordinate prefix is ignored
|
||||
// here — a UCS origin is always absolute.
|
||||
let wcs_origin = if let Some(ref ucs) = self.tabs[i].active_ucs {
|
||||
let wcs_origin = if sub == "ORIGINW" {
|
||||
pt
|
||||
} else if let Some(ref ucs) = self.tabs[i].active_ucs {
|
||||
ucs_to_wcs(pt, ucs)
|
||||
} else {
|
||||
pt
|
||||
@@ -401,9 +499,6 @@ impl OpenCADStudio {
|
||||
}
|
||||
|
||||
// ── Named Views (VIEW command) ────────────────────────────────
|
||||
// PLAN — look straight down at the drawing (top view). The optional
|
||||
// World/Ucs/Current keyword is accepted; all map to the world top
|
||||
// view for now.
|
||||
// PLAN — plan view of a UCS (#326). Shows its options right on
|
||||
// activation instead of flipping the view like a cube click; the
|
||||
// view only changes once an option (or the Current default) runs.
|
||||
|
||||
@@ -1787,6 +1787,14 @@ impl OpenCADStudio {
|
||||
{
|
||||
self.tabs[i].active_layer = new_name.clone();
|
||||
}
|
||||
if type_str == "UCS" {
|
||||
if let Some(active) = self.tabs[i].active_ucs.as_mut() {
|
||||
if active.name.eq_ignore_ascii_case(&old_name) {
|
||||
active.name = new_name.clone();
|
||||
self.tabs[i].persist_active_ucs();
|
||||
}
|
||||
}
|
||||
}
|
||||
self.tabs[i].dirty = true;
|
||||
self.command_line
|
||||
.push_output(crate::tf!("RENAME: '{}' → '{}'.", old_name, new_name).as_ref());
|
||||
@@ -2357,7 +2365,15 @@ fn rename_symbol(doc: &mut acadrust::CadDocument, ty: &str, old: &str, new: &str
|
||||
}
|
||||
true
|
||||
}
|
||||
"UCS" => rekey(&mut doc.ucss, old, new),
|
||||
"UCS" => {
|
||||
if !rekey(&mut doc.ucss, old, new) {
|
||||
return false;
|
||||
}
|
||||
if doc.header.model_space_ucs_name.eq_ignore_ascii_case(old) {
|
||||
doc.header.model_space_ucs_name = new.to_string();
|
||||
}
|
||||
true
|
||||
}
|
||||
"VIEW" => rekey(&mut doc.views, old, new),
|
||||
_ => false,
|
||||
}
|
||||
|
||||
+25
-32
@@ -257,11 +257,22 @@ impl DocumentTab {
|
||||
/// is identity (plain WCS).
|
||||
pub(super) fn model_ucs_from_header(&self) -> Option<Ucs> {
|
||||
let h = &self.scene.document.header;
|
||||
let mut u = Ucs::new(h.model_space_ucs_name.clone());
|
||||
let mut u = if h.model_space_ucs_name.is_empty() {
|
||||
Ucs::new("*ACTIVE*")
|
||||
} else {
|
||||
self.scene
|
||||
.document
|
||||
.ucss
|
||||
.get(&h.model_space_ucs_name)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| Ucs::new(h.model_space_ucs_name.clone()))
|
||||
};
|
||||
u.origin = h.model_space_ucs_origin;
|
||||
u.x_axis = h.model_space_ucs_x_axis;
|
||||
u.y_axis = h.model_space_ucs_y_axis;
|
||||
if super::helpers::UcsXform::from_ucs(&u).is_identity() {
|
||||
if h.model_space_ucs_name.is_empty()
|
||||
&& super::helpers::UcsXform::from_ucs(&u).is_identity()
|
||||
{
|
||||
None
|
||||
} else {
|
||||
Some(u)
|
||||
@@ -278,11 +289,21 @@ impl DocumentTab {
|
||||
if !vp.ucs_per_viewport {
|
||||
return None;
|
||||
}
|
||||
let mut u = Ucs::new("*VPUCS*");
|
||||
let mut u = if vp.ucs_handle.is_null() {
|
||||
Ucs::new("*VPUCS*")
|
||||
} else {
|
||||
self.scene
|
||||
.document
|
||||
.ucss
|
||||
.iter()
|
||||
.find(|ucs| ucs.handle == vp.ucs_handle)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| Ucs::new("*VPUCS*"))
|
||||
};
|
||||
u.origin = vp.ucs_origin;
|
||||
u.x_axis = vp.ucs_x_axis;
|
||||
u.y_axis = vp.ucs_y_axis;
|
||||
if super::helpers::UcsXform::from_ucs(&u).is_identity() {
|
||||
if vp.ucs_handle.is_null() && super::helpers::UcsXform::from_ucs(&u).is_identity() {
|
||||
None
|
||||
} else {
|
||||
Some(u)
|
||||
@@ -369,34 +390,6 @@ impl DocumentTab {
|
||||
self.scene.viewcube_ucs = self.ucs_xform().rotation_mat();
|
||||
}
|
||||
|
||||
/// UCS→render(wire)-space affine for commands that build axis-aligned
|
||||
/// geometry. Columns are the UCS axes; translation is the UCS origin in wire
|
||||
/// space. Identity outside model space (no UCS there).
|
||||
pub(super) fn ucs_wire_affine(&self) -> glam::Mat4 {
|
||||
if !self.editing_model_space() {
|
||||
return glam::Mat4::IDENTITY;
|
||||
}
|
||||
let (o, x, y, z) = self.ucs_xform().axes();
|
||||
let origin = glam::Vec3::new(o.x as f32, o.y as f32, o.z as f32);
|
||||
glam::Mat4::from_cols(
|
||||
x.as_vec3().extend(0.0),
|
||||
y.as_vec3().extend(0.0),
|
||||
z.as_vec3().extend(0.0),
|
||||
origin.extend(1.0),
|
||||
)
|
||||
}
|
||||
|
||||
/// World-space rotation angle (radians) of the active UCS X axis — the
|
||||
/// default rotation for new text-bearing objects so their text aligns to
|
||||
/// the user's coordinate system. Zero outside model space / with no UCS.
|
||||
pub(super) fn ucs_rotation_angle(&self) -> f64 {
|
||||
if !self.editing_model_space() {
|
||||
return 0.0;
|
||||
}
|
||||
let (_, x, ..) = self.ucs_xform().axes();
|
||||
(x.y as f64).atan2(x.x as f64)
|
||||
}
|
||||
|
||||
/// Grid origin (render/wire space) and UCS→world rotation for grid snap and
|
||||
/// the grid overlay. Identity / origin-at-zero outside model space.
|
||||
pub(super) fn ucs_grid_basis(&self) -> (glam::Vec3, glam::Mat4) {
|
||||
|
||||
+55
-5
@@ -5,7 +5,7 @@ use acadrust::tables::Ucs;
|
||||
/// How a typed coordinate should be interpreted relative to the last
|
||||
/// input point.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub(super) enum CoordKind {
|
||||
pub(crate) enum CoordKind {
|
||||
/// `@x,y` prefix — offset from the last input point.
|
||||
Relative,
|
||||
/// `#x,y` prefix — world/UCS absolute, overriding DYN.
|
||||
@@ -14,12 +14,13 @@ pub(super) enum CoordKind {
|
||||
Default,
|
||||
}
|
||||
|
||||
/// Parse a typed coordinate string into a Vec3 plus its interpretation.
|
||||
/// Accepts "x,y" → Vec3(x, y, 0)
|
||||
/// "x,y,z" → Vec3(x, y, z)
|
||||
/// Parse a typed coordinate string into a local Vec3 plus its interpretation.
|
||||
/// Accepts Cartesian, polar, cylindrical and spherical forms:
|
||||
/// `x,y`, `x,y,z`, `distance<angle`, `distance<angle,z`, and
|
||||
/// `distance<angle<elevation`.
|
||||
/// A leading `@` marks the value relative to the last point; a leading
|
||||
/// `#` forces absolute. Separators: comma or semicolon.
|
||||
pub(super) fn parse_coord(text: &str) -> Option<(glam::DVec3, CoordKind)> {
|
||||
pub(crate) fn parse_coord(text: &str) -> Option<(glam::DVec3, CoordKind)> {
|
||||
let trimmed = text.trim();
|
||||
let (kind, rest) = if let Some(r) = trimmed.strip_prefix('@') {
|
||||
(CoordKind::Relative, r)
|
||||
@@ -28,6 +29,34 @@ pub(super) fn parse_coord(text: &str) -> Option<(glam::DVec3, CoordKind)> {
|
||||
} else {
|
||||
(CoordKind::Default, trimmed)
|
||||
};
|
||||
let number = |value: &str| value.trim().parse::<f64>().ok();
|
||||
if let Some((distance, angles)) = rest.split_once('<') {
|
||||
let distance = number(distance)?;
|
||||
if let Some((azimuth, elevation)) = angles.split_once('<') {
|
||||
let azimuth = number(azimuth)?.to_radians();
|
||||
let elevation = number(elevation)?.to_radians();
|
||||
let horizontal = distance * elevation.cos();
|
||||
return Some((
|
||||
glam::DVec3::new(
|
||||
horizontal * azimuth.cos(),
|
||||
horizontal * azimuth.sin(),
|
||||
distance * elevation.sin(),
|
||||
),
|
||||
kind,
|
||||
));
|
||||
}
|
||||
let angle_and_z: Vec<&str> = angles.split(|c| c == ',' || c == ';').collect();
|
||||
let (angle, z) = match angle_and_z.as_slice() {
|
||||
[angle] => (number(angle)?, 0.0),
|
||||
[angle, z] => (number(angle)?, number(z)?),
|
||||
_ => return None,
|
||||
};
|
||||
let angle = angle.to_radians();
|
||||
return Some((
|
||||
glam::DVec3::new(distance * angle.cos(), distance * angle.sin(), z),
|
||||
kind,
|
||||
));
|
||||
}
|
||||
let parts: Vec<f64> = rest
|
||||
.split(|c| c == ',' || c == ';')
|
||||
.map(|s| s.trim())
|
||||
@@ -40,6 +69,23 @@ pub(super) fn parse_coord(text: &str) -> Option<(glam::DVec3, CoordKind)> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod coordinate_parsing_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_all_coordinate_forms() {
|
||||
let close = |a: glam::DVec3, b: glam::DVec3| (a - b).length() < 1e-9;
|
||||
assert!(close(parse_coord("1,2").unwrap().0, glam::dvec3(1.0, 2.0, 0.0)));
|
||||
assert!(close(parse_coord("1,2,3").unwrap().0, glam::dvec3(1.0, 2.0, 3.0)));
|
||||
assert!(close(parse_coord("10<90").unwrap().0, glam::dvec3(0.0, 10.0, 0.0)));
|
||||
assert!(close(parse_coord("10<90,4").unwrap().0, glam::dvec3(0.0, 10.0, 4.0)));
|
||||
assert!(close(parse_coord("10<0<30").unwrap().0, glam::dvec3(5.0 * 3.0_f64.sqrt(), 0.0, 5.0)));
|
||||
assert_eq!(parse_coord("@10<0").unwrap().1, CoordKind::Relative);
|
||||
assert_eq!(parse_coord("#1,2").unwrap().1, CoordKind::Absolute);
|
||||
}
|
||||
}
|
||||
|
||||
// ── UCS ↔ WCS converter ─────────────────────────────────────────────────────
|
||||
|
||||
/// The single bridge between WCS (how geometry and the file are stored) and the
|
||||
@@ -122,6 +168,10 @@ impl UcsXform {
|
||||
(self.origin, self.x, self.y, self.z)
|
||||
}
|
||||
|
||||
pub(super) fn working_plane(&self) -> crate::command::WorkingPlane {
|
||||
crate::command::WorkingPlane::new(self.origin, self.x, self.y)
|
||||
}
|
||||
|
||||
/// UCS→world rotation matrix (columns = UCS axes). For consumers that take
|
||||
/// a `Mat4` rotation directly (ViewCube, OTRACK ray directions). The GPU /
|
||||
/// screen layer is f32, so the axes downcast here at the boundary.
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ pub(crate) mod commands;
|
||||
mod document;
|
||||
mod expr_eval;
|
||||
mod find_replace;
|
||||
mod helpers;
|
||||
pub(crate) mod helpers;
|
||||
mod history;
|
||||
mod layers;
|
||||
mod model_ops;
|
||||
|
||||
+34
-5
@@ -1146,10 +1146,24 @@ impl super::OpenCADStudio {
|
||||
.bump_entities(&[(h, crate::scene::ChangeKind::Modified)]);
|
||||
self.tabs[i].dirty = true;
|
||||
} else {
|
||||
// Align new MText to the active UCS (text runs along the UCS X axis).
|
||||
mt.rotation = self.tabs[i].ucs_rotation_angle();
|
||||
let plane = if self.tabs[i].editing_model_space() {
|
||||
self.tabs[i].ucs_xform().working_plane()
|
||||
} else {
|
||||
crate::command::WorkingPlane::default()
|
||||
};
|
||||
let position = plane.to_local(glam::DVec3::new(
|
||||
mt.insertion_point.x,
|
||||
mt.insertion_point.y,
|
||||
mt.insertion_point.z,
|
||||
));
|
||||
mt.insertion_point = acadrust::types::Vector3::new(
|
||||
position.x,
|
||||
position.y,
|
||||
position.z,
|
||||
);
|
||||
mt.rotation = 0.0;
|
||||
self.push_undo_snapshot(i, "MTEXT");
|
||||
let handle = self.commit_entity_handle(EntityType::MText(mt));
|
||||
let handle = self.commit_entity_handle(plane.place_entity(EntityType::MText(mt)));
|
||||
if annotative {
|
||||
let scale = self.tabs[i].scene.current_annotation_scale_handle();
|
||||
if let (Some(handle), Some(scale)) = (handle, scale) {
|
||||
@@ -1217,9 +1231,24 @@ impl super::OpenCADStudio {
|
||||
.bump_entities(&[(h, crate::scene::ChangeKind::Modified)]);
|
||||
self.tabs[i].dirty = true;
|
||||
} else {
|
||||
mt.rotation = self.tabs[i].ucs_rotation_angle();
|
||||
let plane = if self.tabs[i].editing_model_space() {
|
||||
self.tabs[i].ucs_xform().working_plane()
|
||||
} else {
|
||||
crate::command::WorkingPlane::default()
|
||||
};
|
||||
let position = plane.to_local(glam::DVec3::new(
|
||||
mt.insertion_point.x,
|
||||
mt.insertion_point.y,
|
||||
mt.insertion_point.z,
|
||||
));
|
||||
mt.insertion_point = acadrust::types::Vector3::new(
|
||||
position.x,
|
||||
position.y,
|
||||
position.z,
|
||||
);
|
||||
mt.rotation = 0.0;
|
||||
self.push_undo_snapshot(i, "MTEXT");
|
||||
let handle = self.commit_entity_handle(EntityType::MText(mt));
|
||||
let handle = self.commit_entity_handle(plane.place_entity(EntityType::MText(mt)));
|
||||
self.tabs[i].dirty = true;
|
||||
if annotative {
|
||||
let scale = self.tabs[i].scene.current_annotation_scale_handle();
|
||||
|
||||
+53
-2
@@ -375,10 +375,31 @@ impl OpenCADStudio {
|
||||
source_entity,
|
||||
annotation_scale_handle,
|
||||
);
|
||||
let entity = contextual.as_ref();
|
||||
let plane = if self.tabs[i].editing_model_space() {
|
||||
self.tabs[i].ucs_xform().working_plane()
|
||||
} else {
|
||||
crate::command::WorkingPlane::default()
|
||||
};
|
||||
let display_entity = dispatch::entity_in_working_plane(contextual.as_ref(), plane);
|
||||
let entity = &display_entity;
|
||||
let group_names = self.tabs[i].scene.group_names_for_entity(handle);
|
||||
let mut sections =
|
||||
dispatch::properties_sectioned(handle, entity, &text_style_names);
|
||||
sections.insert(
|
||||
0,
|
||||
crate::scene::model::object::PropSection {
|
||||
title: t!("Coordinates").into_owned(),
|
||||
props: vec![crate::entities::common::ro_prop(
|
||||
t!("Coordinate system").as_ref(),
|
||||
"coordinate_system",
|
||||
self.tabs[i]
|
||||
.active_ucs
|
||||
.as_ref()
|
||||
.map(|ucs| ucs.name.clone())
|
||||
.unwrap_or_else(|| "WCS".to_string()),
|
||||
)],
|
||||
},
|
||||
);
|
||||
|
||||
// Turn the Material row into an editable picker: the source
|
||||
// options (ByLayer / ByBlock) plus every named material the
|
||||
@@ -1528,7 +1549,37 @@ impl OpenCADStudio {
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let sections = aggregate_sections(&filtered, &text_style_names);
|
||||
let plane = if self.tabs[i].editing_model_space() {
|
||||
self.tabs[i].ucs_xform().working_plane()
|
||||
} else {
|
||||
crate::command::WorkingPlane::default()
|
||||
};
|
||||
let local_entities: Vec<(Handle, EntityType)> = filtered
|
||||
.iter()
|
||||
.map(|(handle, entity)| {
|
||||
(*handle, dispatch::entity_in_working_plane(entity, plane))
|
||||
})
|
||||
.collect();
|
||||
let local_refs: Vec<(Handle, &EntityType)> = local_entities
|
||||
.iter()
|
||||
.map(|(handle, entity)| (*handle, entity))
|
||||
.collect();
|
||||
let mut sections = aggregate_sections(&local_refs, &text_style_names);
|
||||
sections.insert(
|
||||
0,
|
||||
crate::scene::model::object::PropSection {
|
||||
title: t!("Coordinates").into_owned(),
|
||||
props: vec![crate::entities::common::ro_prop(
|
||||
t!("Coordinate system").as_ref(),
|
||||
"coordinate_system",
|
||||
self.tabs[i]
|
||||
.active_ucs
|
||||
.as_ref()
|
||||
.map(|ucs| ucs.name.clone())
|
||||
.unwrap_or_else(|| "WCS".to_string()),
|
||||
)],
|
||||
},
|
||||
);
|
||||
ui::PropertiesPanel {
|
||||
choice_combos: sections
|
||||
.iter()
|
||||
|
||||
@@ -211,9 +211,15 @@ impl super::OpenCADStudio {
|
||||
.bump_entities(&[(h, crate::scene::ChangeKind::Modified)]);
|
||||
self.tabs[i].dirty = true;
|
||||
} else {
|
||||
let plane = if self.tabs[i].editing_model_space() {
|
||||
self.tabs[i].ucs_xform().working_plane()
|
||||
} else {
|
||||
crate::command::WorkingPlane::default()
|
||||
};
|
||||
let position = plane.to_local(ed.pos);
|
||||
let mut t = Text::with_value(
|
||||
&ed.value,
|
||||
Vector3::new(ed.pos.x, ed.pos.y, ed.pos.z),
|
||||
Vector3::new(position.x, position.y, position.z),
|
||||
)
|
||||
.with_height(ed.height);
|
||||
// New text inherits the document's current text style (STYLE), not
|
||||
@@ -231,10 +237,8 @@ impl super::OpenCADStudio {
|
||||
&self.tabs[i].scene.document,
|
||||
&t.style,
|
||||
);
|
||||
// Align new text to the active UCS (baseline along the UCS X axis).
|
||||
t.rotation = self.tabs[i].ucs_rotation_angle();
|
||||
self.push_undo_snapshot(i, "TEXT");
|
||||
let handle = self.commit_entity_handle(EntityType::Text(t));
|
||||
let handle = self.commit_entity_handle(plane.place_entity(EntityType::Text(t)));
|
||||
if annotative {
|
||||
let scale = self.tabs[i].scene.current_annotation_scale_handle();
|
||||
if let (Some(handle), Some(scale)) = (handle, scale) {
|
||||
|
||||
@@ -417,8 +417,8 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
// independent of DYN: `@` forces relative, `#` forces
|
||||
// absolute, and a bare value is absolute. (Relative-by-
|
||||
// default lives in the DYN tooltip path — see
|
||||
// `dyn_resolve_point` — matching AutoCAD, where the
|
||||
// command line stays absolute regardless of DYN.)
|
||||
// `dyn_resolve_point`; command-line coordinates stay
|
||||
// absolute regardless of the DYN setting.)
|
||||
let want_relative = matches!(kind, CoordKind::Relative);
|
||||
let ucs = self.tabs[i].active_ucs.clone();
|
||||
let wcs_pt = match (want_relative, self.last_point) {
|
||||
@@ -474,7 +474,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
}
|
||||
|
||||
self.command_line.push_error(crate::tf!(
|
||||
"Expected coordinates (x,y) or a number, got: \"{text}\""
|
||||
"Expected Cartesian, polar, cylindrical or spherical coordinates, or a number; got: \"{text}\""
|
||||
).as_ref());
|
||||
return self.focus_cmd_input();
|
||||
}
|
||||
@@ -1874,10 +1874,20 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let plane = if self.tabs[i].editing_model_space() {
|
||||
self.tabs[i].ucs_xform().working_plane()
|
||||
} else {
|
||||
crate::command::WorkingPlane::default()
|
||||
};
|
||||
for &handle in &handles {
|
||||
if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle)
|
||||
{
|
||||
crate::scene::view::dispatch::apply_geom_prop(entity, field, &value);
|
||||
crate::scene::view::dispatch::apply_geom_prop_in_working_plane(
|
||||
entity,
|
||||
field,
|
||||
&value,
|
||||
plane,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1936,6 +1946,11 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
crate::scene::view::dispatch::set_prop_current_vertex(
|
||||
self.tabs[i].properties.prop_vertex,
|
||||
);
|
||||
let plane = if self.tabs[i].editing_model_space() {
|
||||
self.tabs[i].ucs_xform().working_plane()
|
||||
} else {
|
||||
crate::command::WorkingPlane::default()
|
||||
};
|
||||
for &handle in &handles {
|
||||
// Skip objects on a locked layer.
|
||||
if self.tabs[i].scene.is_layer_locked(handle) {
|
||||
@@ -2007,8 +2022,11 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
if let Some(entity) =
|
||||
self.tabs[i].scene.document.get_entity_mut(handle)
|
||||
{
|
||||
crate::scene::view::dispatch::apply_geom_prop(
|
||||
entity, field, &val,
|
||||
crate::scene::view::dispatch::apply_geom_prop_in_working_plane(
|
||||
entity,
|
||||
field,
|
||||
&val,
|
||||
plane,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,13 +188,15 @@ impl OpenCADStudio {
|
||||
/// values. Locked fields use their typed buffer; the rest fall back to
|
||||
/// the live cursor-derived value. Returns `None` when the field set
|
||||
/// isn't one we know how to turn into a point.
|
||||
/// Hand the active command the current UCS (as a UCS→wire affine) so
|
||||
/// axis-aligned constructions build square to the user's coordinate system.
|
||||
/// No-op for commands that don't override `set_ucs`.
|
||||
/// Hand the active command the current full-precision coordinate frame.
|
||||
pub(in crate::app) fn push_ucs_to_cmd(&mut self, i: usize) {
|
||||
let ucs = self.tabs[i].ucs_wire_affine();
|
||||
let plane = if self.tabs[i].editing_model_space() {
|
||||
self.tabs[i].ucs_xform().working_plane()
|
||||
} else {
|
||||
crate::command::WorkingPlane::default()
|
||||
};
|
||||
if let Some(c) = self.tabs[i].active_cmd.as_mut() {
|
||||
c.set_ucs(ucs);
|
||||
c.set_working_plane(plane);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2086,7 +2086,11 @@ impl OpenCADStudio {
|
||||
match (a, b) {
|
||||
(None, None) => true,
|
||||
(Some(a), Some(b)) => {
|
||||
a.origin == b.origin && a.x_axis == b.x_axis && a.y_axis == b.y_axis
|
||||
a.origin == b.origin
|
||||
&& a.x_axis == b.x_axis
|
||||
&& a.y_axis == b.y_axis
|
||||
&& a.handle == b.handle
|
||||
&& a.name.eq_ignore_ascii_case(&b.name)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user