A custom entity without its object enabler (an AutoCAD Architecture door,
wall, ...) arrives as Unknown but ships a cached proxy-graphics preview —
the vector fallback AutoCAD itself draws. The old decoder read only a lone
type-6 polyline; generalise it to the real record stream
([total_size, record_count, {record_size, record_type, data}...]) and decode
the geometry records: polyline (6), polygon (7, closed) and circular arc
(4/5, flattened to a strip). Trait records (colour/lineweight/...) are
skipped for now, so the preview draws in the entity's own colour.
The Unknown-entity path now draws every decoded primitive in one wire, so
these objects appear (and become selectable) instead of vanishing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A raster image reference now resolves through one memoised path shared by
the loader and the unresolved-reference placeholder:
- http/https URLs are fetched natively (rustls, 8s timeout, 32MB cap); a
local file is read as before. The web build never fetches. The cache is
cleared when a new document opens.
- Online, the fetched image displays; offline or on a broken/missing
reference, the frame + saved path text still shows. One resolver keeps the
two in agreement without fetching twice.
The raster is also clipped to its boundary: the image draws as the
triangulated clip polygon (ear-clipped) with per-vertex UVs instead of a
full quad, so pixels appear only inside the clip. Clip-boundary Y is raster
space (top-down), so it is flipped to the image's Y-up space in both the
outline and the clip triangulation so they align.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An IMAGE whose file can't be opened (a URL, or a missing/renamed file) now
draws its clip/frame outline plus the saved path as centred text in the
image colour — AutoCAD's broken-reference placeholder — instead of an empty
frame. OCS never fetches remote paths. Bumps acadrust so the entity's path
is populated from its ImageDefinition.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A CONSTANT ATTDEF has no per-insert ATTRIB — its value belongs to the block
itself — so block expansion must draw it instead of skipping every ATTDEF.
Non-constant templates are still skipped (the insert supplies their ATTRIB).
Also bumps acadrust to decode the embedded MTEXT of R2018 multiline
attributes, so a value like "A\PB" renders both lines.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The mode-4 tick was drawn as a full vertical line centred on the point;
AutoCAD draws it rising from the point upward by PDSIZE/2. The '+' and
'×' arms were half size — AutoCAD extends them to the full PDSIZE so the
cross pokes out past an enclosing circle/square (which sit at PDSIZE/2).
Ground-truthed every PDMODE combination against ezdxf's point renderer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
MTEXT background fill flag 0x02 means "use the drawing-window colour" — a mask
that erases geometry behind the text — while 0x01 means "paint background_color".
The fill-colour pick tested 0x01 first, so text flagged 0x03 (both bits, e.g. a
dimension whose DIMTFILL is the drawing background) painted the stored
background_color (a stray grey box) instead of masking. AutoCAD shows no colour
there.
Test 0x02 first: when set, fill with the drawing-window colour (invisible on a
matching canvas) regardless of 0x01; a plain 0x01 fill still paints its colour.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A dimension's definition points are baked into its *D block as POINT entities on
the Defpoints layer. AutoCAD treats them as grip markers and never draws them,
but OCS rendered them through the baked-block path as PDMODE glyphs (a vertical
tick with the drawing's PDMODE 4 / PDSIZE). Two of them sit on the measured
geometry at each extension line's foot, so the tick read as the extension line
running past the geometry — the lines looked too long.
Skip POINT sub-entities when rendering a dimension's baked block; only its lines,
arrowhead blocks and text are geometry.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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 <noreply@anthropic.com>
A 2D SOLID's `to_truck` emitted only its 4 perimeter edges; its fill came from
a solid-fill HatchModel the scene injects for top-level SOLIDs. That injection
never runs for a SOLID nested in a block, so a filled arrowhead like the
`_BoxFilled` dimension arrow rendered as a hollow box outline.
`to_truck` now also emits the two fill triangles (visual quad p0→p1→p3→p2, the
second degenerate for a triangle SOLID). Block expansion carries fill_tris, so
the arrowhead — and any SOLID inside a block — renders filled; the dimension /
MultiLeader / mesh 2-D fill pass already routes these to the flat fill batch.
Top-level SOLIDs also keep their HatchModel fill; the duplicate is invisible for
the opaque solids these are.
Verified on budweiser dim 3728: the `_BoxFilled` arrow SOLID now tessellates to
fill_tris (2 triangles) instead of an empty fill.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An OLE2FRAME drew only a rectangle-with-X placeholder. AutoCAD shows the OLE
object's cached presentation image, which the DWG stores as a BMP embedded in
the OLE data blob (a BITMAPFILEHEADER "BM" + DIB).
`ImageModel::from_ole2frame` scans the blob for a self-consistent embedded BMP
(validating the DIB-header size, so a metafile/other-format OLE cleanly falls
back), decodes it with the `image` crate, and builds the textured quad at the
frame rectangle with a per-corner double-single split so it stays precise at UTM
scale. The scene's image collection now feeds OLE2FRAME entities through it
alongside RasterImage. The placeholder's diagonal X is dropped — only the frame
border is drawn now, so it no longer crosses over the bitmap.
Verified on budweiser handle 3088: the 32×32 embedded bitmap decodes to RGBA
with the expected colours (yellow upper-left, blue centre, red lower-right),
upright and positioned at the frame corners.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A Table whose cells are baked into a *T block (block_record_handle) was rendered
by tessellating the block's sub-entities directly. Those are laid out in
block-local space (origin at the table's top-left corner) — the Table entity
carries the world placement in insertion_point + horizontal_direction, exactly
like an INSERT — so the whole table drew at the world origin instead of its
location.
Each baked sub-entity is now cloned and placed (rotated about the local origin
by the horizontal_direction angle, then translated by insertion_point) before
tessellation, so tessellate_entity produces correctly located, relative-to-eye
geometry. Verified on budweiser handle 9378: its baked lines move from (0,0) to
the insertion point (-1200018.7, -800071.4).
Also bumps acadrust to pull the OLE2FRAME corner-decode fix, which places
OLE2FRAME entities (e.g. handle 3088) at their real corners instead of the
(1,1)/(0,0) defaults.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A fit-point SPLINE (no control vertices) was drawn with Catmull-Rom through its
fit points, which ignores the stored begin/end tangents and uses local slopes.
An open curve then peeled away from the true shape at its ends — budweiser's
handle 2735 lost the tangent-driven overhang at its top end.
A fit spline is the C² cubic that passes through every fit point clamped to the
stored end tangents. Replace Catmull-Rom (kept only for closed splines, whose
wrap the clamped solve doesn't model) with `fit_spline_polyline`: chord-length
parameterisation (honouring knot_parameterization), a tridiagonal Thomas solve
for the knot slopes with clamped-tangent or natural end rows, and Hermite
evaluation. Verified on 2735: the curve passes through all five fit points and
its ends follow begin_tangent/end_tangent (dir·tan = 0.997).
Also fixes grip-selection: grips()/apply_grip() only ever looked at
control_points, so a fit spline (0 CV) showed no grips and couldn't be
grip-selected or edited. Both now fall back to fit_points when there are no
control points — dragging a grip moves the fit point and the curve re-fits on
the next tessellation, keeping the end tangents. The mirror path already
transformed fit_points; move/rotate/scale run through acadrust.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Loaded 3DSOLID faces rendered on a ~0.06 m grid and crawled against their own
wireframe at UTM scale. Both ACIS tessellation paths cast each vertex f64→f32 in
the samplers, and only split the double-single low half (verts_low) when the
body carried a `transform` record. But many files — budweiser among them — store
solid geometry in absolute coordinates with no body transform, so verts_low
stayed empty: the GPU got position_low = 0 and reconstructed an already
f32-quantized absolute position, while the feature edges (split unconditionally
from f64) stayed exact.
Both paths now accumulate vertices in f64 and hand them to one finalize step:
finalize_mesh(name, verts_f64, normals, indices, color, xform)
the only place a solid vertex becomes f32 — it computes the world coordinate in
f64 (body placement applied, or identity), then splits into (verts, verts_low)
unconditionally, exactly as attach_feature_edges already does for the wireframe.
push_quad / tess_plane_face / tess_cone_face / tess_sphere_face /
tess_torus_face / tess_spline_face and the truck append_group / bespoke_face /
append_spline_faces all carry f64; apply_body_transform is gone.
Verified on budweiser (all solids, body_transform = NONE): verts_low is now
populated with the captured residual, max |low| ≈ 0.0624 m. The Model-tab
(truck_tess::polygon_to_result) and edge paths were already correct and are
unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Curved 3-D solid faces used fixed per-LOD grid counts, so a partial arc (a
sphere cap, a torus C-arc, a short cone wall) was sampled as finely as a whole
rim, and the density knobs were scattered across four files.
Every curved face (sphere / cone / torus / spline), feature edge and isoline
now derives its segment count from a chord-height tolerance plus the arc's own
radius and span — the same model the 2-D circle/arc/ellipse wires use
(tess_util::arc_segments): a big rim samples finer than a small one, a short arc
proportionally less than a full turn, and facet error is size-independent.
All tolerances live in one block at the top of solid3d_tess: LodConfig is now a
single chord fraction per LOD (arc_segs / circle_segs derive counts), plus
EDGE_CHORD_FRAC, TRUCK_CHORD_FRAC and BOUNDARY_CHORD_FRAC. FACETRES scales the
fraction. The cone height is a straight generator (no curvature) so it meshes in
one division. Removes the scattered constants (circ_segs/grid_u/grid_v,
CURVE_REL_TOL, BOUNDARY_SEGS, per-site M / EDGE_SEGS / ring_segs).
Adaptive counts come out lower, not higher: the € solid drops 3056→1780 tris, a
full sphere stays at 1024, five-solid total is 2989.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An open "C" tube (the € currency symbol's ring) rendered as a full closed
donut: truck builds a torus as a full revolution and can't trim it, and the
wireframe/silhouette arc estimate was wrong.
The arc estimate failed because `collect_face_polygon` reads only the first
loop, and the "widest empty gap = tube body" heuristic breaks once the tube
carries interior holes (where the two horizontal bars punch through it): a hole
splits the body into wide hole-free stretches that masquerade as the opening.
`torus_phi_range` now walks every boundary loop and classifies each: a minor
circle sitting in a constant-φ plane (spread < ~8.6°) is an end cap; a wider
loop is an interference hole. The opening is the gap flanked on both sides by
end caps — a hole never bounds it — so the body is the rest of the turn.
Torus faces now route to the bespoke sampler (like spheres); `tess_torus_face`
clips its φ grid to that arc, so the shaded tube is an open C. `torus_isolines`
and `collect_curved_gens` use the same range for the wireframe rim and the
silhouette. Removes the now-dead truck torus path (`torus_faces`,
`Outward::Torus`, `vadd`).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A torus's ISOLINES were only its minor cross-section circles, so a ring read
as a row of disconnected circles rather than a ring — the outer and inner
profile arcs that make its outline were never drawn. They are the major
(ring-direction) isolines: a circle around the ring at each tube angle, the
outer (θ=0) and inner (θ=π) ones being the defining outline.
Both directions now draw. The revolution is swept in full rather than clipped
to the face: a tube's boundary is only its two end caps, clustered at the ends,
so it never covers the revolution arc and can't say which way round the tube
runs — the same limitation the arc range already had. Full is correct for a
closed or nearly-closed ring (this Euro symbol is a ~345° torus) and only
over-draws a short torus fillet by the small missing arc, which a later
winding-aware extent would tighten.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A curved solid's fillet/blend boundaries are `intcurve-curve` spline edges.
The edge collector handled only straight and elliptic curves, so a spline
edge fell back to a straight chord between its two vertices — or, for a
closed-loop edge whose endpoints coincide (a sphere/torus face rim), to
nothing at all. Those boundaries simply weren't drawn.
Both the feature-edge list and the face-boundary sampler now sample the
spline over the edge's own parameter span via the new `SatIntCurve`, so the
curved edges draw as real curves. On budweiser2018.dwg handle 2700 the
solid's edge-segment count rises from 110 to 392 — the previously missing
sphere/torus rim curves.
The face-boundary sampler feeding a real loop (instead of a degenerate
chord) is also the prerequisite for trimming those curved faces to their
extent, a follow-up.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extends the curved-face wireframe treatment from cones to spheres and tori, so
a solid built from those faces reads the way a cylinder already did.
ISOLINES: sphere faces get meridian lines across the face's colatitude span at
evenly spaced longitudes; torus faces get minor cross-section circles at evenly
spaced revolution angles. Both clip to the face's own parametric window (from
the boundary polygon via a shared `angular_span` gap-finder), so a partial
sphere or torus arc doesn't draw lines out over the missing part.
Silhouettes (DISPSILH): `CurvedGen` becomes an enum. A sphere silhouettes as
the great circle perpendicular to the view, clipped to the face window; a torus
as the two curves where the tube turns edge-on, traced around the revolution.
The silhouette now uses the camera's view *direction* alone, not the eye
position — a single parallel direction for the whole scene — so the outline
follows the view angle, holds still under pan, and doesn't foreshorten, which
is the orthographic silhouette a CAD wireframe expects.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ISOLINES show a curved solid from any angle, but its outline still cut across
whichever fixed longitudinal lines happened to face the eye. DISPSILH draws the
true silhouette — the two lines where a cylinder (or cone) turns edge-on to the
view — and it moves with the camera, so it cannot be a baked edge.
Tessellation stores a world-space generator per cone/cylinder face on the mesh
set (axis, radial frame, radius, cone taper, extent), sharing the face-extent
extraction with the ISOLINES collector. Each frame `upload_silhouettes` walks
those generators, solves `du·cosθ + dv·sinθ = -tanα·da` for the two edge-on
angles (`φ ± acos(...)`, `φ ± π/2` for a cylinder), and builds a line list in
the mesh vertex format — double-single, so it stays precise at UTM. It draws
through the existing wireframe / edge pipelines in the wireframe and
shaded-with-edges passes.
Gated on the document's DISPSILH header flag, which defaults off — so the
per-frame pass is a no-op unless the drawing asks for it. Scoped to top-level
solids: a block instance's generators aren't transformed by the INSTANCE pose
yet, so they are cleared on the block path (its isolines and feature edges
still draw).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A cylinder's wireframe showed only its two rim circles, so from the side it
read as two disconnected ellipses rather than a cylinder. AutoCAD draws
ISOLINES — a fixed set of longitudinal lines on each curved face — so the
surface reads from any angle.
`collect_isolines` walks the cone-surface faces (cylinder and cone), recovers
each face's height and angular extent with the same logic `tess_cone_face`
uses, and emits `header.isolines` (default 4) longitudinal segments from the
bottom rim to the top rim. They are appended to the feature-edge line list, so
they ride the existing body transform, double-single split, and top-level /
per-INSTANCE re-split for free.
Being part of `edge_verts`, they inherit the edge pass's gating: drawn in the
wireframe modes and in shaded-with-edges, hidden in plain shaded — matching
where AutoCAD shows isolines. `isolines` is threaded from the header through
the tessellation entry points alongside `facet_res`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A 3D solid's shaded faces used absolute f32 world coordinates while its
feature edges carried the double-single (high, low) pair the mesh shader
reconstructs relative to the eye. At UTM scale the faces snapped to a
~0.06 m grid, so the shaded surface crawled against its own wireframe as
the camera moved.
`apply_body_transform` is where a solid gets its world placement — the
large body translation is exactly where the coordinate stops fitting an
f32. It now computes each world vertex in f64 and splits it into the pair,
filling `verts_low` the same way the feature edges already are. Both
tessellation paths (truck B-rep and the bespoke sampler) route through it,
and the downstream re-split points (top-level offset, per-INSTANCE block
transform) already consume the pair, so they inherit the corrected
residual. Picking, which reads `verts_low` too, sharpens at UTM as well.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three faults in the in-place editor, all on the same columnar UTM MTEXT.
Preview at UTM was garbled: the panel drew each stroke point as an absolute
f32, dropping the double-single low half, so at ~1.2M coordinates every glyph
collapsed under f32 cancellation (317 distinct x positions fell to 17). The
preview fits its own box and never lands on the drawing, so it now lays out at
the origin — insertion is a pure translation, so shape, wrapping and alignment
are unchanged, and no large value reaches an f32.
Columns vanished from the preview: `build_mtext` rebuilt the entity from
`MText::default()`, keeping only the seven fields the toolbar edits and zeroing
the rest — column_data above all. It now starts from the entity as opened, so
columns (plus rotation, normal, defined height, background fill, annotative)
round-trip untouched. (Commit-of-edit was already safe — it updates the live
entity in place — so this was a preview-only miss.)
Columns collapsed on the first keystroke: every edit rewrites `content`
through doc_to_cells -> cells_to_doc, and `Cell::Break` carried only the
paragraph properties, not whether the break was a `\N` column break or a plain
`\P`. So one round-trip flattened every `\N` to `\P` and the committed value
lost its column breaks even though column_data survived. `Cell::Break` now
carries a `starts_column` flag; a typed or pasted break is always a plain `\P`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two layout fixes on the same MTEXT (budweiser2018.dwg handle 2609 / 8782).
Fraction size: `\H0.7x;\S1/2;\H1.429x;` is how a 70 %-scaled fraction is
spelled, so the 0.7 already *is* the fraction's size — the earlier constants
shrank each half again on top of it and buried the stack inside one line.
A half is now near the run height and the stack stands about twice that: the
numerator rises past the cap and the denominator sits on the baseline, the way
a fraction is meant to grow past its line rather than be squeezed into it.
Alignment persistence: a `\p...;` block stays in force until the next `\p`, so
a paragraph opened by a bare `\P` / `\N` inherits it. The parser records only
the literal codes, so the second line of a centred or right column
(`\pxqc;col2\P≡`) fell back to the left edge while its heading stayed aligned.
Layout now carries a paragraph's whole property block forward to any paragraph
that sets none of its own — measured, the column symbols land on the same
centre / right edge as their headings.
Kept in the layout adapter rather than the parser: acadrust faithfully records
which paragraphs carry an explicit `\p`, so persistence is a rendering
interpretation, and the literal record stays available to an editor.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two things an MTEXT can ask for that the layout ignored, both found on the
same drawing (budweiser2018.dwg handles 8782 and 2609).
Columns: `column_data` was read but only ever reached the properties panel,
so a three-column MTEXT ran as one stacked block. Each line now carries the
column it belongs to, the row counter restarts per column, and wrapping
measures against the column rather than the block — measuring against the
block would let a line run across the gutter and over its neighbour.
Paragraph alignment already worked, which is what made the bug read as
"columns are there but misplaced": `\pxqr;` pushed col3 to the right edge
either way.
Only explicit `\N` breaks move to the next column. A column that fills up
does not spill into the next on its own — `heights` / `auto_height` are not
read, so a column runs as long as its content.
Stacking: `\S1/2;` was flattened to the literal "1/2", which is why the
line then overflowed its 7.26-wide box and dropped `°C ª` onto a second
row. The halves now share one horizontal slot, drawn at a shrunk height
with the numerator lifted, so the slot is as wide as the wider half. `^`
stacks without a rule; `#` stays inline `num/den`, which is what a slanted
fraction reads as and needs no second baseline.
The fraction rule rides a run-less stroke group: a group carrying a
`GlyphRun` renders as SDF quads with its strokes suppressed, a run-less one
keeps them, and the rule is plain geometry rather than a glyph.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A wide polyline draws a solid band, but hit-testing only ever saw the
centreline 8 px out, so a donut could only be selected by clicking the
phantom circle inside the disc it paints. `self.hatches` — the map the
hatch pick channel walks — is filled from HATCH and SOLID alone, so the
band `wide_fills` produces was drawn (via `synced_hatch_models`) and never
picked.
The band rides in on `pick_tris`, the same channel a thickness wall uses:
`wide_fills` already computes it, so it costs one tessellation and inherits
the block transform. It ear clips rather than fans — an arc band is an
annular sector, concave on its inner edge — reusing `triangulate_planar`.
Wiring `pick_tris` through `Lines` alone was not enough: a plain wide
polyline leaves `to_truck` as `Contour`, and a PLINEGEN=0 one as
`SegmentedLines`. Both now carry it.
This needs the wire's cullable box to cover the band, and it did not.
`bounding_box()` boxes a polyline's stored vertices, knowing nothing of the
width painted around them: a donut's two vertices sit on one horizontal
line, so the box had zero height and rejected every click on the disc
before any triangle was tested. Every site that stamps the entity box onto
a wire now goes through `set_wire_aabb`, which widens it over `pick_tris`
(a no-op for the entities that have none, which is nearly all), and the
block cache chains `pick_tris` into its own box for the same reason.
That box was already too small for the centreline itself — an arc leaves
the vertex box — so a donut was unselectable at its top and bottom at
enough zoom. Widening fixes that too.
Verified end-to-end: tessellate a width-0.5 donut on a 0.05 centreline
radius, then `click_hit` five points 20 px clear of the centreline — all
land, and a click outside the disc still misses.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
DXF codes 43 / 40 / 41 store a polyline band's full width, and
`polyline_segment_fill` offsets ±hw about the centreline, so the stored
value has to be halved. Both `wide_fills` passed it in whole and drew
every wide polyline at double width.
The codebase was writing the spec value and reading it as something else:
`make_donut` stores `outer_r - inner_r`, a full width, which `wide_fills`
then treated as a half-width. So OCS mis-drew even its own donuts.
Seen on budweiser2018.dwg handle 2579, a donut of width 2.2 on a 1.1
centreline radius: it should fill out to radius 2.2 and instead reached
3.3 — a disc 1.5× its real size, overflowing the square it sits in.
Affects every wide polyline, so TRACE and PEDIT-widened runs come back to
true size too. The writers already stored full width and are left alone,
as is the property panel, which shows the file's own value.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Same hazard as the DYN guide in 48a43aa, in the three remaining dashed strokes:
a dash pattern is measured in pixels, so a path costs the tessellator a quad
every few pixels and its price is set by screen length. All three run from a
projected world point — an entity endpoint for the extension guides, the
acquired point for the OTRACK alignment line — which lands millions of pixels
away for a far or very large entity. That is millions of quads, and the process
fills RAM (#406).
The alignment line even said "the canvas clips it to the viewport" as the reason
extending it by 5000 px was safe. It isn't: the canvas clips after tessellating,
so every quad is built first. That assumption is what made these look bounded.
Clip in `clip_seg` (Liang–Barsky) before stroking, padded a dash period so a
guide still visibly leaves the frame. Unit-tested: a guide from 5,000,000 px
away comes back under 2000 px with its direction and its on-screen end intact,
a fully visible segment is untouched, and one that misses is dropped.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The dynamic-input guides are dotted, and a dash pattern is measured in pixels:
the tessellator emits a quad every few pixels, so a guide costs in proportion
to its SCREEN length, not to what it represents. Entering a large distance
(5000000 in the value box) projects the cursor millions of pixels from the
anchor, so the guide asks for millions of dash quads — gigabytes — and the
process fills RAM and dies while building a frame whose content is off-screen
anyway (#406).
Clamp the drawn length to just past the viewport diagonal. `dx`/`dy` still come
from the true cursor, and the angle and read-outs are computed from the raw
length, so direction and every displayed value are unchanged — only the tail
past the viewport edge, which nobody can see, is dropped. Clamping `cursor`
bounds every guide below it: they all derive from it, including the polar arc
and the perpendicular endpoint.
The reporter's own bisect points here: with DYN off the crash does not happen,
and drawing the same line by command (which builds no interactive guide) never
reproduced it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An entity from an application we have no reader for — e.g. an Autodesk Raster
Design embedded raster image — now arrives as `Unknown` (acadrust 769741d) and
rendered as nothing at all, so the drawing silently lost it with no hint that
anything was there.
Such entities almost always ship proxy graphics: the vector preview their author
cached for precisely this case, and what AutoCAD itself falls back to when the
object enabler is absent. Decode it and draw it, so the entity holds its real
place and size.
The record grammar has no reference implementation to copy — LibreDWG keeps the
blob raw ("see par 29") and ACadSharp stores the bytes without parsing them — so
rather than infer a general decoder from one sample, `proxy_graphics` accepts a
single polyline record and only when the blob matches it byte-for-byte: sizes
must agree, the points must consume every remaining byte, and coordinates must
be finite. Anything else returns None and draws nothing, exactly as before, so
a file this does not model cannot regress or sprout invented geometry.
Verified on a Raster Design drawing: its 140-byte preview decodes with 0 bytes
left over to the image frame (1192.149 x 877.824, closed), matching what ODA
reports for the same entity. Unit-tested against that real blob.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The uncaptured-error handler from #404 was armed in `Pipeline::new`, which
`MultiPipeline::ensure_len` runs again for every pane added — so the slot was
re-registered on every new pane. Move it to `MultiPipeline::new`, which runs
once per device; if iced ever rebuilds the device it builds a new
`MultiPipeline` too, so the fresh device stays covered — where a `Once` guard
would have left it on wgpu's panicking default.
Throttle the message. The handler slot is per-device and single, so it also
catches iced's own draw errors and is the only thing that can report them — it
has to stay loud. But a validation error normally repeats on every frame, and
an unthrottled print floods stderr at refresh rate and buries the first, most
useful message. Log the 1st, 2nd, 4th, 8th … occurrence instead: the error is
never hidden, the running count shows it is ongoing, and the output stays
finite.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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.