fix: address review findings on merged #293/#292 hatch+snap

Three defects found reviewing the just-merged PRs:

- PLOTWINDOW forced corner snap (#293) only ran in the on_tick preview
  path, so the marker highlighted an endpoint but the click committed a
  plain snap — with the global OSNAP master (or Endpoint mode) off the
  corner landed at the raw cursor, defeating the feature. Apply the same
  snap_forced_corners branch at the click-commit recompute.

- build_dxf_pattern (#292) wrote the pattern line's LOCAL step into the
  world-frame HatchPatternLine.offset. The new prebaked reader
  inverse-rotates offset assuming world frame, so app-created hatches
  (HATCH command) collapsed their spacing by cos(angle) — ANSI31 at 45deg
  rendered 2.245 instead of 3.175 on both viewport and PDF/plot export.
  Rotate the local step into world here so it round-trips and the stored
  offset is format-correct for other CAD apps.

- far_from_origin_pattern_hatch_still_fills placed its offset ALONG the
  45deg lines (projects to k~0), so it never exercised the span-cap fix and
  passed even with the old absolute-index clamp restored. Move the offset
  perpendicular to the lines so |k| >> the cap. Add
  app_created_hatch_roundtrips_catalog_spacing to guard the offset-frame
  fix (verified: fails at 2.245 without it, passes at 3.175 with it).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-07-06 20:23:59 +03:00
commit cb41910f12
3 changed files with 97 additions and 5 deletions

View file

@ -1828,7 +1828,23 @@ 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;
self.snapper.snap(snap_cursor, p, &all_wires[..], view_rot, eye, bounds, go, gr)
// Mirror the preview path (see on_tick): a command that
// forces corner snap (PLOTWINDOW) must commit the forced
// Endpoint hit, not a plain snap — otherwise the marker
// promises an endpoint the click places at the raw cursor
// whenever the global OSNAP master (or Endpoint mode) is off.
let force_corners = self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.forces_endpoint_snap())
.unwrap_or(false);
if force_corners {
self.snapper.snap_forced_corners(
snap_cursor, p, &all_wires[..], view_rot, eye, bounds, go, gr,
)
} else {
self.snapper.snap(snap_cursor, p, &all_wires[..], view_rot, eye, bounds, go, gr)
}
};
// Snap runs in model space; the result is already model.
let mut pt = snap_hit.map(|s| s.world).unwrap_or(raw);

View file

@ -45,10 +45,20 @@ pub fn build_dxf_pattern(entry: &PatternEntry) -> DxfPattern {
pat.description = entry.description.clone();
for ln in &entry.pat_lines {
let angle_rad = (ln.angle_deg as f64).to_radians();
// The catalog stores the step (dx = shift along the line, dy =
// perpendicular spacing) in the pattern LINE-LOCAL frame. The DWG
// `HatchPatternLine.offset` is a WORLD-space vector — that is how real
// files store it and how `family_from_stored_line` reads it back (it
// inverse-rotates by the line angle). Emitting the raw local step here
// made the reader recover a rotated, too-dense spacing (e.g. ANSI31 at
// 45° collapsed 3.175 → 2.245). Rotate local → world by the line angle
// so the offset is format-correct and round-trips to the exact spacing.
let (ca, sa) = (angle_rad.cos(), angle_rad.sin());
let (ldx, ldy) = (ln.dx as f64, ln.dy as f64);
pat.lines.push(HatchPatternLine {
angle: angle_rad,
base_point: Vector2::new(ln.x0 as f64, ln.y0 as f64),
offset: Vector2::new(ln.dx as f64, ln.dy as f64),
offset: Vector2::new(ldx * ca - ldy * sa, ldx * sa + ldy * ca),
dash_lengths: ln.dashes.iter().map(|&d| d as f64).collect(),
});
}