Resolve curve intersections through cadkernel, retain empty exact results, gather analytic candidates independently of tessellation, and bound material uploads. Preserve large MINSERT data while limiting rendering work; remove obsolete review narratives.
github.com/HakanSeven12/OpenCADStudio discussion #1052: constructing a
3-4-5 right triangle by drawing two circles (radius 3 and 4, centers 5
apart) and using OSNAP Intersection to find their crossing points gave
measured side lengths that didn't match the entered radii.
OSNAP's Intersection snap found candidate points by sweeping line
segments from each wire's *tessellated* polyline approximation, never
the entity's real geometry. Circles feeding the resident wire set
always tessellate at a fixed 48 segments (Scene::resident_wires_for
passes wpp=None deliberately, to avoid re-tessellating on every camera
pan/zoom) regardless of radius or zoom — so every circle/arc/ellipse
intersection snap was already off by construction, not just at extreme
zoom levels. Arcs, ellipses, and splines have the same problem via
their own fixed-angle tessellation.
Added an exact intersection path that bypasses tessellation entirely,
used by both call sites in the Intersection-snap code (the indexed/
local fast path and the brute-force wire-pair fallback), falling back
to the original tessellated sweep for anything it can't resolve:
- exact_circle_intersections: closed-form circle/circle intersection
for any pair of Circle/Arc entities (frame-independent — each side's
own arc sweep is checked against its own native axis, so two arcs in
differently rotated coordinate frames still resolve correctly).
- exact_curve_intersections: a more general path for anything
involving a line or an ellipse — embeds both entities into a shared
working plane and dispatches to cadkernel::geom2d::intersect (an
exact/tolerance-converged solver already vendored in this repo's own
dependency but previously unused for this purpose). Handles a
reflection hazard the naive version would miss: when an entity's own
normal is anti-parallel to the shared embedding frame's normal, the
mapping mirrors the parametrization and reverses which physical arc
a [start, end] range refers to — both the Arc and PlanarEllipse
branches detect this and swap start/end to compensate.
The only remaining case still going through the tessellated sweep is a
compound polyline chain with a bulge segment (not a single recognized
curve — it would need its own per-segment bookkeeping this pass
doesn't add).
12 new tests, including the reported 3-4-5 case verified against the
exact expected crossing points, arcs in differently rotated frames,
and the anti-parallel-normal reflection case for both Arc and Ellipse
(independently verified via a Python script for exact precision).
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Keep hidden pre-drag geometry out of snap candidates and avoid empty
scene invalidations. Require stable OTRACK dwell observations and cache
planar-solid visibility during live previews.
Refs #930
Require OTRACK crossings to include the active Extension ray and resolve Shift constraints consistently across preview, grips, and clicks.\n\nRefs #716
Each entity built its own, and what they offered had drifted apart from
what the geometry actually has:
- An arc offered no quadrants at all. It now offers the ones its sweep
covers, and no more.
- A spline offered its control points as endpoints. For a control-point
spline those are not on the curve, so the cursor landed in empty space
beside the geometry. It now offers its two real ends and its midpoint,
with the fit points — which are on the curve — still on offer.
- An elliptical arc had no midpoint and no ends.
A new SnapHint::Endpoint carries an end that cannot go in key_vertices.
Consecutive entries there are taken to be joined by a straight segment
and their midpoint offered, which is right for a line or a polyline and
wrong for anything curved: an arc's two ends would also produce a snap
out on the chord. Splines lose their key_vertices for the same reason —
the chord between two fit points of a curvy spline does not run along
it.
A polyline's midpoints and arc-segment centres stay out of snap_pts
deliberately. The engine already derives a chain's midpoints from its
vertices, and a wire carrying a centre is treated as round elsewhere,
which a polyline with one bulge in it is not.
Co-Authored-By: Claude Opus 5 <[email protected]>
Perpendicular tracking arrived as a second full scan of the drawing's
segments, run next to the edge-direction scan on every acquisition. The
two ask different questions of the same segments — does this one end
here, does this one pass through here — and the scan is the expensive
part, so they now ask together and the drawing is walked once.
Two things fall out of sharing the walk. The perpendicular path picks up
the non-finite guard the edge path already had, so a NaN vertex can no
longer reach the projection. And a perpendicular is now skipped when the
edge rays already cover its direction: at a square corner one segment's
perpendicular is the other segment's own ray, and two rays down the same
line are two identical-looking things for the cursor to choose between.
Behaviour is otherwise unchanged — same tolerance, same near-parallel
dedup, same cap of six each, same skipping of round wires.
Co-Authored-By: Claude Opus 5 <[email protected]>
Extension answered "which acquired ray is this snap on?" twice, from two
different tests: the overlay picked its guide by off-ray distance in
screen pixels, while typed-distance entry re-searched the tracking set in
world space. Two tests can disagree, and the ways they disagree are both
bad -- a guide on screen that typing ignores, or a distance running off
along a ray the user cannot see. The second search could also come up
empty on its own, since only four acquisitions are kept and the oldest is
dropped as new ones arrive.
Ask once. The search that draws the guide now also reports the endpoint it
drew from, the snap carries it, and typed distance reads the ray straight
off that -- the direction is just the way from the endpoint to the snap,
which sits beyond it by construction. The screen-space test stays the only
one, which is what the guide wanted anyway: it stays scale-independent
where a world test would not.
Also correct the tracking-point doc: it claims XZ with Y=0, but
`edge_dirs_at` builds every direction from X/Y with Z zeroed and the rays
follow it.
Co-Authored-By: Claude Opus 5 <[email protected]>
The extension anchors needed the same pane-to-canvas lift the snap
marker already got, but the block that added it was placed next to the
one that was already there rather than merged into it, so `screen` took
the tile origin twice. A single full-canvas tile has no origin to add, so
it only shows once the viewport is split. Fold the anchors into the
existing block.
Also require the ray to pass through the snapped point before offering it
as a distance reference. Only four acquisitions are kept and the oldest is
dropped as new ones arrive, so the ray that produced a snap can be gone by
the time a distance is typed; picking the nearest survivor unconditionally
sent the point off along an unrelated ray instead of falling back to plain
point input. `extension_snap` projects onto the ray in exact f64, so the
tolerance only has to clear rounding, scaled with the coordinate to hold at
UTM magnitudes.
Co-Authored-By: Claude Opus 5 <[email protected]>
Grip drags mutated the document without ever pushing history — dragging
a polyline vertex (or any grip) could not be undone. The release-commit
now swaps the pre-drag backup in, snapshots that PRE state as a "GRIP"
entry, swaps the dragged result back and clears the redo stack; the
Esc-cancel path is unchanged and a no-move engaging click records
nothing.
Ctrl+Z during a drawing command steps the command instead of the
document undo swallowing the in-progress object (which also killed the
active command): new CadCommand::on_undo_step hook, consulted before
undo_active_tab. PLINE pops its last vertex (shared with the U option),
LINE reverts the last committed segment and keeps drawing from the
previous point (rides its existing U/UndoDocument path), and ARC
Continue reverts the last arc and resumes from the prior anchor with
the correct tangent via a new anchor history. With nothing placed yet
each falls through to the document undo.
Also drops the two warnings the #336 work left behind (unused Entity
import, dead Snapper::override_active).
Closes#332
Co-Authored-By: Claude Fable 5 <[email protected]>
Shift+right-click in the viewport opens a cursor-anchored grid of snap
icons (names as hover tooltips). Picking one engages a one-shot
override: only that snap applies — even with running osnap off — until
the next point pick commits, which restores the saved configuration.
Esc drops an unconsumed override; an outside click closes the menu.
The status-bar snap toggles are untouched — this is a separate,
temporary mechanism.
Covers items 1 and 2 of #337 (mid-between-two-points still pending).
Co-Authored-By: Claude Fable 5 <[email protected]>
Object-snap selection ran a strict priority ladder, so a circle's
Center (rank 4) beat its Quadrants (rank 6) anywhere in the aperture —
and the rim-hover Center candidate measured its distance to the CURVE
(≈ 0 all along the rim), masking quadrants at every rim position
(#420). Discrete point snaps now share one tier decided by cursor
distance, with the classic order kept as the tie-break for coincident
candidates (an Endpoint still beats an Intersection on the same
point), and the rim-hover Center drops to a tier below every discrete
snap but above the continuous ones. The #118 guarantee is unchanged:
continuous snaps (Nearest, Perpendicular, …) still never suppress a
discrete snap inside the aperture.
Co-Authored-By: Claude Fable 5 <[email protected]>
seg_intersect_xy found where two segments' XY projections cross and returned
the point at z=0, ignoring height. Two lines at different Z whose plans cross
therefore fired a real Intersection snap when they only *appear* to cross in
top view — that case belongs to Apparent Intersection (view-space).
Rename to seg_intersect_3d and require the segments to be at the same height
at the crossing: compute Z on each at its crossing parameter and reject when
they differ (tolerance scaled to coordinate magnitude). A genuine crossing
now snaps at its true height instead of z=0; a plan-only crossing is left to
Apparent Intersection.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
extension_bases_screen only skipped closed curves (circle/ellipse, keyed on
the Quadrant hint) when finding which endpoints' extension rays a snapped
point lies on. An arc is an open curve with no Quadrant hint, so its
tessellation chords were scanned; one straight chord-extension grazes a
nearby line-line intersection (beyond the arc endpoint, within 4px), drawing
a spurious dashed extension guide radiating from the arc.
Skip every round curve by the Center hint (which arcs, circles and ellipses
all carry and straight lines never do) — the same signal nearest_segment
uses. The intersection snap point itself was already correct; only the stray
guide line is removed.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Snap and hover hit-testing scanned the whole model wire set every cursor move; a heavily block-instanced drawing explodes to millions of wires, so this stalled for over a second per move. Add a cached spatial grid (wire_grid) keyed on the geometry epoch and query only the cursor neighbourhood via Scene::hit_test_wires_near. Gate the O(in-range segments squared) intersection passes on the in-range POINT count, not the wire count — a handful of curved wires, each hundreds of tessellated points, blew the quadratic up while the wire count looked modest. Give zero-size entities away from the origin (POINTs) a real cullable AABB, and fall back to a block sub-wire's own world points when its entity bounding box is degenerate.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Object snap ran several full scans of the drawing on every cursor move,
and the Intersection / Apparent-Intersection passes compare pairs of
in-range segments — O(k^2) in the number of wires whose bounding box
overlaps the aperture. Zoomed out over a large drawing (or with long
lines whose AABB spans the view) that pairwise scan grows to the whole
document and freezes point picking, so MOVE/COPY/paste placement hung.
Count the in-range wires once and gate only the pairwise passes on it:
above the cap they are skipped (you cannot resolve a single intersection
when the aperture spans thousands of segments anyway), while every
single-wire snap (endpoint, midpoint, centre, perpendicular, nearest)
keeps working — so point picking still snaps to corners at any zoom.
Once the first point of a segment is placed, OTRACK now offers an
alignment along the line joining that base point and an acquired
tracking point — the direction *between* them, not only the ortho/polar
axes or the corner's own edges. Acquire an existing corner while drawing
and the cursor locks onto the base→corner line, with typed-distance
measured from the base point.
Adds a base→corner ray per acquired point in otrack_snap, grouped with
that corner so it never self-intersects with the corner's other rays.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Rearchitect SDF text from a document-wide collector to per-entity
production during tessellation, so each entity owns its glyph quads and
composite/nested text is covered for free (no re-explosion, no per-frame
document walk).
- WireModel gains `text_verts`: glyph quads ride on the wire, so they are
cached by the tess memo, cloned on hit, and transformed by the block
expand loop exactly like `points`.
- Text arm (tessellate.rs) builds the quads when SDF is on and suppresses
the strokes; render gathers text_verts from the viewport wire set
(`gather_text_verts`, cached on wire content id) — the old collector is
removed. Covers model, dimension, table, block-internal and paper-sheet
text uniformly.
- MLEADER text (lays out via layout_mtext, not the Text arm) gets its own
SDF branch driven by the per-run GlyphRun.
- Pick box comes from the rendered glyph quads (true text extent), not
entity_aabb which mis-places the box for MTEXT; composite paths no longer
clobber the empty text wire's tight AABB (dim pick box hugs the text).
- Selection / hover highlight via a text-highlight overlay (upload_text_
highlight, keyed on selection_generation) mirroring the selected-wire
xray overlay; base glyphs stay neutral so a deselect leaves no stale tint.
- Debug: env OCS_TEXT_BOX draws each run's glyph-bounds rectangle.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Remove the forces_endpoint_snap mechanism merged in #293:
CadCommand::forces_endpoint_snap, Snapper::snap_forced_corners /
forced_corner_modes, the PlotWindowCommand override, the on_tick +
click-commit wiring, and the associated tests. PLOTWINDOW now uses the
normal snapper like every other command.
Rationale: forcing Endpoint snap on regardless of the global OSNAP master
overrides an explicit user setting and is surprising — a plot window is a
coarse print region that usually wants a margin, not a corner lock, and
the forced set only caught Endpoint (not center/mid/intersection) so it
was a partial, opinionated helper. Parking it pending a design discussion
with the PR author; can be reinstated behind a clearer UX (e.g. respecting
OSNAP, or an opt-in modifier) if justified.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
PLOTWINDOW / PW now forces Endpoint object-snap while picking its two
corners, independent of the global OSNAP toggle, so the plot area lands
exactly on frame corners. The snapper's master toggle and configured
modes are saved and restored around the pick, so the user's OSNAP
settings are unchanged afterward.
- CadCommand::forces_endpoint_snap() opt-in (default false; PlotWindowCommand true).
- Snapper::snap_forced_corners() forces Endpoint only, then restores state.
Intersection is deliberately excluded: it is O(n^2) over the hit-test wire
set and froze the UI on large drawings when a plot window is picked
zoomed-out. Frame corners are endpoints, so Endpoint alone suffices.
- the cursor-move handler routes the plot-window pick through the forced path.
Works in model and paper space (shared pick path). Tests: opt-in flag;
forced mode set is endpoint-only; snapper state is restored after the call.
Two refinements to Parallel snap. The acquired reference now keeps a
point on the line and draws a ∥ marker there, so the user sees which line
the parallel is measured from. And dwelling on that same reference line
again removes it (toggle), matching how OTRACK tracking points clear;
dwelling on a different line switches the reference. Same-line detection
uses parallel direction plus perpendicular screen distance, so it holds
across zoom.
Refs #277
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Parallel snap did nothing — the type was in the osnap set, priority and
marker, but there was no logic behind it. Implement it: hovering a line
(or polyline segment) for a short dwell acquires its direction as a
reference; then while drawing from the last point, when the cursor runs
parallel to that reference the point locks onto the line through the last
point parallel to it, showing the ∥ marker and the alignment guide.
Curves (circle/arc/ellipse) are ignored — parallel to a curve is
undefined. Works with only the Parallel object snap on, independent of
OTRACK; the reference clears with the command's tracking state.
Closes#277
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The earlier #274 fix only corrected the tangent point's coordinate — the
point was still the circle edge facing the cursor, so Tangent snapped
like Nearest. Compute the true tangent from the point the command is
drawing from (from_point): the tangent point's radius is perpendicular to
the line from that point, giving two solutions at acos(r/|CP|) either
side of the C→P direction — take the one nearer the cursor. Falls back to
the facing point for a deferred tangent (object picked first) or a point
inside the circle. A line is tangent to itself everywhere, so the Line
case stays nearest-on-line. Covered by a perpendicularity unit test.
Refs #274
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Catching a plain line-line Intersection re-runs extension_bases_screen to
draw the dashed guide(s) for an *extended* intersection (#247/#259). It
scanned every wire including tessellated circles, and a full circle's
chords point in every direction, so one almost always has an outward
extension that grazes the crossing — drawing spurious guide lines
radiating from nearby circles.
Skip closed curves (full circle / ellipse) in that scan, identified by
their Quadrant snap hints (arcs never carry them, so arc extension guides
are unaffected).
Closes#276
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Two object-snap fixes:
- Tangent on a circle built the snap point as
`(cx + r·nx, cy, cy + r·ny)` — the y-offset landed in Z and Y was stuck
at the centre, so the tangent point sat at the wrong place. Build it as
`(cx + r·nx, cy + r·ny, cz)`.
- Endpoint snapped to a full circle / ellipse: for tessellated curves it
snaps the first/last point, but a closed curve's first/last is a seam,
not an endpoint. Skip Endpoint for closed curves — identified by their
Quadrant snap hints, which arcs never carry — so arcs still snap their
real endpoints.
Closes#274, closes#275
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Extension-driven acquisition (OTRACK off) acquired any dwelled snap
point, including the extension foot the cursor paused on while tracing an
extension. Each such spurious acquisition filled the 4-point buffer and
evicted, oldest-first, the endpoint the user had actually acquired — so
after a few pauses its + marker and its extension disappeared.
Restrict Extension-driven acquisition to real segment endpoints (points
with an incident edge); midpoints, centres, intersections and extension
feet are ignored. OTRACK still acquires any snap point.
Refs #262
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Extension snap extended every segment's line in the whole drawing, so in
a larger project an extension foot was live almost everywhere and the
cursor snapped to it constantly.
Extension now behaves like a temporary tracking point: an endpoint must
first be acquired by dwelling on it, and only then does that segment's
line extend. Acquisition — and the tracking-point marker — is active
whenever OTRACK is on OR the Extension snap is on, so Extension works
independently of OTRACK's on/off state (and is not tied to POLAR). Both
the per-segment extension and the extended-intersection candidates are
gated on an acquired endpoint, and the whole scan is skipped when nothing
is acquired (the common case), keeping large drawings responsive.
Closes#262
Co-Authored-By: Claude Opus 4.8 <[email protected]>
#218: with Ortho on and a base point the axis is now a hard lock — a lone
tracking ray can't pull the cursor off it. Only a tracking ray crossing
the ortho axis locks (the corner-finding case); otherwise the ortho
constraint applies as the fallback, so Ortho reliably locks when OTRACK
is also on.
#219: an acquired corner now contributes an alignment ray along its own
edge direction(s) (captured once at acquisition), so OTRACK follows a
segment's extension, not just the ortho/polar axes. Acquisition is also
captured on leave — a perfectly still cursor emits no move events, so
"pause on a corner, then drag" now reliably acquires it. The acquired
marker projects through the active pane's camera + rect (canvas offset
included) instead of vanishing, and the active alignment path is drawn
as a full-length dashed guide through the corner.
Co-Authored-By: Claude Opus 4.8 <[email protected]>