Face3DGpu batched every visible 3DFACE quad and PolyfaceMesh /
PolygonMesh fill triangle into a single vertex buffer. A mesh-heavy
DWG (e.g. a Navisworks import) blows past wgpu's default 256 MB
max_buffer_size once enough layers are thawed; the resulting
uncaptured validation error panics inside iced's main-thread redraw
and aborts the whole process. Split the 3D and 2D fill streams into
per-limit chunks on whole-triangle boundaries — the same scheme
build_mesh_batch already uses (#203) — and register an
on_uncaptured_error handler so any remaining GPU validation error
degrades the frame instead of killing the session.
Co-Authored-By: Claude Fable 5 <[email protected]>
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]>
A linetype element can carry a `complex` record with no renderable content
(shape #0, null style handle, zero scale) — some DWGs store plain dash
elements that way (e.g. ISO03W100 "ISO dash space"). document_complex_lt
treated any `complex.is_some()` as complex and sent the entity down the CPU
apply_along path, which skips the GPU dash shader and its A-type endpoint
alignment. Gate on *renderable* content (non-empty text, or a shape with a
real shape-file handle) so these fall through to resolve_pattern and dash on
the GPU with proper A-type alignment.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
glam 0.33.2 deprecated Mat4::{orthographic,perspective,look_at}_rh in
favour of the glam::camera::rh::* functions. Switch the 7 call sites in
camera.rs and viewcube.rs to the equivalent camera-module functions
(directx orthographic/perspective = same [0,1] depth range; look_at_mat4
= same view matrix), silencing the deprecation warnings. No behaviour
change.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
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]>
DGN line-style linetypes (empty standard LTYPE) drew only end symbols on
a single centre line. Render the host polyline as two parallel walls
offset by ±(symbol radius = block extent / scale) so each wall sits
tangent to the end circles, and dash them with the line style's stroke
pattern.
The stroke dash lengths live in the component leaf (byte-aligned
big-endian f64 after the shared class GUID). `wall_dashes` reads the
native dash of the root compound's dash stroke ([5.25, 7.125] for DET);
`stroke_dashes` decodes the leaf. The native lengths scale to drawing
units by f = radius / symbol-scale (0.1875) — the same factor that maps
the compound's native offset (2.0) onto the measured wall offset
(0.375), which also lands the dash at ~1.0 drawing unit.
Walls dash through the normal WireModel pattern (shader rebuilds each
segment relative-to-eye, so it stays precise at UTM coordinates).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Linetypes converted from MicroStation DGN keep their pattern in DGN line-style
objects (empty standard LTYPE), so entities using them drew as a plain line and
lost the symbols (e.g. a pipe's end circles). Walk the line-style tree
(resolved by name to its LSDefinition), collect the symbol components' anonymous
blocks, and draw each at the host polyline's endpoints, scaled by the symbol's
decoded scale (geometry / scale) so it matches the drawing (verified: a 0.75
ellipse renders at the file's intended 0.375 radius).
Bumps acadrust to 8e18c6b (DGN line-style symbol-scale decode). First pass: the
stroke dash pattern and exact per-vertex placement need more of the line-style
leaf decoded; the base line still draws solid.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Picks up acadrust 6508b76: reads DGN line-style objects (AcDbLS*) into the
document's dgn_ls_definitions / dgn_ls_components tables. No OCS code consumes
them yet — that lands with the linetype linkage and renderer.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Multilines were drawn with a fixed ±scale/2 two-line guess in the entity
colour, ignoring the MLINESTYLE entirely, so a drawing's custom style (per-
element offsets, colours and linetypes) was lost — a red/yellow multiline
rendered as a white box.
- mline.rs: resolve the MLINESTYLE (by style_handle, then name) and build one
parallel line per element from its offset x scale + justification shift.
- tessellate.rs: special-case MLINE (like Leader) to emit one WireModel per
element in the element's own colour, CPU-dashing every element through
apply_along so the parallel lines stay in phase.
- linetypes.rs: render a drawing's OWN complex linetypes (embedded text/shape),
document definition first and the bundled .lin catalog only as a fallback, so
a file-defined text linetype like PLYN shows its text.
- Run the dash/glyph layout in a local frame (f64 origin subtracted, re-split to
double-single after) so spacing stays precise at UTM coordinates, and pass
tracking = 1.0 so embedded text keeps its letter spacing.
Cargo.lock pins acadrust to the MLINE->MLINESTYLE handle-link reader fix.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Add SelectThenTwoValueCommand (gather a selection, then two values) and use it
for POLYSOLID: bare POLYSOLID gathers the polyline if none is selected, prompts
for wall width then height, and dispatches POLYSOLID <width> <height>.
SCRIPT/SCR, DATALINK, LANDXMLIMPORT, CUIEXPORT, CUIIMPORT/CUILOAD now prompt
for the path via ValuePromptCommand instead of printing usage when run bare;
each dispatches `NAME <path>` to the existing inline handler. The path keeps
its case (dispatch does not upper-case arguments), so case-sensitive paths on
Linux/macOS still resolve. A native file dialog would be a nicer follow-up.
Add SelectThenValueCommand (gather selection, then one value) and convert:
- SelectThenValueCommand: HYPERLINK (url), ARCTEXT (text), TEXTFIT (width),
SCALETEXT (factor), TCOUNT (start), TEXTMASK (Enter to apply).
- SelectThenKeywordCommand: JUSTIFYTEXT (Left/Center/Right/Middle/Aligned/Fit),
TCASE (Upper/Lower/Sentence/Title).
Each bare form gathers a selection when none is set, prompts, and delegates to
the inline handler. OBJECTSCALE already opens its dialog; TABLE CELL takes three
values (row/col/text) and stays typeable — TABLE bare already builds a table
interactively.
Bare COUNT now offers [All (by type) / By layer] as buttons instead of
immediately dumping the by-type tally. "All" dispatches COUNT TYPE, whose
output is identical to the old bare COUNT; "By layer" dispatches COUNT LAYER.
Counting a specific layer (COUNT <layer>) stays typeable. Finishes bucket B.
Add SelectThenKeywordCommand: if nothing is selected it first gathers a
selection (Enter confirms, following the ALIGN contract), then shows the
sub-verbs as buttons like KeywordCommand and dispatches `<name> <verb>
[value]` to the inline handler, which reads the still-selected set.
Convert CHPROP, ADJUST, XDATA, UNDERLAY and DRAWORDER. Verbs the single-value
generic can't express keep working by typing the full line: XDATA SET
<app> <value> and DRAWORDER Above/Under <handle> (a reference pick) are left
off the buttons; CHPROP/ADJUST/UNDERLAY are fully covered.
Verified end to end: SELECTALL then `CHPROP LAYER <name>` moves the selected
entity to that layer through the stepped front-end.
Convert the remaining bucket-B commands to prompt step by step instead of
requiring arguments or printing usage:
- KeywordCommand (buttons): VIEW, LINETYPE, SCALELISTEDIT, UCSICON, VSCURRENT.
- ValuePromptCommand (one value): LAYDEL (layer), ATTSYNC (block).
- TwoValuePromptCommand (new; two values): LAYMRG (source + target),
SETVAR (variable + value, blank second reads).
Each bare form now starts the stepped front-end and delegates to the existing
inline handler; the argument form is kept and is the dispatch target. The
direct visual-style shortcuts (HIDDENLINE / XRAY / …) and UCSICON's argument
form are untouched.
COUNT is intentionally left alone: bare COUNT already reports the tally (a
useful inquiry action, not a usage line), so prompting would only add friction.
Add KeywordCommand, a generic stepped front-end for keyword sub-verb
commands: bare NAME shows the sub-verbs as clickable buttons, and picking
one either dispatches `NAME <verb>` (action verbs like ATTDISP On/Off/Normal)
or prompts for an argument and dispatches `NAME <verb> <value>` (LAYERSTATE
Save/Restore/Delete <name>). It delegates to the existing inline handler, so
the logic stays in one place. First two of bucket B converted; the rest
(VIEW, LINETYPE, SCALELISTEDIT, SETVAR, LAYDEL/LAYMRG, and the selection-first
XDATA/UNDERLAY/ADJUST/DRAWORDER/CHPROP) can follow the same pattern.
The Annotation Object Scale dialog (advertised in v0.8.2) was dead code:
bare OBJECTSCALE was caught by the immediate "mark selection annotative"
arm, so the `"OBJECTSCALE" =>` dialog arm below it never ran and nothing
else opened the dialog. Move the quick mark to an explicit `OBJECTSCALE ADD`
keyword (what the ribbon "Add Scale" button now sends, dispatched whole so
it doesn't collide), leaving bare OBJECTSCALE to open the dialog.
USERI/USERR set one of five integer/real header registers and needed both
inputs (register + value) typed on one line; bare printed usage. Add a
two-step front-end (UserRegCommand): it prompts for the register as 1–5
buttons, then the value, and dispatches USERI/USERR <n> <value> to the
existing inline handler. The argument arm now requires a space so the bare
name reaches the stepped command.
Bare CLAYER / CDIMSTY / COLOR / LWDISPLAY / ISAVEBAK / FILEASSOC /
SAVETIME / ANNOSCALE printed the current value or a usage line and
stopped; only the fully-typed `NAME <value>` form set anything. Route the
bare form through ValuePromptCommand (the pattern LTSCALE / PDMODE / PDSIZE
already use): it prompts for the value and re-dispatches `NAME <value>` to
the existing inline handler, so the set/get logic stays in one place. The
bare-Enter getter is preserved — ValuePromptCommand dispatches `NAME `,
which the argument arm reports the current value for (added that empty
case to ISAVEBAK / FILEASSOC / SAVETIME, which previously errored on it).
COLOR keeps BYLAYER as an immediate shortcut rather than a prompt.
USERR / USERI are left as-is: they take two inputs (register 1-5 + value),
so they need a two-step command rather than the single-value prompt.
Bare RENAME printed a usage line and did nothing; only the fully-typed
`RENAME <type> <old> <new>` form worked. Add a stepped front-end
(RenameCommand) that prompts for the object type — as clickable buttons
(Layer, Text style, Dim style, Linetype, UCS, View) — then the current
name, then the new name, and dispatches to the existing inline handler.
The rename logic stays in one place; the argument form is kept and is also
the front-end's dispatch target. BLOCK is left off the buttons because the
inline handler does not implement it.
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
Match text style and height between TEXT/MTEXT objects (#361).
MATCHPROP copied only the generic properties, so Text->Text and
Text->MText matching left the destination's style and height unchanged.
The MatchProperties handler now also carries style + height between
text-like objects, both directions, like AutoCAD's "Text" special
property. bump_geometry re-tessellates so matched text repaints at its
new size.
Conflict: the fix (command_driver.rs) auto-merged; only the additive
test block in automation.rs conflicted with tests main added since the
PR branched. Resolved by keeping all four tests.
# Conflicts:
# src/app/automation.rs
sdf_text_emits_vector_ops_at_the_run_position laid glyphs into the process-
global text atlas, released the lock, then re-read the atlas in emit_text.
Another test growing or resetting the atlas in between shifted the uv keys,
so the lookup missed and the assert saw zero polylines — ~1 in 5 parallel
runs. Its own comment already noted it couldn't catch the one thing worth
catching (a mirrored corner mapping); the size-based tests and text_gpu's
vertex-order tests cover what remains. Not worth a global test mutex.
Upgrade a loaded plugin in place instead of failing on a locked binary.
A resident cdylib is mmap'd by its runner process. On Windows the file is
locked, so the marketplace's in-place overwrite failed with os error 32; on
POSIX the naive same-inode O_TRUNC would instead corrupt the running
plugin's mapping. replace_library now moves every resident library aside to a
.old stash and writes the new one under its own name — the correct
atomic-replace pattern on all three platforms.
The AppImage ships both the .desktop file and the hicolor icon, yet the dock
button came up blank on Wayland. A Wayland compositor has no StartupWMClass to
key on — it matches a window's app_id against the basename of an installed
.desktop file to find its icon — and iced defaults window app_id to an empty
string, which OCS never overrode, so nothing matched.
Set application_id to the reverse-DNS id the .desktop is installed under,
reusing the existing file_association::APP_ID const rather than a second copy.
iced enables both the x11 and wayland winit backends by default and feeds the
same application_id to each: on Wayland it is the app_id, on X11 it becomes
WM_CLASS. So this also changes the X11 WM_CLASS, and the old
StartupWMClass=OpenCADStudio would no longer match — realign it to the same id
so X11 keeps working too.
Confirmed on COSMIC/Wayland: the dock icon now appears. This is the Linux half
of the same class of bug as the Windows taskbar report (#392, still open): the
window not carrying the identity the OS needs to find its packaged icon.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Pass anno_scale to expand_insert in the text_font_rendering test.
The annotative feature (1b037a6) added a 15th anno_scale parameter and
updated the production call sites, leaving this integration test at 14
arguments — it failed to compile with E0061 once rebuilt. cargo build does
not cover it; cargo test does.
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]>
Review follow-ups to #390, which fixed PDF/print text for model space but
left the layout-plot path — the one issue #385 was filed from — broken.
viewport_content_wires rewrote points/snap_pts/key_vertices/aabb and then
cloned text_verts through untouched, so paper-space viewport text stayed at
model (UTM) coordinates: a dimension at model (25000, 12000) plotted its
lines onto the sheet and its glyphs kilometres off the page. A text-only
wire fared worse — TEXT/MTEXT carry no stroke points since text went
SDF-only, so it was dropped outright by the empty-clip and AABB rejections.
Project the glyphs through the same proj_abs the snap points use (via the
existing map_text_verts helper), cull per glyph on the quad centroid, count
them towards the paper AABB, and dim them like the wire colour.
Also in emit_text, which diverged from the wire pass it mirrors:
- divide the pen by `scale`, or a Fit/windowed plot prints text as
near-invisible hairlines while its own lines stay correct;
- apply the CTB plot style, or a monochrome.ctb plot renders the lines
black and leaves the text on its screen colour;
- reset the dash pattern, which is persistent PDF state the wire pass
leaves set — a HIDDEN-linetype last wire printed glyph outlines dashed.
export_table's doc claimed it honoured TEXTFILL and widened the bold pen
itself; it did neither. Gate fill_tris on textfill() so hollow-on-screen
text exports hollow, carry the bold flag and widen the pen by the 1.7x the
bake uses, and resolve Face once per family instead of once per glyph (the
atlas mutex is held for that whole walk).
uv_key identifies a glyph by its tile's uv_min, but grow_height rescales
every entry's V and reset rewinds the packer, leaving already-built quads
addressing the wrong tile — garbage on screen and a silent miss in the
export table, i.e. #385 again under #347's grow-the-atlas conditions. Bump
an atlas generation on both and fold it into the tessellation memo guard so
the text re-lays-out instead. Quads built earlier in the same pass that
grows are still stale for that pass; the guard heals them on the next one.
Tests: the integration test's "some wire has text_verts" assert passed on
pre-fix code too (the exporter ignored them; the scene always carried
them), and its dimension half was satisfied by the TEXT alone — assert per
entity handle instead, and stop colliding on fixed names in the shared temp
root. The unit test's bbox came from the same quads the mapping reads, so
it held by construction; pin the run's own world box instead. That still
cannot catch a mirrored corner assignment — verified by mirroring it — so
say so rather than claim coverage the assert doesn't have.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Render SDF text as vectors in PDF/print export (#385).
Text became SDF-only, so all text — standalone TEXT/MTEXT and dimension
text — rode WireModel::text_verts as GPU glyph quads that the CPU PDF
exporter never sampled. Reconstruct the vector geometry at export time via
a new GlyphAtlas::export_table() keyed by the quads' atlas uv_min.
The annotative feature (1b037a6) added a 15th `anno_scale: f32` parameter to `scene::cache::block_cache::expand_insert` and updated the production call sites, but the text_font_rendering integration test was left at 14 arguments, so the test binary fails to compile with E0061 once rebuilt.
Pass `1.0` (no annotative scaling), matching the `BlockCache::build(&doc, 1.0, ...)` call the test already uses.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Since text became SDF-only (the legacy stroke-text path was removed), all
text — standalone TEXT/MTEXT and dimension text — rendered on-screen only as
GPU glyph quads carried on `WireModel::text_verts`. The CPU PDF/print exporter
draws only wire stroke `points` and hatch fills, so it silently dropped every
glyph: dimensions and text were missing from exported PDFs and prints
(present in v0.7.6).
Reconstruct vector text in the exporter instead of resurrecting the stroke
path, so the SDF render path and its caching are untouched:
- sdf_atlas: `GlyphAtlas::export_table()` snapshots each baked glyph's vector
geometry (outline strokes + fill triangles, glyph space) keyed by its tile
`uv_min`, re-resolved through `Face` so it honours the current TEXTFILL.
- pdf_export: a new `emit_text` pass walks each wire's `text_verts` (one
6-vertex quad per glyph), recovers the glyph geometry by `uv_min`, and
affine-maps it into the quad — `DrawLine` polylines for stroke (LFF/SHX)
fonts, filled triangles for TrueType, plus solid decoration bars. Runs under
the same rotation/scale/clip CTM as the wires, so it covers model space,
paper space and windowed plots, and every SDF-text kind (text, dimension,
multileader, table, tolerance).
Text embedded in complex linetypes is SDF-only with no stroke fallback and
stays out of scope.
Tests: a unit test asserts a stroke glyph emits vector ops within the glyph's
world bbox; an integration test drives a real TEXT + linear DIMENSION through
scene.entity_wires() -> export_pdf and checks text reaches the file. Verified
bug-first: with emit_text disabled the end-to-end test fails with the reported
symptom (text adds no content).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
A symbol font's glyphs straddle the cap box by design — the diameter sign's
stroke overhangs it top and bottom — so anchoring a top-aligned line by the
nominal cap height alone hung it above where the drawing puts it, by the size
of the overhang.
Measure how far the first line's glyphs reach above the baseline and anchor by
that when it exceeds the cap. The fold is one-directional, so ordinary letters
(which stop at the cap) keep their exact previous offset.
The test pins the benchmark's own case: without this, the glyph draws from
0.7372 instead of the insertion point — matching the offset measured against
the file's own LINE endpoints for handle 1087.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
`solid_hatch_model` cast each corner from absolute WCS straight to f32 and
left `world_origin` at [0, 0]. At UTM magnitudes (~1e6) f32's 24-bit mantissa
resolves to ~0.06 units, so the quad snapped to a grid and the fill drifted
off its own outline.
Hold the corners in f64 until the AABB centre is known, then store each as a
small f32 offset from it — the same precision-preserving anchor
`hatch_model_from_dxf` already uses for real HATCH entities. All nine callers
feed through this one function, and every reader of `boundary` already adds
`world_origin` back, so they go from adding zero to adding the real anchor.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
A tolerance drew one row of ASCII soup — "RUN | ANGtol(M) | ..." — about
a tenth of its proper size. Six defects, each independent.
Rows were split on the two-character string "^J". A DWG stores a raw
newline, and the text-DXF reader rewrites the on-disk ^J to one before we
see it, so that split never fired on either mainstream path and every row
collapsed into the first. It now normalises all four spellings the break
arrives in — including the \P our own DXF writer emits, which made
open-DWG → save-DXF → reopen asymmetric.
Symbols were replaced with ASCII labels from a table that was itself
wrong: it read r as circular runout, n as angularity, p as position. The
font's own encoding says concentricity, diameter, projected tolerance
zone, and the benchmark's pre-exploded reference agrees. That table was
an inversion of acadrust's, which is self-contradictory — it binds n to
two different meanings. Do not re-derive from it.
The frame is a proportion of the character height: a compartment spans
-h..+h about its row's centreline, so it is 2h tall and the margin falls
out of that. It was built as height + 2*DIMGAP instead, which is a
different entity's geometry — DIMGAP sizes the dimension line's break and
the rectangle around a basic dimension, where it genuinely applies twice
per axis. Even fed a self-consistent style that can only ever produce
1.5h, so it was wrong in kind rather than mis-fed.
Height came from the entity's own field, which a DWG never sets — the
reader carries only the style's handle, leaving a small non-zero default
that a "> 0" test happily used. It now resolves the style by handle first
and name second: a DWG records the handle and defaults the name, a DXF
the reverse, so matching on the name alone would have picked the wrong
style for every DWG-read tolerance. An entity may also override single
style variables on itself, and this one halves its character height that
way; ignoring that drew the frame several times too large.
Rows stacked upward, every row inherited the widest row's columns, and
one full-width border boxed them all — none of which showed while the
parse only ever produced a single row. The insertion point sits on the
first row's centreline; anchoring the frame's bottom-left there offset it
by its own height, and by an amount that changed with the row count.
Cell text ran together because the glyph run asked for no letter spacing
while the width measurement assumed the font's own, so the characters
crowded inside correctly-sized boxes. Compartments are also measured by
what they draw now: the pen advance stops one letter-space past the last
glyph, and counting that made every one of them too wide.
Symbols come from a bundled stroke font, restored from this repository's
own history: it shipped QCAD's gdt.cxf until the text engine moved from
CXF to LFF, which removed every .cxf and left nothing in their place —
the ASCII labels date from exactly there. Converted to LFF, keyed by the
letter its \Fgdt;X escape carries, and registered under that name like
any other font, so a switch to it resolves through the ordinary lookup:
an MTEXT carrying the escape, or a text style named gdt.shx, reaches it
too, with no code of their own. The geometry is public domain by explicit
waiver; provenance is in the file's header.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
A benchmark drawing carrying a circle and an ellipse 800 km below its own
plane showed neither, and ZOOM Extents zoomed out to 800 km to find them.
Both come from sizing the view off the wrong axis.
Zoom and clipping answer to different extents and are now measured that
way: the zoom frames what the view actually shows, the extent across the
screen plane, while near/far span what lies along the eye direction.
Sizing both from the 3-D diagonal let one far-off Z drag the horizontal
zoom out with it. The two agree on a flat drawing, which is why it went
unnoticed.
A camera restored from a file's saved view keeps its pose, but nothing
had sized its near/far, so the projection fell back to a guess scaled
from the zoom distance — far too narrow when geometry reaches off the
plane, and the geometry was silently clipped away. It now takes the
drawing's own recorded extent.
$EXTMIN/$EXTMAX is untrustworthy for positioning — stale after an edit,
or the sentinel when the writer never computed it — which is why fit_all
scans entities instead. It is the right input here: too wide only costs a
little depth precision, too narrow deletes geometry, so the drawing's own
claim errs in the safe direction. It also costs nothing on open.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Installing a newer version of a plugin whose previous version is currently
loaded failed on Windows. The marketplace writes the native library to the
same filename, but a resident cdylib is memory-mapped by its runner process,
so the overwrite is rejected with "being used by another process" (os error 32).
Users hit this upgrading e.g. Land Survey 0.4.0 -> 0.4.4 and assumed a reboot
was required.
A loaded DLL can't be truncated or removed, but it *can* be renamed (the loader
opens it with FILE_SHARE_DELETE). install() now moves every resident library in
the package aside to a `.old` stash and writes the new one under its own name;
the stash keeps serving the running session and the loader picks up the fresh
library on the next start. Stale stashes are swept on the following upgrade, and
this subsumes the previous differently-named-library cleanup.
Adds regression tests, including one that reproduces the exact lock on Windows
(FILE_SHARE_READ | FILE_SHARE_DELETE, no write share) and confirms an in-place
overwrite fails while replace_library upgrades successfully.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
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]>
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]>
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]>
Three cuts to the per-edit cost, on top of the incremental wire arena:
- Enable OCS_WIRE_GPU_PATCH by default (kill-switch OCS_WIRE_GPU_PATCH=0), so the
in-place / append-tombstone arena — validated on the reference drawing (wire
block ~300ms + 265MB upload -> ~2ms) — is on for everyone; a bail always falls
back to the identical batched path.
- Resident patch: an all-Modified edit never reorders, so reassemble by walking
the recorded assembled order (~10k handles) instead of scanning the whole
document (~800k) to re-derive draw order. Add/Remove still scan (positions
change). ~14ms -> <1ms on a move.
- SDF text: a new content id misses the glyph cache every edit, but if no
text-bearing entity (Text/MText/Dimension/MLeader/Leader/Table/attribute/INSERT)
changed since the last build the glyphs are identical, so reuse them. ~23ms -> 0
on a plain-geometry edit.
A move/edit on this 800k-entity drawing now spends ~50ms (dominated by the undo
document clone) instead of ~85ms, and the wire GPU upload is gone.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
draw_depth_map rebuilt on every geometry_epoch bump — an O(document) walk (all
objects for SortEntitiesTable, all entities grouped-by-block and ranked), ~45ms
per edit on a 788k-entity drawing, and it runs on every viewport snapshot.
But draw order depends only on each block`s entity COUNT (the rank denominator),
the entities` handles, and the SortEntitiesTable — none of which a Modify (move /
rotate / scale / colour / hide) changes (owner and type are preserved, and the map
ignores visibility). Gate the cache on the journal: an all-Modified edit keeps the
map; only Add / Remove (count change) or a table change (a `full` delta) rebuilds
it. So the common move/edit now skips the walk entirely.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The single-batch arena required a set with no mesh/solid edges, so any drawing
with a 3D solid or mesh (this one: 1693 mesh-edge wires of 12200) disqualified the
WHOLE set — every edit fell back to build_wire_buffers re-uploading ~265 MB
(4.4M instances), stalling ~1s per edit on a shared-memory GPU.
Split the resident set into a regular-2D arena and a mesh-edge arena (the wires
that need the draw loops mesh-edge skip/black treatment), one WireArena each,
passed as &[&WireModel] subsets (no clone). Both patch incrementally; the mesh
arena carries is_3d_mesh_edge so the existing draw loop handles it. A changed
handle absent from an arenas subset is a no-op for it, so each edit touches only
the batch it belongs to. Result: the wire block drops from ~300ms + a 265 MB GPU
upload to ~2ms.
Two correctness points folded in from adversarial review:
- mesh-edge wires are stamped draw_depth 0 (matching WireGpu::from_run) so their
hidden back edges dont peek through the shaded fill; the mesh arena also always
bails on append (all its wires share depth 0, so relocation would reorder
coincident edges).
- buffers are capped at wgpus 256 MB limit — a batch larger than one buffer
returns None so the caller chunks it via the batched path (fixes a create_buffer
panic on the 355 MB regular batch).
Still behind OCS_WIRE_GPU_PATCH (default off). OCS_PERF prints the per-edit wire
timing + outcome.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The in-place-only arena still fell back to a full batched rebuild on add/remove —
re-uploading the entire wire instance buffer, which on a drawing whose wires
expand to millions of segments (~265 MB) stalls ~1s per add/remove on a
shared-memory GPU. Only move/edit avoided it.
Add append/tombstone so add/remove patch the instance buffer too: a new or
segment-count-changed entity bump-allocates a slab at the tail (buffers carry
1.5x headroom); an erased entity is tombstoned (blanked at const slot 0). The
huge instance buffer only grows/blanks by the changed entity.
Three correctness points make add/remove safe (each verified by an adversarial
review, findings folded in):
- draw_depth_map normalises each entity`s z-bias by the block entity count, so a
structural change re-scales every entity`s bias. Keep a CPU mirror of the const
array; on any structural change refresh every slab`s draw_depth and re-upload
the small (~1 MB) const buffer, leaving the instance buffer untouched.
- a tail-appended entity draws last, which only mis-orders wires whose overlap
resolves by submission order rather than the z-bias: transparency, and 3D-solid
fallback edges (Solid3D/Region/Body/Surface are excluded from draw_depth_map, so
their opaque edges share depth 0). `append_unsafe` bails to a full rebuild when
the set contains either.
- an in-place edit also flushes its consts to the GPU (a colour change is not
structural, so the whole-buffer refresh would otherwise skip it).
Still behind OCS_WIRE_GPU_PATCH (default off); every uncertain case returns false
and the caller does the identical full rebuild, so correctness never rides on the
fast path.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
hide_selected, end_isolation and register_solid_model called bump_geometry() —
a full-drawing invalidation (clears the tessellation memos, bumps block_epoch) —
even though each touches a known, bounded set of handles. On a large drawing
hiding a few objects then re-tessellated everything still visible.
Report the exact changed handles via bump_entities instead: hide/reveal mark
just the affected entities Modified so the resident set drops or re-adds only
those, and register_solid_model reports its one solid (also making a bulk
register loop O(n) rather than O(n^2)). No block-definition changes are
involved, so skipping block_epoch is correct.
Deliberately left on the full path: layer freeze/off (block sub-entity layer
visibility legitimately needs block_epoch), viewport-scale (annotation-key
sensitive), isolate (already cheap — keeps few), and undo/redo/style/file-open.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Point the acadrust patch at the OpenAEC-Foundation/acadifc fork (still the
`acadrust` crate). Its main carries the Arc copy-on-write entity storage that
makes the undo document clone O(entities) refcount bumps instead of a full deep
copy — cutting the per-edit snapshot on a 788k-entity drawing from ~1.7s to
~70ms. No source changes here: acadrust`s public API is unchanged.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
On a geometry edit the resident set gets a fresh content id, so the batched wire
path (build_wire_buffers) re-emits and re-uploads EVERY wire instance — the whole
buffer even when one line moved. Behind the OCS_WIRE_GPU_PATCH flag (default off,
native only), add a persistent per-entity instance arena: one long-lived
VERTEX|COPY_DST instance buffer + its WireConst storage, with a per-entity slab
table. An in-place edit (move / rotate / scale / colour — same segment count)
becomes a queue.write_buffer of just that entity`s slab.
Scope is deliberately IN-PLACE ONLY. draw_depth_map normalises each entity`s
draw-order z-bias by the block`s entity count, so an Add or Remove re-scales EVERY
entity`s z-bias — which a per-slab patch can`t honour — and a segment-count change
relocates the entity. patch() returns false for any of those (and for mesh-edge /
scissored sets), and the caller falls back to the identical full batched rebuild,
so correctness never rides on the fast path. An adversarial review of an earlier
append/tombstone design surfaced exactly these draw-order hazards; restricting to
in-place removes them while keeping the common interactive-edit win.
The Scene hands the changed handles to the arena via model_wire_gpu_patch,
recorded by try_resident_patch for the Model block and matched by content id in
prepare(); scissored paper viewports and mixed 2D/3D sets keep the batched path.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
MATCHPROP only copied the generic properties (layer, color, linetype,
linetype scale, lineweight), so matching text to text left the
destination's style and height unchanged. Copy the source's text style
and height to TEXT and MTEXT destinations, in both directions, like
AutoCAD's "Text" special property.
Adds an in-process regression test that drives the real interactive
command path (source pick -> destination selection) and fails on the
old code with the reported symptom.
Fixes#361
Co-Authored-By: Claude Fable 5 <[email protected]>