From eb84bae779703b63e22be58f28d8aec0b4aa25ac Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 25 Sep 2026 21:46:47 +0300 Subject: [PATCH] fix(3d): seat axis steps on 3D snaps; raised bases follow the cursor (#1030) Height and distance steps (PUSHPULL, CYLINDER, CONE, PYRAMID, BOX, EXTRUDE) replaced the point with the cursor's screen mapping onto the command axis, discarding an acquired 3D object snap. A genuine snap now supplies its own coordinate along the axis, as axis grips already do. Base steps after the first base point (BOX/WEDGE opposite corner and cube size, CYLINDER/CONE/PYRAMID radius) read the cursor on the base plane through that point. A base started on a raised face intersected the ray with world XY instead, so in oblique views the footprint ran far past the mouse. Co-Authored-By: Claude Opus 5.5 --- src/app/update/viewport.rs | 34 ++++++++++++++++++++++++++++-- src/modules/model/cylinder_cmd.rs | 10 +++++++++ src/modules/model/primitive_cmd.rs | 19 +++++++++++++++++ 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index f3340e923..bb34d867c 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -87,6 +87,28 @@ pub(in crate::app) fn snap_keeps_elevation(hit: Option) - matches!(hit, Some(t) if t != crate::snap::SnapType::Grid) } +/// Point on a command's construction axis (height / distance steps). A +/// genuine object snap supplies its own coordinate along the axis, so a +/// height or push distance seats on existing geometry — as axis grips do. +/// Otherwise the cursor maps onto the axis on screen. +fn command_axis_point( + snap: Option, + cursor: Point, + bounds: iced::Rectangle, + view: glam::Mat4, + eye: glam::DVec3, + origin: glam::DVec3, + direction: glam::DVec3, +) -> Option { + match snap.filter(|hit| snap_keeps_elevation(Some(hit.snap_type))) { + Some(hit) => { + let axis = direction.try_normalize()?; + Some(origin + axis * (hit.world - origin).dot(axis)) + } + None => cursor_on_projected_axis(cursor, bounds, view, eye, origin, direction), + } +} + fn cursor_on_projected_axis( cursor: Point, bounds: iced::Rectangle, @@ -2393,7 +2415,15 @@ impl OpenCADStudio { .as_ref() .and_then(|command| command.cursor_axis()) .and_then(|(origin, direction)| { - cursor_on_projected_axis(p, bounds, view_rot, eye, origin, direction) + command_axis_point( + self.tabs[i].snap_result, + p, + bounds, + view_rot, + eye, + origin, + direction, + ) }) .unwrap_or(effective) }; @@ -3955,7 +3985,7 @@ impl OpenCADStudio { .as_ref() .and_then(|command| command.cursor_axis()) .and_then(|(origin, direction)| { - cursor_on_projected_axis(p, bounds, view_rot, eye, origin, direction) + command_axis_point(snap_hit, p, bounds, view_rot, eye, origin, direction) }) .unwrap_or(pt); // A click while dynamic-input fields hold typed values diff --git a/src/modules/model/cylinder_cmd.rs b/src/modules/model/cylinder_cmd.rs index b57cf5425..efed69a4e 100644 --- a/src/modules/model/cylinder_cmd.rs +++ b/src/modules/model/cylinder_cmd.rs @@ -462,6 +462,16 @@ impl CadCommand for CylinderCommand { self.plane = plane; } + /// The radius step reads the cursor on the base plane through the + /// centre, so a base started on a raised face follows the mouse. + fn cursor_plane(&self) -> Option<(DVec3, DVec3)> { + if !matches!(self.step, Step::BaseRadius | Step::BaseDiameter) { + return None; + } + let frame = self.frame?; + Some((frame.z.normalize_or_zero(), frame.origin)) + } + fn cursor_axis(&self) -> Option<(DVec3, DVec3)> { matches!(self.step, Step::Height).then(|| { let frame = self.frame.unwrap_or(self.plane); diff --git a/src/modules/model/primitive_cmd.rs b/src/modules/model/primitive_cmd.rs index 54a78275f..c707b9531 100644 --- a/src/modules/model/primitive_cmd.rs +++ b/src/modules/model/primitive_cmd.rs @@ -2098,6 +2098,25 @@ impl CadCommand for PrimitiveCommand { self.plane = plane; } + /// Base steps after the first base point read the cursor on the base + /// plane through that point, so a base started on a raised face follows + /// the mouse instead of the ray's hit on the working plane below. + fn cursor_plane(&self) -> Option<(DVec3, DVec3)> { + let origin = if self.shape == Shape::Cone { + matches!(self.cone_step, ConeStep::BaseRadius | ConeStep::BaseDiameter) + .then_some(self.cone_frame?.origin) + } else if self.shape == Shape::Pyramid { + (self.pyramid_step == PyramidStep::BaseRadius).then_some(self.pyramid_frame?.origin) + } else if self.shape.rectangular() { + matches!(self.box_step, BoxStep::OppositeCorner | BoxStep::CubeSize) + .then(|| self.pts.first().map(|&first| self.plane.to_world(first))) + .flatten() + } else { + None + }?; + Some((self.plane.z.normalize_or_zero(), origin)) + } + fn cursor_axis(&self) -> Option<(DVec3, DVec3)> { if self.shape == Shape::Cone { return matches!(self.cone_step, ConeStep::Height | ConeStep::HeightAfterTopRadius)