Route model, paper, viewport and nested block content through one traversal while preserving leaf renderers. Carry decoded material state into mesh shading and harden solid geometry conversion.
Keep Layout output on the selected physical sheet and preserve hatch lineweights and plot styles through paper, viewport, and nested block paths.\n\nRefs #607
Use the same area pipeline as model space while keeping Layout bound to the physical sheet and printable margins. Share scale, rotation, clipping, and viewport fills across preview, PDF, and printer output.
Avoid full document, cache, and GPU rebuilds for entity deltas so add, delete, history, selection, and live polyline edits stay responsive on large drawings.
Keep resident wires, draw depths, GPU slabs, and edit history incremental across entity changes. Defer rollover work to avoid cursor jumps on dense drawings.
Replace full-document snapshots with Arc first-touch deltas and batch cache invalidation. Reuse interaction and GPU category caches across unrelated edits.
A wipeout defined inside a block never masked the geometry once the block
was inserted — wipeout_models only scanned top-level entities, so the
wipeout was invisible unless you entered block-edit (where it is
top-level). It also drew a stray mask at its raw block-local coordinates
because the top-level scan lacked the block-defn reject the hatch path
uses.
Descend visible inserts and place each block-internal wipeout in world
space, applying the accumulated insert transform via apply /
apply_rotation. Add the belongs_to_visible_block filter so block-defn
wipeouts only appear through the descent, in world space.
Bumps acadrust for the matching transform_wipeout fix: its u/v basis was
double-scaled (apply_rotation then another scale-factor multiply), so a
scaled insert grew the mask; the descent sidesteps that path but the
explode/copy commands need the crate fix.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Two rendering defects surfaced on the budweiser benchmark drawings, both
tied to a drawing's annotation scale.
Annotative scale representations. A drawing may materialise each annotation
scale as its own object (here a block insert per scale, on a "0 @ <scale>"
layer, each carrying a per-object AcDbContextData bound to one scale).
Nothing filtered them, so every scale drew at once — a 1× copy stacked
under a 10× one. Add annotative::annotative_offscale and apply it in the
three paths that expand block content: the wire block-defn cache, the
resident/top-level visibility gate (model space only — paper viewports use
their per-viewport frozen scale layers), and the exploded-insert hatch/fill
walk (filtered before normalize strips the handle). An object is shown when
a representation matches the current scale; the off-scale ones are hidden.
When the current scale is unsupported by any representation, fall back to
the base "1:1" copy instead of hiding everything (the object must not
vanish) or showing everything (the copies must not stack).
Model-space linetype scale. PSLTSCALE (a paper-space flag) was applied to
model-space linetypes via the annotation scale, so a drawing saved at e.g.
CANNOSCALE 10:1 drew its linetypes 10× off. Thread a `paper_space` flag
through tessellate_entity and gate the PSLTSCALE factor on it, so it only
scales linetypes for content shown inside a paper-space viewport; model
space keeps LTSCALE × CELTSCALE.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
A dimension bakes its geometry into a per-instance *D block, offset into
the owning block by the dimension insertion_point. When that dimension
sits inside a block (INSERT → block → Dimension → *D), only its top-level
form drew; nested it rendered nothing, and once the lines/text/arrow
outlines were routed through the block cache the arrowhead FILLS were
still missing:
- 2D SOLID arrowheads: block_cache's Dimension arm now expands the *D
block, translating each sub by insertion_point (WCS → block-local) and
routing Insert subs through build_nested_ref, others through
tessellate_sub_local (which emits the solid fill_tris).
- Insert(hatch) arrowheads (custom arrow blocks = LwPolyline + Hatch):
the hatch renders via the separate GPU explosion path, which only
descends INSERT→INSERT→Hatch trees and so never reached a hatch behind
a Dimension. block_has_hatch now recurses through a Dimension into its
*D block, and the fill explosion's new explode_including_dims lifts
each dimension child's *D content into the owning block's frame
(translate insertion_point, then apply the insert transform) so its
arrow inserts / hatches ride the normal walk.
Table *T baked blocks get the mirror treatment in block_cache.
Co-Authored-By: Claude Fable 5 <[email protected]>
The wipeout pass ran after the wire pass with no draw-order bias, so
the mask erased its own boundary frame (and everything else at its
plane regardless of draw order). The mask uniform now carries the
entity's draw-order depth (uniform grows 80 → 96 bytes) and biases its
clip-z like every other pipeline, and the frame wire rides a
+half-rank override — the later mask pass fails the depth test at the
frame's pixels, so the boundary stays visible, while entities drawn
above the wipeout are no longer erased.
Co-Authored-By: Claude Fable 5 <[email protected]>
PDF underlays only drew their clip outline / insertion cross; the page
itself never appeared. The chain now runs: the definition's file path
resolves with the raster-image fallbacks (as stored, relative to the
drawing, basename next to the drawing — attach-time relative paths go
stale), the page rasterises through the system pdftoppm (poppler) at
150 dpi — no new crate; missing tool degrades to the outline — cached
per (path, page) including negative results, and the pixels feed the
existing image pipeline as an ImageModel: 1 underlay unit = 1 PDF inch
(px/dpi × entity scale), rotated about the insertion, fade as opacity,
the clip polygon (non-inverted) as the textured region. Draw-order,
layer visibility and per-viewport freezes ride the generic image path;
in-place edits rebuild through the shared image seed.
When the page renders, the wire path draws its frame with an interior
pick surface instead of the lone cross, so selection covers the visible
page. wasm keeps the outline placeholder (no external process).
Co-Authored-By: Claude Fable 5 <[email protected]>
- Block reference Name (Misc) is editable: the dropdown re-points the
insert to another definition; typing a new name renames the definition
everywhere (record re-key, Block marker, every INSERT reference).
Anonymous (*) and xref blocks stay read-only, with the same guards
added to Scene::rename_block.
- Custom single-control dropdown (text field + caret) built on the
shared floating_below overlay so the list always opens downward,
unlike iced's space-based menu placement.
- Consecutive "<Base> X/Y/Z" rows (Position, Scale, Start, Center, ...)
collapse into one expandable summary row across all entities; the
expand state persists across rebuilds and selection changes.
- The BEDIT block tab no longer offers the layout Rename/Delete
context menu.
Co-Authored-By: Claude Fable 5 <[email protected]>
Follow-up to the LINE/POINT WCS fix - the same bug class elsewhere:
- 2D SOLID rendered its OCS corners raw in both the wire path and the
shared fill builder (solid_hatch_model); SHAPE placed its marker at
the raw OCS insertion. Both now map through the arbitrary axis.
- Grips: central wrap in view/dispatch.rs shows planar entities' grips
at their world position (OCS→WCS) and converts drags back (WCS→OCS
via the new wcs_point_to_ocs) - per-entity grip code stays in raw
field space, INSERT keeps handling its own OCS.
- Pulls acadrust 81ebfee: OCS-aware bounding boxes, translate,
transform and mirror for the ten planar entity types, so MOVE /
ROTATE / MIRROR and culling work on mirrored entities too.
Co-Authored-By: Claude Fable 5 <[email protected]>
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]>
GRADIENT silently degraded to a plain hatch: add_hatch rebuilt the
render model from the DXF entity but never encoded the gradient on it,
so the committed fill lost its gradient entirely. The entity now
carries the full gradient record (name, angle, colour stops,
solid-fill flag), and the standard shape set became selectable:
Linear / Cylinder / Spherical / Hemispherical / Curved plus an Invert
toggle, offered as prompt buttons on the GRADIENT command and as a
Gradient type dropdown + Invert box in the properties panel. All nine
standard DXF names round-trip (inverted linear persists by swapping
the colour stops), and the shaders shape each kind distinctly —
mirrored cylinder, eased curve, dome-profiled hemisphere — on both the
native and web pipelines without changing the GPU struct sizes.
Co-Authored-By: Claude Fable 5 <[email protected]>
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]>
Hatch boundary paths in real files do not store their edges as a
sequential walk: associative hatches list them in boundary-source
order with arbitrary direction, so the next edge may attach to either
end of the chain built so far — or to the far side of the loop.
Concatenating them verbatim drew self-crossing "bowtie" outlines that
flipped the even-odd fill over the wrong region (block-internal
pattern hatches, 26-edge associative loops). chain_path_edges keeps
the loop open at both ends and greedily attaches the unused edge whose
endpoint lies nearest either end, reversing or prepending as needed;
correctly-ordered files match at distance zero and reproduce exactly.
Co-Authored-By: Claude Fable 5 <[email protected]>
A content viewport that froze layers still drew those layers' hatches,
2-D solids, images, OLE frames and 3-D solids — only its wires honoured
`Viewport.frozen_layers`. A viewport meant to be empty (all content
layers frozen) leaked the whole model instead.
viewport_data_for fed content viewports the full model hatch/wipeout/
image/mesh sets with no per-viewport frozen filter. Thread the viewport's
frozen-layer set through the builders (synced_hatch_models,
exploded_insert_hatch_models, wipeout_models, image_models, mesh_models,
instanced_block_meshes) via Scene::layer_frozen_in — the same
layer-name→handle→membership test the wire path uses — and expose
*_for_viewport accessors that cache the filtered set per order-independent
frozen-set signature (viewports sharing a frozen set share the build; an
empty set returns the shared unfiltered arc, so model tiles and
non-freezing viewports are byte-identical).
Each viewport renders in its own pipeline slot with the hatch upload
gated on geometry_epoch, so a per-viewport filtered set uploads once per
epoch rather than every frame.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Extends the wide-polyline shader band to the two cases the first pass left on
the hatch pipeline.
Polyline2D:
- A flat wide Polyline2D now draws its band from the centre-line wire
(`world_width`), same as LwPolyline (`polyline_band_width` handles both).
- A thickened wide Polyline2D extrudes a solid 3-D tube. `thick_wide_band`'s
body moves to a shared `common::thick_band_tube(origin, fills, thickness,
normal, to_wcs)`; LwPolyline delegates to it and Polyline2D calls it from
its thickness path. Tube edges read black for both kinds.
- The model-space wide-band hatch loop is deleted (both kinds now wire/tube).
Blocks:
- Block-internal wide polylines draw the shader band too. `LocalWire` carries
`world_width`; `emit_wire` scales it by the insert transform (average of the
X/Y axis image lengths) so the band tracks a scaled insert, and the scaled
width joins the batch `style_key`. `push_block_wide_fills` (the block hatch
band) is removed.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
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]>
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]>
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]>
A gradient hatch was drawn linear from the entity colour to its second
stop, so a red->yellow spherical fill came out as a pale linear wash. Two
fixes:
- Colour: start from the gradient's first stop, not the entity colour.
hatch_model_from_dxf already baked stop-0 in; stop synced_hatch_models
from clobbering it with the entity's resolved colour for gradient fills.
- Shape: spherical / hemispherical / curved gradient names now render as a
radial fill (new shader mode 3, centred on the boundary with radius to the
farthest vertex) instead of linear. Native and web shaders both updated.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
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 <[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]>
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]>
Every geometry mutation previously bumped a monotonic geometry_epoch and each
derived cache compared its stamp and, on a miss, re-walked the whole document —
so drawing or moving one line rebuilt every hatch / image / mesh / wipeout model
even though none of them changed.
Add a per-mutation delta journal: bump_entities(&[(Handle, ChangeKind)]) records
exactly which handles changed (Added / Removed / Modified), folding the memo drop
and the epoch bump into one call a site cannot forget. A bounded ring keeps the
recent deltas; replay_since(last_epoch) coalesces the changes past a consumer or
returns None (full rebuild) when a spanned delta is `full` or the consumer fell
off the ring. bump_geometry / bump_geometry_no_blocks push a `full` delta so the
journal is never missing a step.
Route the common single-entity ops through it: add (Added), update (Modified),
erase (Removed), move/rotate/scale/mirror (Modified), copy (Added). Gate the four
per-category model caches on category relevance — an edit whose handles are not in
a category leaves that category cache warm; a removal is treated conservatively
(the erased handle can no longer be classified), and the mesh cache also
invalidates on any INSERT edit since it carries the instanced block meshes.
Net: a plain-entity edit no longer rebuilds the hatch/image/mesh/wipeout model
sets. The resident wire clone-all and the GPU re-upload are still whole-set — the
journal is the groundwork for making those per-entity next. Adds 6 journal unit
tests (replay, coalescing, full-fallback, ring overflow, category gate).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
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]>
WebGL2 has no vertex/fragment storage buffers, so the batched storage-buffer
hatch renderer (hatch_gpu) is disabled on wasm and real hatch fills never
rendered on the web build — the pick highlighted the boundary and the command
then appeared to cancel (issue #204).
Add a wasm-only per-hatch renderer (hatch_web_gpu + hatch_web.wgsl) that packs
the variable-length boundary / family / dash arrays into one RGBA32F data
texture read with textureLoad, instead of the fixed-size uniforms of the
wipeout path. This removes the MAX_FAMILIES (16) / MAX_HATCH_BOUNDARY_VERTS
(1024) / MAX_DASHES (128) caps, so every hatch — solid, gradient, and
arbitrarily complex line patterns — renders on the web. The fill algorithm
(in_polygon, per-family line/dash/dot, gradient, LOD, double-single vertex
transform) is identical to the desktop path; only the array ACCESS changes.
Pattern phase is anchored at world_origin with the boundary stored raw, and
scale / gradient-range clamped, to match the desktop batched renderer exactly.
Native (hatch_gpu + hatch.wgsl) and the wipeout mask renderer are untouched;
the new module and pipeline are gated on target_arch = "wasm32".
Also silence the resulting (and pre-existing) wasm-only dead-code warnings for
native-only paths so both the native and web builds compile clean.
Closes#204.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
When writing a hatched region with holes to DXF/DWG, every NaN-separated ring was created with BoundaryPath::external(), so consumers treated inner loops as outer islands instead of holes. Only the first (outer) ring should carry the external / outermost flags; hole rings are left unflagged. A picked big-minus-small hatch now persists with the small rectangle as a real hole.
Adds a regression test asserting only the first boundary path is flagged external.
The HATCH/GRADIENT/BOUNDARY "pick inside" mode returned the first outline containing the click point, so the result depended on draw order and any nested boundary was ignored. For a small rectangle inside a big one, clicking inside the small shape would hatch the whole big rectangle (or the small one, depending on which was drawn first), and clicking in the gap between them always hatched the full big rectangle with no hole.
Resolve the click against every enclosing outline and choose the smallest-area one as the fill. Any other outline fully contained by that ring but not containing the click point becomes a hole. The boundary is emitted as NaN-separated rings, which the GPU and CPU rasteriser already fill via even-odd, so pattern, solid, and gradient hatches all render the correct region (the small shape, or the big-minus-small ring) regardless of draw order. DXF export now writes one boundary path per ring so nested hatches persist with real holes.
Three commit helpers narrowed a typed/picked point back to f32 before it
reached the persisted entity, so the coordinate was quantized to the f32
grid (worse at large/UTM extents):
- Scene::create_block_from_entities took the base point as glam::Vec3, so
the block's local-space translation and the replacement INSERT's
insert point were f32. Param is now DVec3 (matching the sibling
define_block_from_owned_entities); the command_driver handler no longer
downcasts with as_vec3().
- The MTEXT and TEXT in-place editors stored `pos` as Vec3 and built the
committed insertion_point from it, quantizing a new label's position
and re-quantizing an existing one on edit. Both `pos` fields are now
DVec3.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The HATCH/GRADIENT/BOUNDARY commands built the persisted hatch through
the render-side boundary (f32 offsets from world_origin), and with a
zero world_origin that meant absolute f32 - so a typed boundary vertex
of 2000.8 was stored as 2000.8020, and at UTM scale the fill landed far
from its boundary.
HatchModel.boundary stays f32 (the GPU render contract). Added
HatchModel.boundary_wcs: Option<Arc<Vec<[f64;2]>>>, an exact absolute-WCS
boundary set only by the draw commands. Scene::add_hatch persists from
boundary_wcs when present (exact f64 -> DxfHatch), else falls back to the
f32 + world_origin reconstruction used by DXF-rebuilt hatches. manual_pts
is now Vec<DVec3>; make_hatch takes the f64 boundary, stores the exact
copy and derives the RTE render rep. The committed render model is
rebuilt from the DXF entity, so nothing renders the command model's f32
boundary. This also fixes command hatches mis-placing at large
coordinates (world_origin was [0,0]).
Co-Authored-By: Claude Opus 4.8 <[email protected]>