Commit Graph
137 Commits
Author SHA1 Message Date
Hakan SevenandClaude Fable 5 5ea6a9ac8e fix(edit): grip edits drop a dimension's stale baked *D block (#398)
A reopened file renders dimensions from the *D block baked on save, so
grip drags only moved definition points while the stale block kept
drawing — grips worked on fresh dimensions but died after a roundtrip.
Route the grip drag, grip-menu actions and typed grip-menu values
through invalidate_dim_block (#181) so tessellation falls back to live
geometry and the next save re-bakes.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-21 19:56:23 +03:00
Hakan SevenandClaude Fable 5 7811add666 feat(ui): persistent literal-space toggle on the command line
'>' button after the Command: label turns the leading-'>' literal-space
behavior into a mode: while on, Space stays in the line instead of
submitting, until toggled off. State persists in settings.json; a
hand-typed leading '>' still works for a single line.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-21 19:43:11 +03:00
Hakan SevenandClaude Fable 5 e177f92ff5 fix(xref): blocks respect their internal draw order
Xref merge now inserts entities in the source block's chain order and
carries SortEntitiesTable objects across (remapped to merged handles).
draw_depth_map values become [depth, half] so exploded block/xref fills
and wide-polyline band wires both compose child depth as
insert_depth + in-block rank x half, recursing into nested inserts with
the same divisor - bands and fills from one block interleave by the
block's internal order instead of stacking at the insert's single depth.
Band batches split per rank via WireModel.depth_override; thin wires
stay merged. The wire arena falls back to a full rebuild when a slab
carries mixed depths so refreshes don't flatten band overrides.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-21 15:53:28 +03:00
Hakan SevenandClaude Fable 5 9bfcad18b9 fix(hatch): pattern-scale edits take effect + background on/off
Property edits on a hatch were invisible: fills render from the
prebuilt model cache, which move/copy refreshed but the properties
path never did — so a pattern-scale change (which must also rescale
the stored final pattern lines by the ratio) changed nothing on
screen (#415). invalidate_property_targets now rebuilds the edited
fill's cached model via Scene::refresh_fill_model.

The background row gains an on/off box: off removes the
HATCHBACKGROUNDCOLOR record entirely (the colour could never be
reverted before), on seeds ByLayer. The codec covers the full
colour-method range (ByLayer / ByBlock / RGB / ACI) and the backdrop
resolves ByLayer / ByBlock through the style chain. "More Colors…" on
the background picker now opens the palette window targeting the
background colour — it used to just close the picker.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-21 12:22:15 +03:00
Hakan SevenandClaude Fable 5 a7fd9ecb45 fix(dyn): an explicit minus keeps its sign in the angle field
The dynamic-input angle field stripped the typed sign and re-signed
the value from the cursor's side of the X axis, so a negative angle
could not be entered at all — 330 had to stand in for -30 (#417). An
explicit -/+ prefix is now taken literally; unsigned entries keep the
cursor-side behaviour.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-21 11:55:17 +03:00
Hakan SevenandClaude Fable 5 84620852fe feat(select): Previous / Last keywords at Select objects prompts
Standard selection keywords, available typed or as prompt buttons in
every gathering command (#426): P / PREVIOUS re-selects the set the
last finishing command worked on (now captured before the working set
is dropped), and L / LAST selects the most recently created object of
the current space (highest handle among its selectable wires). Handled
centrally in the step router, so the command line, the option buttons
and the automation feeder share one implementation; deleted objects
and locked layers are filtered out.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-21 11:01:51 +03:00
Hakan SevenandClaude Fable 5 8821bb7131 feat(props): uniform-scale checkbox for block references
A selected INSERT's Geometry section gains a "Uniform scale" box
(#427): while the three factors are equal it is checked and the panel
shows a single Scale row that writes all three axes; unchecking
expands the familiar Scale X/Y/Z rows without touching the values
(remembered per entity), and re-checking collapses Y/Z onto X as an
undoable CHPROP step.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-21 10:27:50 +03:00
Hakan SevenandClaude Fable 5 7699f79424 feat(start): tutorial-rail implementation for the playlist fetch
Source half of b672023 (which captured only the removed bundled
thumbnails — the add aborted on the already-staged deletions): the
videos module (playlist scan, oEmbed titles, config-dir thumbnail
cache), the boot fetch on its own thread with the loading state, the
overlay rail + Videos tab on the Start page, and the earlier tabbed-
layout handover.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-21 10:07:21 +03:00
Hakan SevenandClaude Fable 5 c938d56ac6 fix(start): decode recent-file thumbnails off the boot path
load_config synchronously parsed every recent DWG's embedded preview
on the main thread before the first frame, so the Start page appeared
seconds late on DWG-heavy recents. refresh_recent_thumbs now decodes
the missing previews on a background thread and delivers them via
RecentThumbsLoaded; the page opens immediately and thumbnails pop in
as they resolve. The web build (no threads, no filesystem previews)
skips the fetch.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-21 10:06:55 +03:00
Hakan SevenandClaude Fable 5 8295d14c4e fix(select): Esc fully cancels a box selection across pans
The Esc path cleared box_anchor but left box_anchor_world behind, so
the next middle-button pan re-projected the world anchor back into
box_anchor (the #234 camera-tracking path) and the cancelled marquee
sprang back to life. Clear the world anchor too, matching the other
cancellation sites.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-21 09:32:44 +03:00
Hakan SevenandClaude Fable 5 e78ad7884d feat(dialogs): reopen file pickers in the last-used directory
Every rfd dialog result funnels through sys::handle_path, which now
records the picked file's folder (persisted to the config dir), and all
pickers are built via sys::file_dialog(), seeded with that folder.
Save-As keeps the current tab's own directory as the first choice and
falls back to the remembered one.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-21 09:27:44 +03:00
Hakan SevenandClaude Opus 4.8 7d68eb0f10 fix(paper): size inch paper-space sheets in their own units
Some layouts lay out paper space in inches (viewports and drawing limits
in inch units), but paper_width/height and plot margins are always
millimetres. paper_limits() drew the sheet at the mm size, 25.4x too
large, so the drawing shrank into the corner and the printable-area
dashes ran 25.4x too long.

Infer the mm->paper-space unit factor per layout from the limits/paper
ratio (plot_paper_units records the plot output unit and can disagree,
so it is not used). Scale the sheet, the printable-area margins and its
dash pattern by it. PDF export recovers physical mm by dividing back,
leaving millimetre layouts byte-identical.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-20 17:13:01 +03:00
Hakan SevenandClaude Opus 4.8 b3016ca892 feat(polyline): smooth per-segment taper for varying-width wide polylines
A wide polyline with per-vertex varying widths rendered at its widest edge
(uniform-width ones were already exact). Draw the true taper instead.

`WireModel.taper_widths` carries a per-point full band width. The wire
instance (native slim + web fat) gains per-endpoint `world_hw_a/b`, and both
wire shaders interpolate `mix(hw_a, hw_b, which_end)` so each segment's band
narrows/widens linearly — depth-tested, and the linetype dash still applies.

Alignment (widths must match the tessellated points) is solved by building
both together: a new `TruckObject::TaperedLines(points, widths)`, filled by
`common::tapered_band_points` (straight = endpoints, arc = 16 samples with the
width lerped along it). to_truck routes a varying-width polyline here —
LwPolyline before the PLINEGEN split, Polyline2D at its return — while a
uniform-width one keeps the constant-band Contour path unchanged. `world_width`
stays the widest edge as the PDF-stroke + zoom-floor fallback.

Blocks fall back to the constant-widest band (`LocalWire` carries no
taper_widths).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-20 16:36:06 +03:00
Hakan SevenandClaude Opus 4.8 bd1a5affd7 feat(lwpolyline): render the wide-polyline band via the wire shader
A wide polyline's flat band was drawn by the hatch pipeline: a solid fill
with only draw-order depth. It ignored the entity linetype (the width
"crushed" the dash pattern) and showed through 3-D geometry in a tilted view.

Draw the band from the polyline's own centre-line wire instead. A new
`WireModel.world_width` (drawing units) makes the wire vertex shader expand
the quad by `world_half_width / world_per_pixel` (clamped to a hairline), so
the band is sized in world units and tracks zoom. Because it is now a wire:

- the GPU dash shader dashes the band by arc-length — the linetype applies;
- it is depth-tested at the polyline elevation — no draw-order see-through;
- no CPU band triangles or per-dash geometry are generated.

`world_half_width` rides in `WireConst` (native, replacing a pad) and the web
`WireInstance` (loc 14); both wire shaders branch on it. The tessellator sets
`world_width` from the LwPolyline width on the Contour / SegmentedLines arms,
and the model-space fill loop no longer emits a hatch band for any LwPolyline.
Pick still uses the band polygon (`pick_tris`).

Blocks keep the hatch band: `LocalWire` carries no `world_width` (finalize
zeroes it), so a scaled insert still scales its band correctly.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-20 14:50:27 +03:00
Hakan SevenandClaude Opus 4.8 3d37e1f8d8 fix(thickness): edit persists + wide polyline extrudes as a solid tube
Entering a thickness (DXF 39 extrusion) on a wide polyline now works end
to end:

- The properties Thickness field is a General-group common prop, so it is
  routed to apply_common_prop (set_entity_thickness) instead of the
  geometry setter, which ignored it — the value was silently dropped and
  reverted to 0.
- A wide polyline extrudes its whole band into a 3-D tube (outer + inner
  walls with top and bottom caps), not just its centreline.
- The tube is real 3-D geometry, not a flat draw-order hatch: it renders
  solid in every view mode with true depth, so nothing shows through the
  walls. The flat 2-D band fill is skipped for a thickened polyline.
- A fill whose triangles span depth keeps its real depth (no draw-order
  bias), so the extrusion's own edge wires stay in front of the surface
  and remain visible instead of only the silhouette.
- The corner and cap edge lines read black, framing the coloured tube.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-20 13:40:12 +03:00
Hakan SevenandClaude Opus 4.8 626e8091f7 refactor(viewport): unify content-viewport clipping on the stencil path
Rectangular paper viewports now go through the same GPU stencil clip as
non-rectangular ones: viewport_clip_boundary_ndc returns the four rect
corners (a full-target mask) instead of nothing, so the render path has
no rect/non-rect special case — every content viewport carries a clip
boundary and draws with stencil reference 0xFF.

With a single clip path, the old per-wire / -image / -wipeout scissor
mechanism is fully dead and removed: compute_*_scissors, the
*_pixel_scissors buffers, the project_scissor helper, the per-pass
set_scissor_rect blocks, the vp_scissor field threaded through
WireModel / ImageModel / HatchModel and their GPU mirrors (WireGpu /
ImageGpu / WipeoutGpu / HatchWebGpu), from_run's scissor parameter, the
scissor grouping in build_wire_buffers, and the is_arena_eligible gate.
Net -245 lines across 54 files.

Rectangular viewports still clip exactly via their own render rectangle;
the stencil mask is a redundant full-target no-op there, kept only so
the clip code path stays uniform. XCLIP keeps its CPU wire clip.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-20 11:06:28 +03:00
Hakan SevenandClaude Opus 4.8 977701b47e feat(ui): MTEXT editor in shared modal + resizable dialogs
Route the MTEXT editor through the shared in-canvas modal frame with an
Apply-at-top toolbar (style-manager style): Apply commits and keeps the
editor open (creates on first apply, updates in place after), the ✕
cancels. Give every modal a bottom-right corner resize grip driven by a
shared grow-only `modal_resize` delta that resets with `modal_offset` on
open/close, so each dialog opens at its natural size and can be dragged
larger — style managers included.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-19 15:47:09 +03:00
Hakan SevenandClaude Opus 4.8 50f6196594 fix(render): flag 2D overlay fills so they show in every view mode
A SOLID's filled quad and a dimension/MultiLeader text background are flat 2-D
overlays that should draw in wireframe and shaded alike, but face3d_gpu
classified fills by `!fill_tris_low.is_empty()` — and a 2-D fill at UTM scale
carries a double-single low residual for precision just like a real 3-D mesh
face. So the arrowhead fill and the "5;8" cyan text background were routed to
the 3-D buffer and only appeared in shaded mode, vanishing back to nothing on
return to wireframe.

WireModel now carries an explicit `fill_is_3d`: true only for a real surface
(PolyfaceMesh / PolygonMesh face, set in the tessellator), false for every flat
overlay fill. face3d_gpu classifies on the flag, so 2-D fills stay in the
always-visible buffer while keeping their low residual (and UTM precision).

Verified on budweiser: the `_BoxFilled` arrow SOLID and the dim '5;8' MTEXT
background both tessellate with fill_is_3d = false.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-18 15:15:24 +03:00
Hakan SevenandClaude Opus 4.8 3f060a570a fix(pick): hit-test lineweight width and thickness walls
Selection tested a flat 8 px radius around each wire's centreline and
nothing else, so two things the user can see were not pickable.

A wire renders as a band `line_weight_px` wide, so pick now widens to that
rendered half-width via `pick_tolerance_px`, mirroring the wire shader's
`select(0.5, half_width, lwdisplay_enable)`. Behaviour is unchanged for
every standard weight (the widest, 2.11 mm, renders 7.97 px half-width —
just under the 8 px threshold); this keeps the two sides from drifting
apart if the display boost in `lineweight_to_px` ever changes.

Entities extruded by a DXF thickness draw their wall as four edges with
nothing in between, so the interior was a hole the cursor fell through.
WireModel gains `pick_tris`: triangles hit-testing treats as solid and the
renderer never uploads. Kept apart from `fill_tris` because that channel
reaches the GPU, and a wall that rendered shaded would change how every
thickness drawing looks. Ranked below `fill_tris` — those are drawn, these
are not, so where they overlap the drawn face is what the user means.

Walls are dropped when a wire is projected into a floating viewport:
`points` are reprojected and clipped into paper coords there, so cloned
model-space triangles would hit-test at a model-scale offset. Extruded
entities stay selectable by their edges, which do get projected.

The wall loop runs on every hover that misses everything else, so it
rejects on the wire's AABB first — an extruded circle is ~128 triangles
and a drawing full of thickness would otherwise project every wall on
each mouse move.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-17 14:58:55 +03:00
Hakan SevenandClaude Opus 4.8 5bd683cf4e feat(mline): GPU-dash multiline elements with shared A-type alignment
MLINE dashed elements were fully CPU-expanded (apply_along) — one wire per
dash — to keep the parallel lines in phase. Move pure-dash elements onto the
GPU dash shader and end-align every element to the multiline's centre-line so
they share one interior phase.

- wire_distances now accumulates arc-length from the double-single (high+low)
  points, so the dash phase stays precise at UTM coordinates. f32-high-only
  deltas quantised ~0.1 there and drifted the parallel lines apart — the
  original reason MLINE dashes were CPU-side.
- New WireModel.dash_align_end carries a shared begin/end dash length. The
  shader uses it as align_end for every parallel element (the interior phase
  depends on align_end, not the wire's own length) while align_total stays the
  element's own length, so each still ends on a dash at its own endpoint.
- MLINE tessellation derives that length once from the centre-line, routes
  pure dash/space/dot elements to the GPU pattern and text-bearing elements to
  apply_along.
- apply_along gained an "A"-type mode (shared reference length): a solid
  begin/end dash and a phased interior, so its dashes — and embedded text —
  line up with the GPU-dashed sibling elements instead of tiling from start.

Pure-dash MLINEs now cost one WireModel + pattern per element instead of N
segments; single (non-MLINE) entities keep the from-start tiling unchanged.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-17 02:59:11 +03:00
Hakan SevenandClaude Opus 4.8 48e6d0d553 feat(dgn-ls): dash walls from start + fix wall connector and cap identity
DET pipe walls now carry the stroke pattern (dash_from_start), which
tiles from each wall's own start vertex and skips the A-type end
alignment that was force-lighting a solid dash under the end symbol.

Two rendering fixes on top:
- Dedup the host polyline's vertices before offsetting the walls. Some
  DGN pipe polylines carry trailing duplicate end vertices; a zero-length
  segment gave offset_xy a degenerate normal, leaving that vertex
  un-offset and folding the wall down to the centre line. The two walls'
  fold segments met at the centre and drew a spurious line linking them.
- Re-key the symbol (end circle / half-ellipse) wires to the host handle
  so the whole pipe picks and selects as one entity instead of the
  symbols acting as separate phantom entities.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-17 01:49:18 +03:00
Hakan Seven 5b4a6719aa Merge pull request #383 from KevinGriffin-new/fix/pdf-export
Never fail silently on PDF export; add EXPORTPDF <path> (#369).

The export flow handed off to an async rfd save dialog and mapped a None
result straight to Task::none(). On Linux None also means the dialog never
opened (broken XDG portal / missing zenity), and rfd reports that only via
log, so PDF export could end in total silence. The dialog-None arms now
print a message pointing at EXPORTPDF <path>, and EXPORT/EXPORTPDF take an
optional path that exports straight to a file with no dialog — the
workaround where the native dialog can't open, and a headless entry point.

Conflict: the fix (display.rs, file.rs, mod.rs, COMMANDS.md) auto-merged;
only the additive test block in automation.rs conflicted. Resolved by
keeping main's tests and adding the two #369 tests.

# Conflicts:
#	src/app/automation.rs
2026-07-16 11:52:46 +03:00
Hakan SevenandClaude Opus 4.8 f452ae9211 fix(export): keep plot coordinates in f64 until they cancel
A UTM drawing plotted deformed — both geometry and text — while the same
drawing near the origin came out clean. Every pass of the exporter narrowed
an absolute world coordinate to f32 before the offset had a chance to cancel
it, and f32 resolution at UTM is ~0.03 m on easting and ~0.5 m on northing,
so the whole plot snapped onto that grid.

Four places did it, all now folding in f64 and narrowing only once the
result is a single-digit sheet-mm value:

- The wire pass never read `points_low` at all — the word did not appear in
  the file — so it plotted the double-single "high" half alone and dropped
  every residual.
- emit_hatch cast the f64 `world_origin` to f32, re-introducing at the last
  step exactly the quantisation the boundary-relative encoding exists to
  avoid.
- pattern_segments rasterised entirely in absolute f32. The pattern is
  anchored at world (0,0), so a hatch at UTM runs a line index k of ~1e7:
  `origin + k · step` and the `edge − line` intersections both fall apart
  there. k also becomes i64 — `as i32` saturates silently, which would
  collapse the range to a bogus run from i32::MIN rather than drop the fill.
- emit_text folded `pos + pos_low` in f32, which rounds straight back to
  `pos`: the residual the pair exists to carry was discarded at precisely
  the coordinates it was carried for.

The offsets themselves were also narrowed by the callers, so the value meant
to cancel a UTM coordinate was quantised before it could. Widen ox/oy to f64
through export_pdf, build_pdf, print_wires and the two plot-param builders;
printing shared the same path and the same bug.

Pre-existing, not from #390 — that change only routed text through the same
faulty pipe the wires were already in.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-16 10:25:28 +03:00
Hakan SevenandClaude Opus 4.8 f028e93a33 fix(start): let the welcome page's own buttons work (#388, #389)
Donate, Send Feedback, OCS Web and Manage > About all answered "No
drawing open — use New or Open first." and did nothing.

Two doors guarded the welcome tab and the outer one shadowed the inner.
`dispatch_command` already knew which commands stand alone — About,
Donate, Report and the links were all on its list — but
`on_ribbon_tool_click` refused every ribbon event before dispatch was
ever reached.

The welcome page's own buttons emit RibbonToolClick, so they are only
ever clickable while `is_start` holds: the refusal covered exactly the
case they exist in, and they had never worked. New Drawing / Open File /
Plugins send their own messages, which is why the page still looked
alive.

So the ribbon door now defers commands to dispatch and keeps refusing
only what touches the scene. The policy stays in one place instead of
being half-remembered in two. A drawing tool is still turned away on the
welcome tab (#299) — the wording just comes from dispatch now.

The same gap was swallowing tools the issues never mentioned: CUI and
ALIASEDIT configure the application, not a drawing, and were missing
from the list, so fixing only the ribbon door would have moved their
refusal one door down rather than removing it. HELP and CUILOAD /
CUIIMPORT join them.

The new test drives the real message path and fails on the old code with
the reported wording. It reads the allowlist from source rather than
dispatching DONATE / REPORT / WEBVERSION, which shell out to a browser —
that also fails the day someone adds a welcome-page button and forgets
the list.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-15 16:29:42 +03:00
Hakan SevenandClaude Opus 4.8 b3bd93d8c5 feat(open): open drawings in the running editor
Double-clicking a drawing started a whole second copy of the app. Now it
lands as a tab in the editor already open, and that window comes forward.

The election is the bind itself: every GUI launch tries a deterministic
per-user loopback port, and the OS grants it to exactly one process and
releases it on any death, SIGKILL included. Nothing to reap, no PID to
probe, no stale lock after a crash. The winner serves the port from an
iced subscription; the loser checks it is talking to a matching editor,
hands the paths over and exits. Every surprise — no answer, a stranger on
the port, a timeout — boots a normal window, i.e. the old behaviour: the
feature can degrade, but it cannot lose the file.

The gate in main is positional, after every headless mode has returned.
The plugin runner is this same binary re-spawning itself, so a flag list
would rot the first time a mode is added; a position cannot.

Handing a drawing over that is already open switches to its tab instead
of loading a second copy, comparing resolved paths so a symlink or a
`..` hop is still recognised as the same file.

`opening` is a single slot that a second open would overwrite, and
on_file_opened drops any result arriving once it is clear — so drawings
handed over at once would silently vanish. They queue in pending_opens,
drained from every path that clears the slot, error and cancel included.

Exec becomes %F so one launch takes the whole selection; the CLI takes a
list and boot fans it out through the same door. --new-instance forces a
separate process; --read-only / --script / --new always get their own.

Not reused: the automation server. It dispatches arbitrary commands and
writes arbitrary paths, and its open replaces the active tab's document
in place — publishing that on a port every local process can reach would
be unforced privilege escalation. Only its JSON-lines framing is shared.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-15 15:18:16 +03:00
Hakan SevenandClaude Opus 4.8 1490fe4ea9 perf(undo): record entity deltas instead of cloning the whole document
Frequent entity-only edits (MOVE/ROTATE/SCALE/MIRROR/ALIGN, COPY/ARRAY,
draw-add, ERASE) captured a full ~800k-entity CadDocument clone (~46ms) on
every edit — the last big per-edit cost after the incremental-render work.

They now record only the touched entities' before/after images:

- Scene self-records through the five mutation primitives when a recording is
  open (UndoRecording + is_recording_undo guard, zero cost off-path).
- apply_entity_delta installs one side per handle (in-place / re-insert with
  the original handle / remove) plus a per-handle cache reseed, then a single
  bump_entities so the incremental caches patch instead of a full re-tess —
  undo/redo of a small edit is fast too, not just the edit.
- HistorySnapshot is now Full | Delta; a Delta is self-inverting and moves
  between the undo/redo stacks without re-capturing. Commands that touch
  non-entity state (new layer, group, *D block, viewport) fall back to the
  full snapshot via per-command predicates, and a poison flag debug-asserts
  any mis-scope.

remove_entity leaves the handle dangling in its owner block record's
entity_handles (harmless on its own, a lookup miss is skipped), so a
re-insert strips stale entries first — otherwise add_entity would list the
handle twice and the DXF/DWG writer would emit the entity twice.

Round-trip tests cover transform/erase/add/copy (do->undo->redo equality,
handle preservation, no duplication).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-15 11:11:17 +03:00
Kevin GriffinandClaude Fable 5 d2af4c0b34 fix(plot): never fail silently on PDF export; add EXPORTPDF <path> (#369)
Export to PDF could end in complete silence: the flow hands off to an
async rfd save dialog, and when that future resolves to None the app
mapped it straight to Task::none(). None does not only mean the user
cancelled — on Linux the XDG portal SaveFile call can fail outright
(rfd then tries a zenity fallback, which may not be installed) and the
future still just resolves to None. rfd reports those failures only
through the `log` crate, and logging is opt-in (--log / RUST_LOG), so
the reporter saw exactly what #369 describes: no dialog, no error, no
console output, no file.

- EXPORT / EXPORTPDF now accept an optional path argument that exports
  straight to the file with no dialog — both the workaround for systems
  where the native save dialog cannot open and a headless-testable
  entry point. A relative path lands next to the drawing; a missing
  extension gets .pdf.
- The dialog-None arms (PlotExportPath / PlotWindowExportPath) now push
  a command-line message pointing at EXPORTPDF <path> instead of doing
  nothing.
- The success message prints the full output path, not just the file
  name, so users can find the PDF.

Verified end to end on the real binary via --serve: LINE + CIRCLE then
EXPORTPDF <path> writes a valid PDF with the expected geometry. Both
new tests fail on the previous code.

Fixes #369

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-14 13:38:16 -07:00
Hakan SevenandClaude Opus 4.8 3548de332c feat(block): BEDIT block editor as a dedicated space tab (#261)
Selecting a block and running BEDIT (command, ribbon button, or a
double-click on the block) opens a block-editor "space" tab, named after
the block, alongside Model/Layout in the status bar. The tab draws ONLY
the block record's own entities in block-local coordinates; edits are
live on the definition, so every INSERT updates. Save Block / Discard
buttons (side toolbar) finish the session; Discard restores the on-entry
snapshot. Space switching is locked while a block is open, and BEDIT and
REFEDIT are mutually exclusive.

Implementation: a Scene.block_edit_block override at the top of
current_layout_block_handle scopes render/pick/new-entity ownership to the
edited block while current_layout stays "Model" (so the pervasive
Model-vs-paper code keeps model behaviour). The model render/pick paths
that hardcoded model_space_block_handle (wire tile set, hatch render +
pick, block-instance meshes) are made binding-aware so the block isolation
actually takes effect and model content does not leak in. Entering fits
the camera to the block (fit_all already scopes via the binding). Renaming
the block tab renames the block itself — new Scene::rename_block re-keys
the record, updates the Block marker and repoints every INSERT reference.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-14 13:03:04 +03:00
Hakan SevenandClaude Opus 4.8 c0844ad55c feat(annotative): synthesize per-object contexts + editable UI
Make objects genuinely annotative — carrying real per-scale
representations that interoperate with other CAD apps — rather than just
a native flag. `create_annotation_context` synthesizes the extension-dict
chain (AcDbContextDataManager -> ACDB_ANNOTATIONSCALES -> per-scale leaf)
from the entity's placement; the editable Annotative toggle now covers
Text and block references as well as MText/MLeader; and a new
"Annotation Object Scale" dialog (OBJECTSCALE) adds/removes an object's
per-scale memberships. Requires the acadrust ObjectContextData encoder
(Cargo.lock bump).

Also fixes a render bug where objects carrying an *empty*
ACDB_ANNOTATIONSCALES (a single-representation marker with no per-scale
reps) were treated as annotative and (mis)scaled by the annotation factor
in scaled paper viewports, ballooning the text: an object is now
annotative-by-context only when its scale collection is non-empty, so
such objects render at their base geometry.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-13 22:38:29 +03:00
Hakan SevenandClaude Opus 4.8 d005cc4ab1 feat(props): make dim-line colour editable for leaders and dimensions
The "Dim line color" row on Leader and Dimension entities was
read-only: an earlier attempt wired it to Leader.override_color, which
acadrust never serialises, so the pick was lost on save. Store it
instead as a standard ACAD_DSTYLE per-object dimension-style override
(DXF code 176, an ACI index) through the existing dim_override codec,
so it round-trips through both DWG and DXF like the other dim
overrides. RGB picks collapse to the nearest ACI, matching the rest of
the dim-colour stack (dimension styles are index-only through the file
layer). The renderer prefers the override over the style's DIMCLRD.

The write branch is guarded to leaders and dimensions so a mixed
selection cannot stamp the override onto other entity types.

Bumps acadrust to c9fe982, which carries parsed XDATA onto dimensions
on DXF read (it was dropping common.extended_data), so the dimension
override persists on DXF save as well as DWG.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-13 17:31:09 +03:00
Hakan SevenandClaude Opus 4.8 70225de11f fix(annotative): make scale-manager rename and edits actually persist
Double-click rename did nothing and ratio edits were lost when switching
scales — and on drawings saved by other programs nothing could be edited
at all.

- Fallback scales were virtual (no Scale objects), so edit/rename no-oped;
  materialize the standard set into real staged objects when the manager
  opens, reverted on close unless applied.
- On third-party DWGs the mutators walked the root named-objects dict ->
  ACAD_SCALELIST, which can be unreachable, and matched dictionary keys
  that are internal ids (*A1, A0) rather than scale names. Locate the
  scale-list dict robustly and match scales by object name + handle.
- Rename rows inline via double-click; repair a dangling current-scale
  header pointer on staged rollback.
- Stage text/mleader style-manager edits on switch so they persist like
  the scale manager.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-13 14:37:33 +03:00
Hakan SevenandClaude Opus 4.8 cd766bc1f5 fix(annotative): stage scale-manager edits like the style managers
The scale manager now runs as a transaction, matching the style managers'
staging: New starts a blank editor draft (nothing is added to the drawing
until Apply), Delete removes immediately but staged, and closing the window
without Apply restores the snapshot taken on open — so a scale created or
deleted mid-session is discarded on cancel. Apply commits the staged list
changes as a single undo entry. This replaces the earlier behaviour where
New copied the selected scale and edits landed immediately.

A `changed` flag guards the commit so a no-op / failed Apply (blank or
invalid fields, or an edit that didn't apply) no longer dirties the drawing
or pushes a phantom undo entry. Set Current is deliberately outside the
transaction — it takes effect immediately like the scale pill (and syncs
CANNOSCALEVALUE), so it is never rolled back on close. Renaming the current
scale follows the pointer; editing one of the built-in fallback scales (shown
when the drawing has no scale list) materialises it as a real scale; the
snapshot restore touches only the ACAD_SCALELIST root entry.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-13 12:14:40 +03:00
Hakan SevenandClaude Opus 4.8 1071570f40 feat(annotative): annotation-scale manager window
Replace the scale popup's inline delete/add affordances with a single
"Manage scales..." row that opens a proper manager, matching the style
managers: it reuses the shared style-manager palette and chrome (toolbar +
list + editor frame), so it looks and behaves like the Text / Dim / MLeader
managers.

The manager lists the drawing's annotation scales (name + ratio, the current
one checked) and edits them through a Name / Paper units / Drawing units
editor: New adds a scale from the fields, Apply renames / re-ratios the
selected one, Delete removes it (never the current scale), and Set Current
makes the selected scale the model-space annotation scale. Backed by
Scene::add_scale / remove_scale / edit_scale on ACAD_SCALELIST.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-13 10:39:14 +03:00
Hakan SevenandClaude Opus 4.8 b1eba180eb feat(annotative): add/remove drawing annotation scales
The drawing's annotation-scale list (ACAD_SCALELIST) is now editable, so the
set of scales offered by the status-bar scale pill can be grown or trimmed:

- Scene::add_scale / remove_scale create or drop a Scale object and its
  SCALELIST dictionary entry (creating the SCALELIST dictionary, and
  registering it in the root, for drawings that carry none).
- SCALELISTEDIT gains ADD <ratio> / DELETE <name> alongside the existing
  listing (e.g. `SCALELISTEDIT ADD 1:50`).
- The scale popup (model space) grows a delete affordance on each non-active
  scale and a "+ Add scale..." row that primes the command line. The current
  annotation scale is protected from deletion.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-13 10:18:05 +03:00
Hakan SevenandClaude Opus 4.8 fc5e93dcc1 feat(properties): editable Annotative toggle for MText and MLeader
The Annotative row becomes an editable toggle for the two entity types that
carry a native per-object annotative flag — MTEXT (is_annotative) and
MULTILEADER (enable_annotation_scale). Turning it on flags the object
annotative (it then scales with the current annotation scale in model space);
turning it off clears the flag AND removes the per-object annotation context
(the AcDbContextDataManager -> ACDB_ANNOTATIONSCALES leaf subtree) plus the
legacy annotative XDATA markers, via scene::annotative::set_entity_annotative /
clear_annotation_context, so the object stops resolving annotative instead of
staying on through a stale context. The shared SCALE objects are left intact.

Style-derived types (text/dimension/leader/table/insert) keep a read-only
Annotative row — their state comes from the assigned style, and making a single
object annotative there needs a per-object context object (a dedicated
acadrust ObjectContextData writer) which is out of scope here. Verified an
existing annotative DWG round-trips its 134 per-object contexts through a
save/reload unchanged, so the far more common open-and-save path is unaffected.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-13 09:32:22 +03:00
Hakan SevenandClaude Opus 4.8 7cf1bf014b feat(properties): editable per-object leader dimension overrides
A legacy leader's Lines & Arrows / Text / Fit rows are now editable
per-object overrides of its dimension style, stored in the standard
ACAD_DSTYLE XDATA record (new src/entities/dim_override.rs codec). Arrow
block, arrow size, dim-line lineweight, text offset, vertical text
position and overall scale each prefer an override over the style; the
renderer honours arrow size / block / overall scale and the dim-line
lineweight, so an edited leader redraws at its new arrow and weight.

XDATA edits go through a new dispatch::set_entity_xdata, which registers
the application in the APPID table (the DWG writer drops records for an
unregistered app) and drops that app's stale verbatim EED block (which
otherwise wins over the structured record on a DWG save), so edits and
clears round-trip. The hyperlink editor moves onto this path too.

Dim-line colour stays read-only: it would live in the leader's
override_color, which the file layer doesn't serialise, so making it
editable would silently lose the pick on save. Picking "Closed filled"
writes an explicit null-handle arrow override rather than clearing, so
it sticks even when the style's arrow differs; an unparseable numeric
entry is ignored instead of wiping the stored override.

Note: the DXF entity writer only emits XDATA for hatches, so these
overrides (and hyperlinks) currently persist on DWG save but not DXF —
an acadrust-side gap to close separately.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-13 00:28:42 +03:00
Hakan SevenandClaude Opus 4.8 984fa79189 feat(properties): make the Hyperlink field editable
The General-group Hyperlink row becomes an editable text field; committing a
value writes the standard PE_URL XDATA record (an empty value clears it). Adds
scene::view::dispatch::set_common_xdata, which rebuilds an entity's XDATA to
replace or remove a single application record — ExtendedData only appends, so a
plain add_record would leave a stale duplicate the reader would pick first.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-12 23:24:34 +03:00
Hakan SevenandClaude Opus 4.8 1b037a68aa feat(annotative): scale text/dims/tables/blocks at the annotation scale + editable property pickers
Render: annotative-ness is resolved centrally in scene::annotative::is_annotative
(per-object context dictionary, legacy XDATA, or annotative style) so the bake and
the properties panel agree. Text, dimensions, tables and blocks now display at the
current annotation scale in model space. An annotative block scales as one uniform
unit about its insertion point — its internal geometry and attributes are carried by
that scale instead of being scaled individually, fixing the block-attribute size
regression. Tables scale their column/row/margin geometry.

Properties: the handle- and flag-backed rows are now editable pickers — MLEADER
multileader style / text style / arrowhead / leader linetype, General Material, and
Plot style (named plot-style mode only; the color-dependent mode stays read-only,
which is correct). A picked name is resolved back to its handle in the update loop,
where the document is in scope. Annotative Yes/No and the annotation-scale row are
shown per object.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-12 23:16:44 +03:00
Karim Jerbi 8ef65f59f8 fix(ribbon): highlight toggle buttons by their actual state
The UCS Icon, Properties, File Tabs, and Layout Tabs buttons were highlighted unconditionally because their ids fell through to an active_tool equality check in is_active_tool, which activate_tool sets on every click and never clears. Each id is now mapped to its corresponding visibility flag (show_ucs_icon, show_properties, show_file_tabs, show_layout_tabs), and those flags are kept in sync from their toggle handlers so the highlight reflects live state. The per-toggle booleans previously threaded through the render path are consolidated into a single ToggleState struct read from one place, so introducing a further toggle no longer requires changing every render-function signature and call site.
2026-07-12 19:53:48 +03:00
Hakan SevenandClaude Opus 4.8 dd3ce6767c feat(layout): reopen a drawing in the space it was saved in
Files saved while a paper layout was active reopened in Model space
because the loader hardcoded the current layout to "Model" and never
consulted the space recorded in the file.

The active space is a standard part of the format and acadrust already
round-trips it: $TILEMODE (header.show_model_space) for model-vs-paper,
and the CTAB current-tab variable for the exact layout name.

- Read: on open, pick the current layout from CTAB (when it names a
  layout that exists), else $TILEMODE (Model, or the first paper layout).
- Write: Scene::sync_active_space_to_document() mirrors the active layout
  back into the document (show_model_space + CTAB). It runs from
  set_current_layout, so every active-layout change — the status-bar tab,
  the Layout Manager's Set Current, create / rename / delete, and
  undo/redo — keeps the document in sync for the next save.

$TILEMODE is the guaranteed mechanism (read and written for both DXF and
DWG); CTAB refines the exact tab when the variable is present.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-12 01:01:40 +03:00
Hakan SevenandClaude Opus 4.8 57710eec2b 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 <[email protected]>
2026-07-11 21:26:40 +03:00
Hakan SevenandClaude Opus 4.8 8ed10ceb2a feat(mtext): structured editor with selection, copy, and visible bold
Rewrite the in-app MTEXT editor to run on acadrust's structured
MTextDocument model end-to-end: load via parse_mtext, edit a flat Cell
model, serialize via to_mtext_string. Retires the hand-rolled format-code
string handling, the parallel visible_spans parser, the composed_value
prefix, and the Edit-raw-code tab.

Selection and copy:
- double-click selects the word, triple-click selects all (click-counting)
- Ctrl+A selects all text
- Ctrl+C copies the selected text to the clipboard

Formatting:
- per-selection Bold/Italic/Underline/Overline/Strike/Case/Alignment
- Bold renders as a visibly thicker SDF pen, keyed (family, char, bold)
  through the whole text pipeline; the resolved style font name is stamped
  so the \f...|b1; code survives serialization
- per-selection font and color changes
- editor color dropdown reuses the Properties panel color selector

Global toolbar defaults (font/color/oblique/width/tracking) fold onto the
document as real span properties instead of a leading code prefix; a
trailing empty paragraph is preserved so Enter at the end keeps its line.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-11 20:18:50 +03:00
Hakan SevenandClaude Opus 4.8 cf4074b0f2 fix(ribbon): make command icons inert on the Start page
On the Start page there is no drawing to act on, but ribbon panel
command icons still dispatched into the empty welcome tab. Guard
on_ribbon_tool_click: when the active tab is the Start page, close any
open flyout, show a "use New or Open first" hint, and do nothing. The
quick-access New / Open / Save buttons use a separate path and stay
available, as do the welcome page's own actions.

The icons are not yet visually greyed out (that needs an enabled flag
threaded through the ribbon render) — this stops them from running.

Refs #299

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-11 10:11:28 +03:00
Hakan Seven 816cb059dd Merge PR #353: fix View Cube toggle (highlight follows state, cube clears on hide)
External PR by Karim Jerbi (@KarimJerbi).
- NAVVCUBE is a command toggle so it never set active_tool; add a
  show_viewcube flag to the ribbon and special-case is_active_tool so the
  button highlights only while the cube is on.
- show_viewcube was absent from render_signature, so toggling a cached
  view produced an identical signature and left stale cube pixels; hash
  it into the signature so the toggle invalidates the cache and clears.
2026-07-11 09:20:32 +03:00
Hakan SevenandClaude Opus 4.8 6032a024d2 feat(save): native OS dialog for Save As, keep in-app format picker
Replace the custom in-app file-browser save dialog with the native OS
save dialog for choosing the destination path — folder browsing and
overwrite confirmation now come from the OS. A compact in-app modal
still selects the DWG/DXF format and version (a native dialog cannot
carry the version year) and appears only for Save As. Plain Save on an
unsaved drawing defaults to DWG 2018 with no picker. The file-name field
is shown only on web, where there is no native dialog.

Remove the hand-rolled directory listing, folder navigation, and
overwrite modal.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-11 09:12:36 +03:00
Karim Jerbi 585316945e fix(ribbon): highlight View Cube button by toggle state
NAVVCUBE is a command toggle, so it never set active_tool and is_active_tool never matched it. Add a show_viewcube flag to the ribbon (mirroring wireframe/ortho_mode) and special-case "NAVVCUBE" in is_active_tool to return the live show_viewcube state; ToggleViewCube now calls ribbon.set_viewcube.
2026-07-11 00:40:22 +01:00
Hakan SevenandClaude Opus 4.8 5c4ba97c5f feat(thumbnail): embed a DWG preview on save, show it on the Start page
Generate a small wire-raster thumbnail when saving a DWG and embed it via
acadrust's preview API, so OCS drawings show a thumbnail in file browsers
and other CAD applications. Read the embedded preview back cheaply (file
header + preview bytes only, no full parse) to show a thumbnail per file
in the Start page's Recent Documents panel.

- io/thumbnail.rs: CPU rasterizer (current-layout wires -> BMP DIB) plus
  a preview-only reader/decoder (BMP/PNG -> iced handle) for the Start
  page.
- Embedded on save via stamp_thumbnail on both save paths.
- recent_thumbs cache filled on config load / recents change (never from
  a view); a thumbnail is drawn beside each recent file.

Requires acadrust at 3111694 (preview read/write), already pinned in
Cargo.lock.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-10 23:03:23 +03:00
Hakan SevenandClaude Opus 4.8 dc7117f203 refactor(config): consolidate settings into one grouped settings.json
App preferences were spread across six flat plain-text files (settings.txt,
recent.txt, recent_limit.txt, statusbar.txt, ribbon.txt, plot.txt), each with a
bespoke line parser and no grouping. Replace them with a single grouped JSON,
serialized via serde (enabling the derive feature; serde_json was already a
dependency).

- New app/config.rs: AppConfig { settings, recent, statusbar, ribbon, plot } →
  <config>/settings.json (pretty JSON), load()/save().
- Make UserSettings, StatusBarConfig/StatusPill, CollapseMode and
  PlotDialogState serde-derived; #[serde(skip)] marks the plot dialog's runtime
  fields so only print preferences persist. Their bespoke .txt load/save is
  removed.
- App gains current_config/apply_config/save_config: one AppConfig::load at
  startup distributes into live state; every settings-change site
  (persist_settings_if_changed, recent add/remove/limit, status-bar pill
  toggle, ribbon density, plot commit) routes through save_config, which diffs
  against the last write to avoid thrashing.
- Clean start: the old flat files are ignored (no migration). ocad.pgp stays a
  separate hand-editable file.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-10 16:41:34 +03:00
Hakan SevenandClaude Opus 4.8 256f9a282b refactor(settings): drop Ortho/OSNAP from settings.txt, now per-drawing only
Ortho and the running object-snap set are stored in the drawing header
($ORTHOMODE / $OSMODE) since they were bound to the file, so persisting them
app-globally in settings.txt too was redundant. Remove the ortho, osnap and
snap_modes keys (and the now-dead modes_from / snap_id / snap_from_id helpers)
from UserSettings, and stop reading/writing them in apply_settings /
current_settings.

No boot behavior change: Snapper::new already defaults to the same running-snap
set (Endpoint, Midpoint, Center, Node, Quadrant, Intersection, Nearest) with
the master off, and Ortho defaults to false — exactly what settings.txt used to
seed. Old settings.txt files keep working; the stale keys are simply ignored.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-10 16:05:12 +03:00
Hakan SevenandClaude Opus 4.8 71db52a0a4 feat(statusbar): honor $COORDS in the coordinate readout, click to cycle
The Coords pill always showed live absolute X,Y,Z, ignoring the drawing's
$COORDS (coords_mode) sysvar. Drive the readout from it:

- 0 static  — the last picked point; the readout freezes between picks.
- 1 live    — continuous absolute X,Y,Z (previous behavior, default).
- 2 polar   — distance<angle relative to the last point while a command is
              prompting for a point; absolute otherwise.

The cursor and last point are reported in the active UCS, matching the rest of
the readout. Clicking the pill cycles the mode 0→1→2→0 (CycleCoordsMode), marks
the tab dirty and echoes it; SETVAR COORDS still works. $COORDS round-trips per
drawing via acadrust's coords_mode.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-10 15:54:57 +03:00