scene/tess.rs held the free parallel-tessellation functions
(tessellate_entity, lod_stub_wire, entity_aabb, …) that the giant-file
split had left at the scene root. They belong with the rest of the
tessellation code, so move the file to scene/convert/tess.rs alongside
tessellate.rs / tess_util.rs and re-point the re-export.
Leaves the scene/ root as a consistent set of `impl Scene` method
groups; the free-function outlier is gone.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Group the flat src/ui/ dir: move the 9 style editors into ui/style/
(dimstyle, mleaderstyle, mlstyle, plotstyle, textstyle, tablestyle,
point_style, style_list, style_manager) and the 6 popups into
ui/popup/ (cycle, isolate, scale, selection_filter, snap, units).
Call sites repointed to crate::ui::style::/crate::ui::popup::; no
compat re-exports. ui/ root drops from 36 to 21 files.
- Rename app/cmd_result.rs -> app/command_driver.rs: the file is the
StepInput->feed_command driver, not the CmdResult type (which lives
in command.rs). Name now matches contents.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The core ribbon registry no longer needs build-time generation. Replace
the generated all_modules() (included from OUT_DIR) with a hand-written
static list in src/modules/registry.rs, listing the seven built-in tabs
in display order.
Trim build.rs down to its only remaining job: embedding the Windows
executable icon via winresource (issue #107). The external plugin system
is unaffected — plugin::registry still appends dynamically-loaded add-on
tabs on top of the core list.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Relocate three root-level modules into io/, where they belong by concern:
- update_check.rs -> io/update_check.rs (its own doc-comment notes it
mirrors how crate::io wraps blocking calls in async).
- patterns.rs, linetypes.rs -> io/ (standard resource catalogs parsed
from asset files and populated into the document — IO-shaped work).
Fix the moved files' include_str! paths (one level deeper), add the
mod declarations to io/mod.rs, drop them from lib.rs/main.rs, and
re-point all crate:: references to crate::io::. No behavior change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Delete src/modules/draw/modify/extend.rs (a 1-line placeholder with no
tool()/logic; `extend::` was referenced nowhere) and its `mod extend;`.
- Flatten single-file module dirs to the modern flat style:
src/command/mod.rs -> src/command.rs, src/snap/mod.rs -> src/snap.rs.
Parent `mod command;`/`mod snap;` are unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Break up the four giant files (~29k lines total) into focused submodule
directories, one concern per file. Pure mechanical moves — behavior is
unchanged.
- app/update.rs (9650) -> app/update/{mod,viewport,command,style,file,
dynamic,dialog,util}. The 384-arm update_inner match stays in mod.rs
with thin delegating arms; the 66 fattest arm bodies move to topic
methods. Arm set preserved exactly (384 -> 384).
- scene/mod.rs (9017) -> scene/{mod,entity,tess,hittest,layout,paper,
mspace,project,selection,modify,group_layer,preview}. The impl Scene
body is split across files via inherent-impl-per-file; struct, ctor,
fields and load-time helpers stay in the root. fn count 123 -> 123.
- app/commands.rs (6082) -> app/commands/* grouped by command family.
The 233-arm dispatch match becomes source-ordered per-family
dispatch_* handlers, preserving first-match precedence (233 -> 233).
- app/view.rs (4686) -> app/view/{mod,overlay,modal,viewcube,controls}.
Largest file drops from 9650 to 3343. Builds clean; app launches and
loads plugins with no panic.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reorder the model-tile control chip to grid, grid snap, render mode,
vertical split, horizontal split. Add a close button (shown only with
more than one model tile) that merges the active pane into its
longest-contact neighbour.
Divider drags now clamp each side to the minimum tile size, so dragging
a boundary to the screen edge no longer collapses a pane — closing is
done with the button instead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Builds on the background-persistence change: let BACKGROUND take a single
named-colour token in addition to `<r> <g> <b>`, so a common dark/light
background is one word instead of a remembered triplet:
BACKGROUND WHITE | BLACK | GRAY|GREY | DARKGRAY|DARKGREY | LTGRAY
Colour parsing moves into `parse_background_color`; the persisted-default
and recolor paths are unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The BACKGROUND command set a per-tab colour that was lost on restart and
didn't apply to new drawings, so there was no way to make a non-black
background stick. Persist the chosen model/paper background in UserSettings
(settings.txt) and re-apply it on launch and to every drawing tab created
afterward.
- settings.rs: persist `bg_color` / `paper_bg_color` as 0–255 RGB triplets.
- App gains `default_bg_color` / `default_paper_bg_color`; current_settings /
apply_settings round-trip them, and apply_settings pushes the restored
colour onto existing tabs. New/opened/replaced tabs call `apply_bg_default`.
- BACKGROUND updates the persisted default, saved centrally via
persist_settings_if_changed; RESET clears it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
DistCommand stored the first point as Vec3 and ran on_point through
`let pt = pt.as_vec3();`, downcasting the f64 picked point to f32 before
computing distance/deltas. At survey-scale coordinates (eastings ~5e5)
f32's 24-bit mantissa gives ~0.03–0.06 unit resolution, so a snapped
endpoint measured slightly off — matching the report in #193.
Store `first` as DVec3 and keep the math in f64; cast to f32 only for
the visual preview wire vertices.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Remove the unused `crate::par::prelude` glob import in wire_gpu.rs and
the never-called `viewport_at_paper_point` method; fold its doc into the
screen-space twin that is actually used so no intra-doc link dangles.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The call-timeout test asserted the real 10 s Dispatch floor, adding 10 s+
to every suite run. Add a cfg(test)-only OCS_PLUGIN_TEST_FLOOR_SECS seam
so the test fires at its 1 s base instead; production still enforces the
hard safety floors. Test now finishes in ~1 s.
Also document in mark_dead that the host-side socket is closed indirectly
by killing the child (which unblocks and drops the detached reader
thread's Stream), not by the take() in mark_dead itself.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Picks up acadrust 2a59d3e, which reads/writes LWPOLYLINE thickness and
extrusion as BD / 3BD instead of BT / BE. Without it, polylines carrying
a thickness or extrusion flag parsed with a garbage normal and point
count and were dropped by the corrupt-entity guard on open — e.g. ~1600
valid polylines in the PLN_Sev block of a route drawing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The model-space wire geometry is camera-independent, yet every pan/zoom
re-tessellated the whole model and re-uploaded it to the GPU. On a
block/text-heavy drawing that is millions of segments, costing a ~100ms
re-tessellation plus a ~150ms GPU re-upload per camera change (~258ms
freeze) while the GPU itself draws the full set comfortably in real time.
Tessellate the full, un-culled model once per geometry epoch, hold it
resident, and return it for every camera and tile. A pan/zoom now only
changes the view uniform; no frustum cull, no zoom LOD, no re-tess or
re-upload on camera moves. Pan/zoom frame time drops to ~7ms (~140fps)
with no stutter.
Removes the now-dead per-tile cull cache, pan-invariant hash, AABB
containment helper, and the reuse-during-motion / settle-regen machinery
that an earlier iteration introduced. Also exempts Face3D from the
sub-pixel stub LOD: a flat face is trivially cheap to tessellate, so it
is drawn full at any zoom and no longer pops a cube stub at the threshold.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`wipeout_boundary_2d` built the fill boundary from the wipeout's
insertion point cast straight to f32, stored in a HatchModel with
world_origin = [0, 0]. At UTM-scale coordinates (~1e6) f32 resolves to
~0.06, so the boundary collapsed and the relative-to-eye fill shader
cancelled `boundary - eye` to noise — the mask rendered as squares.
Return the boundary as small f32 offsets relative to a world_origin
(the insertion point, kept in f64) and set HatchModel.world_origin to
it at both the model-space and paper/viewport fill sites. Same fix as
the wide-polyline and on-create hatch fills.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The solid fill band of a wide LwPolyline / Polyline2D was built from
absolute WCS vertex coordinates cast straight to f32, then handed to a
HatchModel with world_origin = [0, 0]. At UTM-scale coordinates (~1e6)
f32 resolves to ~0.06, so the half-width offset collapsed and the
relative-to-eye fill shader cancelled `boundary - eye` to noise — the
band rendered as a string of squares. (Exploding the polyline drops the
width, hence no band and a clean line, which is why that "fixed" it.)
Build the band relative to a world_origin (the first vertex) in f64 and
store the small f32 offsets, mirroring how baked hatch fills keep
precision. Same class of bug as the on-create hatch-fill fix.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every wire segment is one instance, and each carried the wire's color,
line-weight, dash pattern and draw-depth (~44 B) inline — re-fetched per
segment even though they're constant along the wire. On native, move
those into a per-wire `WireConst` storage buffer (group 1) indexed by a
`wire_id` on the instance. The instance drops from 104 B to 60 B (~42 %)
across every entity's wires, and the shared constants are fetched once
per wire (cached) instead of once per segment — less vertex-attribute
bandwidth and less GPU upload on rebuilds.
WebGL2 has no vertex-stage storage buffers, so the wasm build keeps the
original self-contained fat instance and wire.wgsl; native uses the new
slim instance, wire_indexed.wgsl, and a group-1 bind group built per
batch in WireGpu::from_run / from_batch. Visual output is unchanged
(colors are now full f32 rather than 8-bit, if anything more precise).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two follow-ups to dropping the hatch boundary outline from the wire set:
- `closed_outlines` copied segment-list wire points (LwPolyline emits
each segment as a NaN-separated pair, so every shared corner repeats)
straight into the hatch boundary, giving two grips at each corner.
Collapse the points back into a clean ring — skip NaN separators and
any vertex coincident with the previous one, and drop a trailing
vertex equal to the first. One grip per corner; point-in-polygon and
the BOUNDARY / GRADIENT commands get the clean ring too.
- Edit previews (move / copy / array / grip-drag) tessellate the edited
entity into preview wires, but a hatch now produces none, so a dragged
hatch didn't update until release. Build a live outline from the
current HatchModel for the preview only (`apply_grip` keeps it in
step), so the shape tracks the cursor again. The normal render still
shows just the fill.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A HATCH was emitting its boundary as a dense outline polyline in the
wire set on top of its fill. On hatch-heavy drawings that outline was
the dominant wire-instance cost — 14.9M of 19.1M segments on the #131
sample — and on integrated GPUs (shared system RAM) the per-segment
instances pushed memory past the OOM threshold on open.
Skip the outline in `tessellate`: hatches render as a fill, are
highlighted by their fill tint when selected (#71), and are still
picked by their fill area via the existing `click_hit_hatch` path, so
nothing else depends on it. Wire segments drop ~78% on the sample.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`add_hatch` let `add_entity` build the render model from the new DXF
entity (via `hatch_model_from_dxf`, AABB-centred `world_origin`) and
insert it — then overwrote it with the command-built model, whose
`world_origin` is [0, 0]. After the world_offset removal the fill is
drawn relative-to-eye from `world_origin`, so a [0, 0] origin with a
full-WCS boundary cancels to garbage in f32 and the fill renders
off-screen — invisible until a later edit rebuilt it from the DXF.
Drop the overwrite so creation keeps the correct seed; it now matches
what an edit or reload produces. Fixes the regression for hatches drawn
away from the origin.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Preview / interim / grip-drag overlay wires were drawn in the main wire
pass with the depth-tested wire_pipeline (depth_compare=LessEqual), so a
live command preview could be hidden behind occluding geometry — a 3D
solid, or 2D geometry drawn in front of it.
Draw them with the existing wire_xray_pipeline instead (depth_compare=
Always, no depth write), the same always-on-top path selection highlights
already use. Previews are now never occluded, for every command.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
TRIM ignored circles: the target dispatch handled Line/Arc/Ellipse/
Spline/Ray/XLine/LwPolyline but had no Circle arm, so clicking a circle
fell through to a no-op. Add `trim_circle()` — a full circle needs ≥2
boundary crossings; the arc segment under the click is removed and the
circle becomes a single Arc (center/radius/thickness/normal preserved).
Wire it into both the pick (apply) and hover-preview paths.
Also fix `arc_pts`, which emitted points as [x, y, cy+r·sin] — the XZ
plane — so arc and circle previews drew in the wrong plane (obvious on a
full circle). Match the XY convention used by line_pts and fillet's
arc_pts: [cx+r·cos, cy+r·sin, z]; callers now pass center.z.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Tracks acadrust main at 0.4.x: plotstyle/material common-entity
ordering fix plus in-flight DWG reader work. Patch comment updated
to match; drop the git patch once these land in a published 0.4 crate.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A DWG DIMENSION references an anonymous *D<n> block that holds its drawn
geometry; AutoCAD-family readers render that block rather than re-deriving it
from the definition points. OCS re-tessellates dimensions for display and never
materialises the block, so on save a created dimension goes out referencing a
block that doesn't exist and the writer emits a null block handle. Strict
readers (DWG TrueView, QCAD) then drop the dimension or demand a recovery, and
lenient ones (BricsCAD) regenerate it at a different position.
Add bake_dimension_blocks(), called on the cloned document in
io::save_as_version / save_to_bytes, which builds a *D<n> anonymous block from
each block-less dimension's exploded geometry (extension lines + dimension line
+ measurement text) and points its block_name at it. Dimensions that already
own a block (imported, or copied via the *D-cloning copy path) are left
untouched.
Verified end-to-end: before, AutoCAD/Civil 3D 2026 reads 0 dimensions from an
OCS-saved file; after baking it reads all of them.
Fixes#178, #181, #182.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up cleanups on the merged TTF-fill work:
- Drop the unconditional resolve_text_style log line — it fired for every
text style and spammed ~7k stderr lines opening a single real drawing.
- Drop the per-glyph tessellation-error eprintln; a malformed font would
otherwise log once per character. The glyph still falls back silently.
- Tessellate glyph fills with the nonzero winding rule (lyon defaults to
even-odd, which mis-fills TrueType outlines whose contours overlap).
- Make the sysfont prefix fallback deterministic (sorted) and require ≥3
chars so a 1–2 letter request can't grab an arbitrary family.
- Remove the unrelated windows-gnu --exclude-all-symbols linker flag that
slipped into .cargo/config.toml.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A drawing opened with a twisted view made a TOP-face click keep the
twist, so north ended up at the bottom. Add Camera::snap_to_face, which
snaps to a canonical orientation — north (UCS +Y) up for top/bottom,
world (UCS +Z) up for the side elevations — ignoring the current
up-sense. Face clicks use it; edges/corners still preserve their up so
they spin smoothly around the clicked feature.
The Home button routes through the same path, giving a reliable reset
for a twisted view.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Build the ViewCube out into a full navigation widget:
- Compass ring painted in the cube's ground plane (z = base) so it
reads as a ground compass and foreshortens with the view.
- N/E/S/W cardinals painted flat onto the ring band; clicking one snaps
to that side elevation. Added S/W glyphs to the bitmap atlas.
- Home button (top-down view), roll arrows (90° roll about the view
axis), and four nudge triangles (90° tip/spin) as SVG iced controls
laid out around the cube. New home.svg.
- WCS / named-UCS selector under the cube, switching the active UCS.
The cube hit area now shares one stack with the control buttons, so a
click on a button is caught by the button while a click on the cube
falls through. Face clicks snap to the highlighted region rather than a
cursor position the viewport's move handler can overwrite.
Camera gains home_view / roll_by / nudge_90; floating viewports drive
the same ops through mutate_active_viewport_camera, which re-encodes the
result to the stored view direction + twist.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Lay each label's glyph quads out in the face's own local plane and
project the corners individually, instead of drawing screen-aligned
billboard text at the projected face centre. The letters now skew with
perspective and sit on the surface as the cube rotates.
Per-face (u, v) basis vectors are chosen so u x v equals the outward
normal, which keeps the text from ever reading mirrored.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A dimension's drawn geometry lives in a baked anonymous *D block. COPY
cloned the Dimension entity (translating its definition points) but left
its block_name pointing at the source block, whose sub-entities stay at
the original location — so the copy rendered on top of the original and
appeared not to copy at all.
Add `clone_transformed_block`: when copying a dimension that has a baked
block, duplicate that block under a fresh *D name with every sub-entity
transformed by the same offset, and repoint the copy at it. The copy now
lands at the drop point with its baked geometry and text preserved (no
synthesis, so diameter/radius values stay correct). Covered by a portable
regression test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Text elements previously rendered as hollow wireframe outlines and sometimes
fell back to the wrong system font due to case-sensitive exact matching. Also,
the viewport background grid drew on top of solid entities.
Case-insensitive and partial-name fallback matching (e.g., mapping "arialn"
to "Arial Narrow") is now added to `sysfont::canonical_family_name`, ensuring
text resolves to the correct installed system font.
TrueType font contours are now triangulated into solid fills (`fill_tris`)
using `lyon_tessellation` in `ttf_glyph.rs`. To prevent the GPU from misclassifying
this 2D text as a 3D mesh (which broke flat shading), text tessellation splits
the output into separate outline and fill `WireModel`s.
The background grid is extracted from `SelectionCanvas` into `GridCanvas` and
moved beneath the 3D viewport to ensure proper layering. Finally, `DepthBiasState`
is enabled on all face/fill pipelines to apply a polygon offset, perfectly
resolving Z-fighting between coplanar wireframe lines and solid faces.
ROTATE's reference-point step prompts "or skip: type angle directly", but
on_text_input only handled the later Angle step. So picking a centre and
typing the angle did nothing, and the next Enter cancelled the command —
the objects never rotated, which reads as the rotate "erasing" / losing
them.
Accept a typed angle at the reference-point step too, rotating directly
about the centre (the reference point stays optional). Added an automation
regression test that rotates a line 90° with centre + angle only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The internal PDF export set each wire's colour and thickness but never a
dash pattern, so every dashed / centre / dash-dot line printed solid. It
also derived thickness from the on-screen pixel weight, which carries a 2×
screen-legibility boost — so print weights were inconsistent and barely
differentiated.
Emit a PDF dash pattern per wire from its linetype run lengths (mm→pt,
real length = index of the last non-zero element + 1, 1 pt floor so a dot
still marks), resetting to solid where there is no pattern. Convert
lineweight with a factor that undoes both the 96-dpi scaling and the
display boost, so print thickness is the true physical mm.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A new drawing's layouts carry paper limits but no sheet viewport; it is
created lazily on the first switch into the layout. That viewport kept
Viewport::new's default view (target 0,0 / height 210), so camera_from_view
aimed at the paper's bottom-left corner, and with no geometry yet the
fit_all fallback was a no-op — the layout opened off-centre.
Give the freshly created sheet viewport a view that frames the page:
view_target at the paper centre and view_height a little taller than the
sheet. The first switch now opens centred on the page, and the view persists.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Creating a viewport in a layout commits through document.add_entity_to_layout
+ Scene::auto_fit_viewport, both of which skip the geometry-cache bump that
Scene::add_entity performs for every other entity. So the new viewport's
border wasn't tessellated until the next zoom/pan happened to invalidate the
cache. Bump the wire tessellation (no-blocks: a viewport touches no block
definitions) right after the viewport is added.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Driving the annotation / viewport scale picker purely from the drawing's
ACAD_SCALELIST regressed every drawing that carries no Scale objects
(most fresh drawings and minimal DXF/DWG exports): scale_list() returned
empty, so the status-bar scale popup opened with nothing to pick and the
button looked dead.
Fall back to the standard ratio set (1:500 … 1:1 … 10:1) when the drawing
defines no scales of its own; file-defined scales still take precedence
whenever present. Also re-enables the viewport-scale picker in the
properties panel for those drawings.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The text / dimension / multileader / table style combos rendered their
open list inline inside the ribbon row, so the popup was clipped to the
fixed ribbon-row height and got cut off even with only two entries. The
Draw-tab dropdowns and the layer combo avoid this by rendering as
floating overlays.
Render the style list as an overlay too: the combo now draws only its
button inline, and a new `style_combo_overlay` builds the list as a
floating panel that grows to fit its entries, positioned with the
existing `compute_dropdown_left` (which already accounts for
StyleComboGroup widths) and mirroring `layer_combo_overlay`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two issues in a file whose dimensions carry baked *D### blocks.
Missing diameter text: the baked MText values are "\U+220520" /
"\U+220510" — the \U+2205 (⌀) escape immediately followed by the literal
digits "20"/"10". The MText parser read up to six hex digits for a \U+
escape, so it greedily consumed "220520" = 0x220520, which is past the
maximum code point, char::from_u32 returned None, and the whole text
dropped. A \U+ escape is exactly four hex digits; read four so the symbol
decodes and the trailing digits stay literal. (Angular "90\U+00B0" only
worked because its escape sits at the end of the string.)
Node artifacts: PDMODE=0 points were drawn as a fixed 0.1-world-unit "+",
which in a small drawing turned the 31 dimension def-points on the
Defpoints layer into visible crosses. Size the dot from world-per-pixel
(~1 px, zoom-adaptive via the tessellation cache key) to match the near-
invisible dot other CAD apps draw.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The continued dimension line was positioned with an offset measured from
the base dimension's *first* extension origin, then re-applied to each new
chain point (the previous dim's second origin). When the two extension
origins sat at different perpendicular positions — i.e. the extension
lines had different lengths — that offset placed the new dim line at
`D + (p2 - p1)·perp` instead of the base line's `D`, so the chain drifted
off the base line. Equal-length extension lines have `(p2 - p1)·perp = 0`,
which is why it only misbehaved sometimes.
Store the base dim line's absolute perpendicular coordinate and project
each extension origin onto it, so the whole chain stays collinear
regardless of extension-line lengths. The preview uses the same
projection.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
DIMALIGNED's drag preview never matched where the dimension landed. The
preview offset the dimension line in the XZ plane while the committed
entity's renderer offsets in the XY plane, and the commit path passed the
straight-line p2→cursor *distance* to `set_offset`, which re-applies it
along the perpendicular — wrong distance, always on the +perp side. Both
now store the cursor as the definition point and project it onto the
XY-plane perpendicular, matching the renderer and DIMLINEAR.
The same XZ-vs-XY plane confusion affected other annotate commands:
- DIMORDINATE worked entirely in the XZ plane (dropping the Y coordinate)
and left `definition_point` at the origin, so the leader kinked through
(0,0,0). Reworked onto the XY plane with a proper orthogonal elbow and a
live preview that matches the placed entity.
- DIMTEDIT / DIMJOGLINE cursor markers were built in the XZ plane, so in
the top-down view they collapsed to a flat line instead of a box /
zigzag. Rebuilt in the XY plane.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A dot in an LTYPE pattern is a zero-length element. The pattern resolver
encoded it as a tiny `0.01 * scale` world-length dash, which had two bad
effects: it was sub-pixel at normal zoom so the dash LOD saw the pattern's
smallest feature drop below one pixel and collapsed the whole line to
solid, and at larger LTSCALE it left only invisible sub-pixel dots between
big gaps. Either way dotted and dash-dot linetypes were never visible;
only plain dashed (no zero elements) worked.
Keep dots as literal 0.0 so they no longer drag `min_elem` down, and
render each as a fixed ~1.5 px mark in the wire shader (pixel-snapped via
world_per_pixel, mirroring the hatch shader's dot handling). The shader
tells a real dot from trailing array padding by element count — the index
of the last non-zero element plus one — so no producer needs a sentinel.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Center object snap was a pre-baked point gated purely on the
cursor's screen distance to the centre point. A circle's centre sits
inside it and an arc's centre is usually off in empty space well away
from the curve, so hovering the curve — the natural gesture — never
landed within the snap radius and no centre marker appeared.
Add a curve-proximity pass: when Center is enabled and the cursor is
near a circle/arc/ellipse curve, offer that curve's centre, ranked by
the cursor's distance to the curve. It runs after the `try_pt` closure's
borrow ends so it can update the candidate state directly, mirroring the
Tangent pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>