fix(precision): keep typed coordinates exact in f64 (#311)

Typed values were quantized to the f32 grid before reaching the
entity (e.g. a length of 2000.8 committed as 2000.8020), worsening
with magnitude. The interaction point layer was all glam::Vec3 (f32).

Two-layer fix:

1. Coordinate backbone -> f64. last_point, last_cursor_world,
   dyn_anchor, dyn_ref become DVec3; UcsXform transforms, parse_coord,
   the ucs helper fns, dyn_resolve_point, the OTRACK typed-distance
   paths, command-line coordinate entry and the coordinate readout all
   compute in f64. Screen/GPU/alignment stays f32 (snap engine, render
   geometry, projection, UCS-icon/viewcube drawing) with casts only at
   those boundaries.

2. Per-command committed storage -> DVec3. on_point already received a
   DVec3, but many command impls narrowed it back to f32 (let pt =
   pt.as_vec3(); Vec<Vec3> fields) and re-widened at build, quantizing
   the committed coordinate. Migrated PLINE, SPLINE, MLINE, LENGTHEN,
   INSERT, 3D primitives, every dimension/leader/mleader, ray, revcloud,
   wipeout, attdef, table, tolerance, array center, mview, plot_window,
   plus the QDIM_PLACE handler and qdim_collect_points. Preview,
   hit-test and rubber-band paths keep f32 at the boundary.

Hatch manual boundaries are left for a follow-up: HatchModel.boundary
is structurally Arc<Vec<[f32;2]>> and needs a model-level widen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-07-11 21:26:40 +03:00
1 parent 8ed10ceb2a
commit 57710eec2b
47 files changed
+699 -606

No files matched your search

+6 -3
View File
@@ -283,13 +283,16 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
// (issue #69).
if let Some((base, dir)) = self.otrack_active {
if let Some(dist) = crate::app::expr_eval::eval_number(text.trim()) {
let pt = base + dir * dist as f32;
// Typed distance along the tracking ray: keep the
// magnitude exact in f64 (ray origin/dir are the
// screen-space snapper's f32).
let pt = base.as_dvec3() + dir.as_dvec3() * dist;
self.last_point = Some(pt);
self.dyn_user_reshaped = false;
self.sync_dyn_fields();
self.reset_tracking_after_point();
self.push_ucs_to_cmd(i);
let result = self.tabs[i].active_cmd.as_mut().map(|c| c.on_point(pt.as_dvec3()));
let result = self.tabs[i].active_cmd.as_mut().map(|c| c.on_point(pt));
if let Some(r) = result {
let task = self.apply_cmd_result(r);
self.refresh_active_cmd_preview(i);
@@ -331,7 +334,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
self.sync_dyn_fields();
self.reset_tracking_after_point();
self.push_ucs_to_cmd(i);
let result = self.tabs[i].active_cmd.as_mut().map(|c| c.on_point(wcs_pt.as_dvec3()));
let result = self.tabs[i].active_cmd.as_mut().map(|c| c.on_point(wcs_pt));
if let Some(r) = result {
let task = self.apply_cmd_result(r);
// The rubber-band preview that the command
+32 -31
View File
@@ -171,9 +171,9 @@ impl OpenCADStudio {
self.tabs[i].dyn_guide = spec.guide;
self.tabs[i].dyn_anchor = match spec.anchor {
crate::command::DynAnchor::LastPoint => self.last_point,
crate::command::DynAnchor::Point(p) => Some(p.as_vec3()),
crate::command::DynAnchor::Point(p) => Some(p),
};
self.tabs[i].dyn_ref = spec.ref_point.map(|v| v.as_vec3());
self.tabs[i].dyn_ref = spec.ref_point;
}
/// Track cursor dwell over a selected entity's grip. Sets
@@ -212,13 +212,11 @@ impl OpenCADStudio {
/// `None` for any other entity kind. Uses `last_point` (the final pick) to
/// pick the endpoint the pen actually finished on.
pub(in crate::app) fn update_cont_anchor(&mut self, entity: &acadrust::EntityType) {
let last = self
.last_point
.map(|v| glam::DVec3::new(v.x as f64, v.y as f64, v.z as f64));
let last = self.last_point;
self.cont_anchor = crate::modules::draw::draw::arc::continue_anchor(entity, last);
}
pub(in crate::app) fn dyn_resolve_point(&self) -> Option<glam::Vec3> {
pub(in crate::app) fn dyn_resolve_point(&self) -> Option<glam::DVec3> {
use crate::app::document::DynComponent;
let i = self.active_tab;
let fields = &self.tabs[i].dyn_fields;
@@ -229,7 +227,7 @@ impl OpenCADStudio {
let base = self.tabs[i]
.dyn_anchor
.or(self.last_point)
.unwrap_or(glam::Vec3::ZERO);
.unwrap_or(glam::DVec3::ZERO);
// Buffer value parsed as f32 (de-scaled by the role so a typed diameter
// becomes a radius), or the supplied geometric live value. Width/Height
// are shown unsigned, so a typed value normally takes the sign of the
@@ -241,13 +239,13 @@ impl OpenCADStudio {
// `dyn_user_reshaped`, so it cleanly distinguishes comma-cartesian entry
// from Tab-separated dimension entry (#269).
let comma_cartesian = self.dyn_user_reshaped;
let val = |idx: usize, live: f32| -> f32 {
let val = |idx: usize, live: f64| -> f64 {
match fields[idx]
.buffer
.as_ref()
.map(|s| s.trim().replace(',', "."))
.and_then(|s| crate::app::expr_eval::eval_number(&s).map(|v| v as f32))
.map(|v| v / fields[idx].role.value_scale())
.and_then(|s| crate::app::expr_eval::eval_number(&s))
.map(|v| v / fields[idx].role.value_scale() as f64)
{
Some(v) => {
if matches!(
@@ -277,12 +275,12 @@ impl OpenCADStudio {
// A typed angle is shown unsigned (0..180); give it the sign of the
// cursor's current side so an entry made below the X axis sweeps
// downward to match the arc instead of mirroring up. Untyped → live.
let angle_rad = |idx: usize| -> f32 {
let angle_rad = |idx: usize| -> f64 {
match fields[idx]
.buffer
.as_ref()
.map(|s| s.trim().replace(',', "."))
.and_then(|s| crate::app::expr_eval::eval_number(&s).map(|v| v as f32))
.and_then(|s| crate::app::expr_eval::eval_number(&s))
{
Some(mag) => mag.abs().to_radians().copysign(dy),
None => live_a,
@@ -298,13 +296,13 @@ impl OpenCADStudio {
(self.tabs[i].dyn_ref, comps.as_slice())
{
let axis = (ref_pt - base).normalize_or_zero();
let perp = glam::Vec3::new(-axis.y, axis.x, 0.0);
let perp = glam::DVec3::new(-axis.y, axis.x, 0.0);
let signed = (w - base).dot(perp);
let typed = fields[0]
.buffer
.as_ref()
.map(|s| s.trim().replace(',', "."))
.and_then(|s| crate::app::expr_eval::eval_number(&s).map(|v| v as f32));
.and_then(|s| crate::app::expr_eval::eval_number(&s));
let h = match typed {
Some(v) => v.abs().copysign(signed),
None => signed,
@@ -316,28 +314,28 @@ impl OpenCADStudio {
// position relative to base; typed values are relative deltas.
let has_base = self.last_point.is_some();
// Relative result: base + the typed UCS-frame offset mapped to world.
let rel = |off_ucs: glam::Vec3| base + xf.vec_to_wcs(off_ucs);
let rel = |off_ucs: glam::DVec3| base + xf.vec_to_wcs(off_ucs);
match comps.as_slice() {
[DynComponent::X, DynComponent::Y] if has_base => {
Some(rel(glam::Vec3::new(val(0, dx), val(1, dy), 0.0)))
Some(rel(glam::DVec3::new(val(0, dx), val(1, dy), 0.0)))
}
[DynComponent::X, DynComponent::Y] => {
Some(glam::Vec3::new(val(0, w.x), val(1, w.y), base.z))
Some(glam::DVec3::new(val(0, w.x), val(1, w.y), base.z))
}
[DynComponent::X, DynComponent::Y, DynComponent::Z] if has_base => {
Some(rel(glam::Vec3::new(val(0, dx), val(1, dy), val(2, dz))))
Some(rel(glam::DVec3::new(val(0, dx), val(1, dy), val(2, dz))))
}
[DynComponent::X, DynComponent::Y, DynComponent::Z] => {
Some(glam::Vec3::new(val(0, w.x), val(1, w.y), val(2, base.z)))
Some(glam::DVec3::new(val(0, w.x), val(1, w.y), val(2, base.z)))
}
[DynComponent::Distance, DynComponent::Angle] => {
let d = val(0, live_d);
let a = angle_rad(1);
Some(rel(glam::Vec3::new(d * a.cos(), d * a.sin(), 0.0)))
Some(rel(glam::DVec3::new(d * a.cos(), d * a.sin(), 0.0)))
}
[DynComponent::Distance] => {
// Keep the cursor's direction (in UCS), override the magnitude.
let dir = glam::Vec3::new(dx, dy, 0.0).normalize_or(glam::Vec3::X);
let dir = glam::DVec3::new(dx, dy, 0.0).normalize_or(glam::DVec3::X);
Some(rel(dir * val(0, live_d)))
}
[DynComponent::Angle] => {
@@ -346,7 +344,7 @@ impl OpenCADStudio {
// magnitude — keep it literal. Only the polar Distance+Angle
// pair uses the cursor-signed `angle_rad`.
let a = val(0, live_a.to_degrees()).to_radians();
Some(rel(glam::Vec3::new(live_d * a.cos(), live_d * a.sin(), 0.0)))
Some(rel(glam::DVec3::new(live_d * a.cos(), live_d * a.sin(), 0.0)))
}
_ => None,
}
@@ -433,7 +431,7 @@ impl OpenCADStudio {
let anchor = self.tabs[i]
.dyn_anchor
.or(self.last_point)
.unwrap_or(glam::Vec3::ZERO);
.unwrap_or(glam::DVec3::ZERO);
let cur = self.tabs[i].last_cursor_world;
let a_cur = (cur.y - anchor.y).atan2(cur.x - anchor.x);
let a_ref = self.tabs[i]
@@ -441,11 +439,11 @@ impl OpenCADStudio {
.map(|r| (r.y - anchor.y).atan2(r.x - anchor.x))
.unwrap_or(0.0);
let mut d = a_cur - a_ref;
while d > std::f32::consts::PI {
d -= std::f32::consts::TAU;
while d > std::f64::consts::PI {
d -= std::f64::consts::TAU;
}
while d <= -std::f32::consts::PI {
d += std::f32::consts::TAU;
while d <= -std::f64::consts::PI {
d += std::f64::consts::TAU;
}
if d < 0.0 {
format!("-{t}")
@@ -479,7 +477,10 @@ impl OpenCADStudio {
.find_map(|f| f.buffer.clone())
{
if let Some(dist) = crate::app::expr_eval::eval_number(text.trim()) {
let pt = base + dir * dist as f32;
// Distance is typed, so resolve in f64: the tracking ray
// origin/dir come from the screen-space snapper (f32) but
// the magnitude the user entered stays exact.
let pt = base.as_dvec3() + dir.as_dvec3() * dist;
self.last_point = Some(pt);
for f in self.tabs[i].dyn_fields.iter_mut() {
f.buffer = None;
@@ -489,7 +490,7 @@ impl OpenCADStudio {
self.sync_dyn_fields();
self.reset_tracking_after_point();
self.push_ucs_to_cmd(i);
let result = self.tabs[i].active_cmd.as_mut().map(|c| c.on_point(pt.as_dvec3()));
let result = self.tabs[i].active_cmd.as_mut().map(|c| c.on_point(pt));
let task = result.map(|r| self.apply_cmd_result(r))?;
self.refresh_active_cmd_preview(i);
return Some(task);
@@ -548,7 +549,7 @@ impl OpenCADStudio {
self.sync_dyn_fields();
self.reset_tracking_after_point();
self.push_ucs_to_cmd(i);
let result = self.tabs[i].active_cmd.as_mut().map(|c| c.on_point(pt.as_dvec3()));
let result = self.tabs[i].active_cmd.as_mut().map(|c| c.on_point(pt));
for f in self.tabs[i].dyn_fields.iter_mut() {
f.buffer = None;
}
@@ -578,7 +579,7 @@ impl OpenCADStudio {
let previews = self.tabs[i]
.active_cmd
.as_mut()
.map(|c| c.on_preview_wires(cur.as_dvec3()))
.map(|c| c.on_preview_wires(cur))
.unwrap_or_default();
self.tabs[i].scene.set_preview_wires(previews);
}
+36 -30
View File
@@ -190,7 +190,7 @@ impl OpenCADStudio {
)
});
let pick = |cam: &crate::scene::view::camera::Camera| match plane {
Some((normal, origin)) => cam.pick_on_plane(p, bounds, normal, origin),
Some((normal, origin)) => cam.pick_on_plane(p, bounds, normal.as_vec3(), origin),
None => cam.pick_on_target_plane(p, bounds),
};
match edit_cam {
@@ -592,7 +592,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
sel.orbit_pivot = self.tabs[i]
.scene
.orbit_pivot()
.or(Some(self.tabs[i].last_cursor_world.as_dvec3()));
.or(Some(self.tabs[i].last_cursor_world));
}
let pivot = sel.orbit_pivot;
drop(sel);
@@ -768,13 +768,13 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
}
if snap_hit.is_none() {
let base = grip.origin_world.as_vec3();
let base = grip.origin_world;
let ucs_xf = self.tabs[i].ucs_xform();
if self.ortho_mode {
snapped = ortho_constrain(snapped.as_vec3(), base, &ucs_xf).as_dvec3();
snapped = ortho_constrain(snapped, base, &ucs_xf);
} else if self.polar_mode {
snapped = polar_constrain_near(
snapped.as_vec3(),
snapped,
base,
self.polar_increment_deg,
view_rot,
@@ -782,8 +782,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
bounds,
self.snapper.osnap_radius_px,
&ucs_xf,
)
.as_dvec3();
);
}
}
@@ -835,7 +834,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
height: vp_size.1,
};
let world = self.cursor_model_point(i, &edit_cam, p, bounds);
self.tabs[i].last_cursor_world = world.as_vec3();
self.tabs[i].last_cursor_world = world;
}
// Rollover highlight: when idle (no active command, no
@@ -936,7 +935,10 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
)
} else {
let (go, gr) = self.tabs[i].ucs_grid_basis();
self.snapper.from_point = self.last_point;
// The snapper is a screen-space (f32) engine; the f64
// base only matters for typed-input precision, so hand it
// the downcast point here.
self.snapper.from_point = self.last_point.map(|p| p.as_vec3());
self.snapper
.snap(snap_cursor, p, &all_wires[..], view_rot, eye, bounds, go, gr)
};
@@ -980,7 +982,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
eye,
bounds,
step,
self.last_point,
self.last_point.map(|p| p.as_vec3()),
self.ortho_mode && !is_window_corner,
ucs,
)
@@ -997,7 +999,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
if self.tabs[i].snap_result.is_none() && otrack_hit.is_none() {
if let Some(par) = self.snapper.parallel_snap(
cursor_world.as_vec3(),
self.last_point,
self.last_point.map(|p| p.as_vec3()),
view_rot,
eye,
bounds,
@@ -1005,7 +1007,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
if let (Some(base), Some((dir, _))) =
(self.last_point, self.snapper.parallel_ref)
{
self.otrack_active = Some((base, dir));
self.otrack_active = Some((base.as_vec3(), dir));
}
self.tabs[i].snap_result = Some(par);
}
@@ -1034,11 +1036,10 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
if let Some(base) = self.last_point {
let ucs_xf = self.tabs[i].ucs_xform();
if self.ortho_mode {
pt = ortho_constrain(pt.as_vec3(), base, &ucs_xf)
.as_dvec3();
pt = ortho_constrain(pt, base, &ucs_xf);
} else if self.polar_mode {
pt = polar_constrain_near(
pt.as_vec3(),
pt,
base,
self.polar_increment_deg,
view_rot,
@@ -1046,8 +1047,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
bounds,
self.snapper.osnap_radius_px,
&ucs_xf,
)
.as_dvec3();
);
}
}
}
@@ -1060,7 +1060,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
}
pt
};
self.tabs[i].last_cursor_world = effective.as_vec3();
self.tabs[i].last_cursor_world = effective;
self.tabs[i].last_cursor_screen = p_full;
// Project the step anchor (an explicit `dyn_anchor` or the
// last point) so the dynamic-input overlay can place its
@@ -1069,8 +1069,8 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
// origin (`tile_b`, the viewport rect inside a viewport) so
// the DYN guide/labels share the canvas frame with
// `last_cursor_screen` (= p_full, canvas space).
let proj = |bp: glam::Vec3| {
let ndc = view_rot.project_point3((bp.as_dvec3() - eye).as_vec3());
let proj = |bp: glam::DVec3| {
let ndc = view_rot.project_point3((bp - eye).as_vec3());
iced::Point::new(
(ndc.x + 1.0) * 0.5 * bounds.width + tile_b.x,
(1.0 - ndc.y) * 0.5 * bounds.height + tile_b.y,
@@ -1096,7 +1096,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
.and_then(|c| c.window_first_corner());
self.tabs[i].scene.selection.borrow_mut().preview_box =
if is_window_corner {
window_first.map(|c1| (proj(c1.as_vec3()), p_full, true))
window_first.map(|c1| (proj(c1), p_full, true))
} else {
None
};
@@ -1217,6 +1217,8 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
// the snapped angle direction, extending across the drawing.
if self.polar_mode && !needs_entity {
if let Some(base) = self.last_point {
// Screen-space dotted guide (render geometry is f32).
let base = base.as_vec3();
let effective = effective.as_vec3();
let dx = effective.x - base.x;
let dy = effective.y - base.y;
@@ -1367,7 +1369,12 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
} else {
None
};
crate::ui::overlay::ucs_icon_hit(cam.view_proj_rte(bounds), bounds, (ux, uy, uz), os)
crate::ui::overlay::ucs_icon_hit(
cam.view_proj_rte(bounds),
bounds,
(ux.as_vec3(), uy.as_vec3(), uz.as_vec3()),
os,
)
}
/// Apply one frame of a UCS-icon grip drag: map the cursor onto the active
@@ -1896,7 +1903,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
.snap_tangent_only(snap_cursor.as_vec3(), p, &all_wires[..], view_rot, eye, bounds)
} else {
let (go, gr) = self.tabs[i].ucs_grid_basis();
self.snapper.from_point = self.last_point;
self.snapper.from_point = self.last_point.map(|p| p.as_vec3());
self.snapper.snap(snap_cursor, p, &all_wires[..], view_rot, eye, bounds, go, gr)
};
// Snap runs in model space; the result is already model.
@@ -1916,7 +1923,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
};
let ucs = self.tabs[i].scene.viewcube_ucs_mat();
self.snapper
.otrack_snap(raw.as_vec3(), view_rot, eye, bounds, step, self.last_point, self.ortho_mode && !is_window_corner, ucs)
.otrack_snap(raw.as_vec3(), view_rot, eye, bounds, step, self.last_point.map(|p| p.as_vec3()), self.ortho_mode && !is_window_corner, ucs)
} else {
None
};
@@ -1934,10 +1941,10 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
if let Some(base) = self.last_point {
let ucs_xf = self.tabs[i].ucs_xform();
if self.ortho_mode {
pt = ortho_constrain(pt.as_vec3(), base, &ucs_xf).as_dvec3();
pt = ortho_constrain(pt, base, &ucs_xf);
} else if self.polar_mode {
pt = polar_constrain_near(
pt.as_vec3(),
pt,
base,
self.polar_increment_deg,
view_rot,
@@ -1945,8 +1952,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
bounds,
self.snapper.osnap_radius_px,
&ucs_xf,
)
.as_dvec3();
);
}
}
}
@@ -2128,7 +2134,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
self.tabs[i].dyn_active = 0;
}
}
self.last_point = Some(world_pt.as_vec3());
self.last_point = Some(world_pt);
self.dyn_user_reshaped = false;
self.sync_dyn_fields();
self.reset_tracking_after_point();
@@ -2147,7 +2153,7 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task<Message> {
.as_ref()
.and_then(|c| c.resolved_anchor())
{
self.last_point = Some(a.as_vec3());
self.last_point = Some(a);
}
handled
} else {