The batched hatch pass evaluates its fill procedurally per fragment — a
point-in-polygon test over the whole boundary plus the pattern test, with a
`discard` that disables early-Z. When the view sits inside a hatch its quad
covers the screen, so panning / zooming re-runs that full-screen shader every
frame (~40 ms/frame on a hatch-heavy plan) even though the rest of the scene
is cheap — the wire pass and CPU prepare are negligible by comparison.
Suppress the hatch pass while the view is actively panning / zooming /
orbiting, detected from `camera_generation` plus a short settle timer
(`Scene::navigating_lod`), and fold that state into the render signature so
the moment the view settles it renders hatches once and the scene-render
cache holds that full-quality frame. A brief `is_settling` subscription keeps
requesting frames just past the settle point so the hatched frame renders
even when no input event would otherwise fire. Mirrors AutoCAD's adaptive
degradation. The paper sheet keeps its fills.
Every cursor movement over the viewport re-rendered the entire GPU scene
(all wires, hatches, meshes) even when nothing visible changed: iced redraws
the whole window on each input message, and the shader's draw/prepare/render
runs every time. On large drawings one such redraw exceeds the frame budget,
so moving the mouse or panning stutters — and it gets worse the bigger the
drawing.
The pipeline already resolves each frame into a persistent, clip-sized
resolve texture and blits that to the surface. This hashes the inputs that
determine the rendered image (camera uniforms, geometry/selection
generations, wire content id, render-mode flags, clip size, live preview
coordinates) into a per-viewport signature. When a frame's signature matches
the previous one the image is pixel-identical, so `prepare` skips the buffer
uploads + per-frame scissor/LOD recompute and `render` skips passes 1-7 plus
the MSAA resolve, re-blitting the resolve texture instead. A pure cursor move
becomes a single fullscreen blit rather than an O(N) re-rasterization.
The ViewCube is excluded from the signature (it renders in its own always-on
pass) so cube hover still tracks while the scene is cached. Any real change —
edit (geometry_epoch), select/hover (selection_generation), camera move or
render-mode toggle — differs the signature and forces a full render, so
nothing goes stale. Navigation still renders every frame (the view genuinely
changes); this targets the wasted re-render on cursor-only movement.
`orbit` revolved the target around the pivot as
`p + (delta * (self.target - p).as_vec3()).as_dvec3()`, casting the
target-to-pivot offset to f32. For a large model that offset is
extent-scale (the cursor can sit far from the view centre), so the f32
cast quantized it — nudging the whole view a fraction of a unit each orbit
step regardless of the drawing's origin. Do the revolve entirely in f64.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Every pan/rotate on a large drawing jerked once at the start of the
gesture. Instrumentation traced it to `paper_sheet_wires_arc` re-running a
full ~300 ms tessellation of the whole model block on the first hit-test
after each camera move.
`entity_wires_arc` (the pick/snap source in Model layout) delegated to the
camera_generation-keyed, view-culled `paper_sheet_wires_arc`. A camera
move bumps `camera_generation`, so the next hit-test cold-missed that cache
and re-tessellated every visible entity — O(visible), which is why the
jump grew with on-screen object count and shrank when zoomed in. The GPU
render was unaffected (it holds the separate camera-independent
`model_static_wires`), so only that one CPU frame stalled and the
accumulated mouse motion applied at once, reading as a jump.
Point the Model pick path at the same resident static set the render holds
(keyed on geometry_epoch only). Pick wants every entity, not a view-culled
subset, so this is also more correct — off-screen entities stay pickable —
and it never re-tessellates on a camera move.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The hatch-free-block skip added in the previous commit duplicated the
existing `block_has_hatch` helper (already used by
`insert_hatches_for_click`). Drop the parallel `block_contains_hatch` and
call the shared, memoised helper instead — same result, one implementation.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Opening an xref-heavy drawing froze for ~22s before the first frame.
`synced_hatch_models` walked every INSERT and called
`explode_from_document()` to scan the result for hatch fills — but explode
CLONES every child of the block, including 3D solids that each carry tens
of KB of SAB geometry. Clinic.dwg's five xref INSERTs each reference a
discipline block of thousands of solids and no hatch, so this materialized
~25000 solid clones (~1 GB) on every open just to find zero hatches.
Add a memoized `block_contains_hatch`: a read-only scan of the block's
entity types (recursing nested INSERTs, cycle-guarded) that skips the
explode for blocks containing no hatch, transitively. Blocks that do hold
a hatch still explode exactly as before, so the hatch set is unchanged.
Hatch build for Clinic.dwg drops from ~21.7s to ~0.02s, cutting the total
open-to-first-frame time from ~25s to ~5s.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Opening a drawing that xrefs several 3D-heavy DWGs (e.g. Clinic.dwg with
5 discipline files) took ~137s, effectively all of it inside the DWG
parser's AcDs SAB blob extraction.
Bump the acadrust pin to ceef045, which bounds the AcDs end-marker search
so a missing marker family no longer scans the whole buffer per record.
Total xref resolve for Clinic.dwg drops from ~97s to ~4s.
Also make the post-xref mesh pass incremental: the host solids were
already tessellated by the background loader and the merge assigns fresh
handles to every imported entity, so `populate_missing_meshes_from_document`
keeps the cached host meshes and tessellates only the merged xref solids
instead of clearing and rebuilding the whole document.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Mesh entities (PolyfaceMesh / PolygonMesh / SubD Mesh) could only be picked
on their thin edges — click_hit tested wire segments, and the solid face pick
only covers ACIS solids. When no edge is close, click_hit now also tests the
mesh's fill_tris with the same projected-triangle containment mesh_click_hit
uses for solids, so clicking a mesh's surface selects it, front-most first.
Edge proximity still takes priority.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
view_world_aabb built a world-XY cull rectangle from the camera target and
ortho size without checking the view angle, so in a tilted / 3D view it
produced a top-down box that does not bound the on-screen footprint. That
box culls the pick wire set (while the render holds the full set via the
static hold), so a mesh running partially off-screen fell outside the box
and became unselectable — by edge or by face — even though it still
rendered. Top views were unaffected (the box is correct there), and 3D
solids pick through their mesh AABBs (not this wire set) so they never
showed it.
Return None for tilted views (fwd.z.abs() < 0.999), matching view_cull_aabb,
so no world-XY cull is applied and the pick reaches every on-screen wire.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Mesh (PolyfaceMesh / PolygonMesh) outline edges are drawn through the wire
pipeline, whose shader pulls each wire toward the camera by
draw_depth * DRAW_ORDER_BIAS (0.001) for 2-D draw-order layering. Every
entity gets a draw_depth in [-1, 1], so a mesh's edges were pulled forward
too — and when the mesh's depth extent projects to less than the bias (a
small or distant mesh), its hidden back-face edges peeked through its own
shaded fill. Solid feature edges use the mesh shader, which has no such
bias, which is why only meshes showed it and why adjusting the fill's depth
bias had no effect.
3-D mesh outline edges are real geometry occluded by true depth, so force
their draw_depth to 0 (they are already grouped as `mesh_edge` in
upload_wires). They now occlude exactly like solid edges.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
An ACIS spline-surface stored as `nurbs` (rational) carries a weight on each
control point, so the control net is a stream of x, y, z, w tuples. The
parser read plain x, y, z triples, shifting every control point by one float
and scrambling the whole net — the surface, and the solid it bounds,
rendered badly deformed. Non-rational `nubs` surfaces were unaffected, which
is why only some solids (rational lofts/sweeps with varying weights) broke.
Detect `nurbs` vs `nubs`; for rational surfaces read the fourth weight
component and build a truck `NurbsSurface` from homogeneous (xw, yw, zw, w)
control points so the weights actually shape the surface. Both the truck
B-rep path and the bespoke fallback share this parser.
Verified on VillaSavoye_MartinHedin2.dwg solid 0x24A1EE (handle 2400750): the
tessellated surface now stays within the solid's true extent instead of
flying outside it.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Extends the solid feature-edge behaviour to mesh entities (PolyfaceMesh /
PolygonMesh): their triangulation outline edges now render black when a
filled-with-edges mode is active, and keep the entity colour in the
lines-only modes (wireframe / hidden-line).
A mesh entity emits its face fill and its outline edges as separate
WireModels sharing the entity handle: the fill carries fill_tris plus a
non-empty fill_tris_low (real 3D depth), the edge carries points. The old
is_3d_mesh_edge flag keyed on `!fill_tris.is_empty()`, which tagged the
points-less fill wire rather than the edge wire, so the edges were never
recognised. upload_wires now collects the handles of real 3D fill wires and
flags any points-wire sharing one as the mesh edge (text greek, whose fill
has an empty fill_tris_low, is excluded — matching the face3d test). The
wire draw loop then routes flagged edges through a new black wire pipeline
(fs_black) in filled-with-edges modes.
fs_black lives in wire_indexed.wgsl (the native shader) and wire.wgsl (the
wasm shader).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Solid wireframes rendered every triangle edge of the tessellation, so a
curved solid read as a dense mesh instead of its real outline. Now each ACIS
solid's B-rep face-boundary edges are collected from the SAT `edge` records
(straight edges as segments, ellipse/circle edges sampled along their bounded
arc), body-transformed and split into the double-single pair, and stored on
the MeshLodSet. The block-instance and top-level offset paths transform these
edges alongside the mesh vertices.
The batch builder routes a solid with feature edges into a dedicated LineList
vertex buffer and skips its triangle wireframe; a mesh with no B-rep edges
keeps its triangulation edges. Edge colour follows the display mode: the flat
`fs_edge` shader draws the entity colour in the lines-only modes (wireframe,
hidden-line) and a `fs_edge_black` twin forces black for the edge overlay in
filled render modes.
Verified: the Clinic storefront arch bars now yield ~14-16 clean edge
segments (vs the full triangulation) bounded to the solid's true extent.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Once solids carried a real sub-1 alpha (after the ENC transparency fix),
the single depth-writing mesh pass made a transparent solid occlude the
geometry behind it: it wrote depth at its near surface, so anything farther
failed the LessEqual test and was never drawn — the transparent fill looked
like it erased what was behind it.
The batched mesh builder now routes each solid's triangles into an opaque or
a transparent index stream by its baked colour alpha (< 0.999). The opaque
fills draw first and write depth; the transparent fills draw last through a
new depth-write-disabled pipeline, so they blend over the geometry behind
them instead of culling it. The hidden-line depth prepass still rasterises
both streams so solid surfaces keep occluding hidden edges.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Curved 3D-solid faces backed by an ACIS cone-surface (e.g. the arched
mullion bars of a storefront curtain wall) were swept a full revolution
regardless of the face's actual angular extent, so a bar bounded to an
8-16 degree arc rendered as a full circle of the surface radius — a large
jagged loop instead of a small arch.
cone_faces (the truck B-rep path) now derives the angular span from the
face's boundary loop and rsweeps only that arc; the bespoke fallback
tess_cone_face no longer forces a full revolution when a thin face
collapses to a single height. Arc extraction uses a seam-robust
largest-gap method so faces straddling +/-pi (the crown of an arch) are
not mistaken for full circles.
Verified against Clinic_Architectural.dwg: the seven arch-bar solids drop
from a ~12.9 (2R) bbox to their true ~1-2 unit extent, and a file-wide
check of 155 cone-bearing solids shows none ballooned and none collapsed.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The paper layout sheet pass reused the full model mesh set, so every
top-level 3D solid piled onto the paper origin — the sheet camera works
in paper coordinates, not model space. Guard `meshes` on
`inst.paper_sheet` the same way wires/hatches/images already are: the
sheet shows only paper-native content; the model's solids render inside
the floating content viewports (model camera + per-viewport scissor).
Also name 3DSOLID/REGION/BODY/SURFACE/MESH/SOLID/MULTILEADER in dxf_name
(they fell through to "ENTITY"), and add a SELHANDLES diagnostic that
reports the active space plus a per-type / per-block selection breakdown.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Thickness (DXF 39) is a General-group property in the palette, but it is
stored per-entity, so it was previously shown (inconsistently) only in the
Geometry section of a few entities. Surface it in General for every entity
type that carries an extrusion thickness (arc, circle, line, lwpolyline,
point, polyface mesh, 2D polyline, shape, 2D solid, text) via
entity_thickness / set_entity_thickness, routed through apply_common_prop.
Removes the now-duplicate Thickness rows (and dead apply arms) from the
arc/circle/shape/solid Geometry sections so it is never shown twice.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The Layer Manager already applied colour/linetype/lineweight in bulk;
extend that to the rest:
- Visibility, lock and freeze toggles now act on every selected layer, not
just the clicked row. The clicked row's new state becomes the target for
all. Transparency edits apply to the whole selection too.
- Clicking a linetype/lineweight combo on a row that is already part of a
multi-selection no longer collapses the selection (the whole row is one
mouse_area whose plain-click select would otherwise reset it), so those
edits stay bulk.
- Ctrl/Cmd+A selects every layer row while the Layer Manager is open, and
all drawing objects otherwise.
Two now-unused Scene layer-toggle helpers are removed (the handlers set the
flags directly and bump once).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
OCS models 3-D solids with truck, but extrude/boolean results carried no ACIS
body, so other CAD apps dropped them on open. Add an exact planar exporter and
run it at save time.
- scene::convert::acis_export::planar_solid_to_sat walks a truck Solid whose
faces are all planes bounded by straight edges into a vertex/face-ring mesh
and builds an exact ACIS body via acadrust's build_planar_body. Curved
faces/edges return None (left for the NURBS path). A signed-volume check
flips the winding if the shell comes out inside-out.
- OpenCADStudio::sync_truck_solids_to_acis fills in ACIS geometry for every
cached truck solid that still has none, just before a Save / Save-As, so the
written DWG/DXF carries real modeler geometry.
- Bump acadrust to 9142a50 (build_planar_body + classic-ACIS AcDs roundtrip fix).
Booleans (already cached as truck solids) now export exact geometry. Extruded
polygons will too once the EXTRUDE command caches its truck solid (a separate
change kept out of this commit because command_driver.rs holds unrelated
in-progress edits).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Root cause: the glyph tessellator advanced the pen by advance*width_factor
while the transform already scales the pen position by the width factor, so
a backward (negative width factor) run kept its word order and grew on the
same side of the anchor instead of mirroring across it. Advance in unscaled
glyph units to match text_local_bounds; the transform applies the factor once.
On top of that:
- The renderer honours the entity's own text-generation flags (DXF 71: bit 2
backward / bit 4 upside-down) XOR the text style's flags, and signs the
horizontal anchor by the width factor so Center/Right backward text mirrors
about its anchor.
- MIRRTEXT on: a MIRROR toggles both group-71 bits so the reflected rotation
renders a true glyph mirror (backward-upright for a vertical axis, flipped
for a horizontal one).
- MIRRTEXT off: the text flips left<->right and lands mirror-symmetric to the
source (right-reading) instead of hugging the axis.
- The MIRROR preview re-derives text ghosts to match the commit; mirrored()
now reflects the double-single residual too.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
transform_entities kept text/mtext/shape right-reading when MIRRTEXT is
off, but the default MIRROR-with-copy path did not, so copied text mirrored
backwards. Extract the capture/restore into shared helpers and apply them
in copy_entities too. (#243)
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Fill the two placeholder rows added earlier. Read the General group's
Hyperlink from the entity's XDATA (the "PE_URL" application record's first
string). When an entity carries an explicit material (material_flags == 3),
resolve its material_handle to the Material object's name and show it in the
3D Visualization group instead of "Custom".
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Per the COMMON section of PROPERTIES.md. Insert a "3D Visualization" group
(Material, Shadow display) after General for every graphical object, and add
Plot style + Hyperlink rows to General. Material / plot style / shadow are
sourced from the entity's flags (ByLayer / ByBlock / custom); resolving a
custom material or plot-style handle to its name, and reading the hyperlink
from XDATA, are follow-ups that need document access.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Groundwork for making the Properties panel conform to the per-entity group/row
spec (PROPERTIES.md). The panel already renders a Vec of sections, but each
entity could only express a single geometry section.
Change PropertyEditable::geometry_properties (and the EntityTypeOps enum
dispatch) to return Vec<PropSection>, update the two impl macros, and flatten
in properties_sectioned so the panel shows [General] + the entity's groups.
Every entity builder is wrapped to return its existing section as a one-element
Vec — pure mechanical change, no rows or titles altered, panel output identical.
Enables Phase 2: rewriting each entity's groups/rows to match the spec.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
An entity added via a plugin's AddEntity with a common.layer name that no
LAYER command ever created kept its layer in-session, but the DWG writer
resolves an unknown layer name to a NULL handle, so on reopen the entity
collapsed to layer 0.
Add a shared Scene::ensure_layer(name) that registers a missing layer with a
real handle (allocate_handle, per #67) and call it from add_entity and
update_entity. This covers the plugin AddEntity/UpdateEntity IPC and every
internal add path without a new IPC request. Existing/empty/"0" layers are
no-ops, so normal draw commands are unaffected.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
#249 — plugin XDATA written via write_record survived only in memory: the
acadrust DWG writer dropped ExtendedData::records on save. Bump acadrust to
e88a9a6 (records now encode to EED and decode back on read) and fix the host
side that fed it:
- ensure_app_id allocates a real APPID handle; a null handle serializes as
0 and the EED reference can't resolve, so the XDATA vanished on reopen.
- write_record / remove_record drop stale raw_dwg_eed for the target app so
an edit made after a save/reopen wins over the pre-edit bytes.
#250 — out-of-process plugins got a throwaway document_mut() snapshot, so
edits to existing entities were silently discarded and deletion wasn't
expressible at all. Add the missing mutation surface:
- UpdateEntity / RemoveEntity IPC requests + HostApi::update_entity /
remove_entity (default in-process impls, RPC overrides on the client that
invalidate the stale document cache).
- Scene::update_entity replaces the entity in place, preserving its handle
and owning block, and reseeds only its derived caches; remove reuses the
cache-coherent erase_entities (which also honours layer locks).
- document_mut() is documented as a local-only snapshot out-of-process.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The batch split at a fixed 6M-vertex cap that assumed 40 B/vertex, but
MeshVertex grew to 52 B when the double-single `position_low` field was
added — so a full chunk was 6M x 52 = 312 MB, past wgpu's 256 MB
max_buffer_size, and Device::create_buffer panicked on large models.
Derive the caps from device.limits().max_buffer_size and the real vertex
size, bound both the vertex buffer and the (fatter) wire-index buffer,
and split any single mesh too large for one chunk into triangle-soup
sub-chunks so no batch buffer can exceed the device limit.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The orthographic near/far span was ±1000x the camera distance, so
zooming out ballooned the depth range and collapsed z-buffer precision:
coincident solids, meshes and wires then flipped draw order (meshes drew
in front of solids only when zoomed out, correct order when zoomed in).
Size the depth half-range from the drawing's extent instead — the bbox
diagonal on fit_to_bounds, the view height on a restored saved view —
and hold it constant as the camera zooms, so precision no longer depends
on distance. Falls back to the old distance-scaled range when unset.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
PolyfaceMesh / PolygonMesh / SubDMesh fills and 3DFACE quads were
classified as 2D fills and given the draw-order z-bias, so they were
pulled toward the camera and drew over solids. The tessellator emits a
mesh's edges and its fill as separate WireModels (points-only vs
fill-only), so the old `!points.is_empty()` discriminator misrouted the
fill. Classify a fill as a 3D surface by its double-single low residual
(`fill_tris_low`) instead, and drop the draw-order bias from 3DFACE
quads. 2D annotation fills (text greek, MultiLeader / dimension
backgrounds) leave `fill_tris_low` empty and keep ordering by rank.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Rework 3D view navigation:
- Turntable / lock-horizon orbit: horizontal drag yaws about world +Z,
vertical drag pitches about the horizontal camera-right axis, so the
view never banks. Pitch is pole-clamped but the straight-down default
view can still be tilted out of.
- Orbit centre: the selection's centre when something is selected,
otherwise the point under the cursor — no longer only the file centre.
The camera revolves rigidly about the pivot, so it stays put on screen
and the view doesn't jump.
- Mouse scheme: Zoom = wheel, Pan = MMB, Rotate = Shift+MMB. Right-drag
no longer orbits (right-click stays context menu / Enter). Floating
viewports orbit with Shift+MMB too.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The window/crossing selection anchor was stored only in screen pixels, so
zooming or panning mid-drag left it frozen while the drawing moved under
it — the rectangle then covered the wrong world area and selected too much
or too little.
Store the anchor's world point and re-project it to the screen anchor
whenever the model-space view zooms (on_viewport_scroll) or pans
(on_viewport_move), so the rectangle tracks the drawing. The moving corner
already follows the cursor. (MSPACE floating-viewport box-select would
need the viewport camera and is left as-is.)
Co-Authored-By: Claude Opus 4.8 <[email protected]>
- Double-clicking a locked object no longer opens its text / attribute /
in-place block editor.
- Clicking a locked object during a command's object-gather no longer
counts as completing the selection (don't set selection_just_completed
on the locked path).
- copy_entities skips locked entities.
- The attribute editor's Apply refuses to write when the block's layer was
locked while the dialog was open.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Locked-layer objects were fully editable. Now they stay visible and
snappable but are otherwise protected:
- Not selectable: the five interactive pick paths (box, lasso, single
click, cycling, box-release) plus SELECTALL and QSELECT skip locked
entities.
- Not modifiable (defense-in-depth even if a handle slips through):
transform_entities, erase_entities, apply_grip and the Properties
commit all skip locked entities.
- Faded: entities on a locked layer render dimmed toward the background
(render_style + wire tessellation); toggling lock re-tessellates.
- Snap still works: locked entities are intentionally kept in the wire
set (visibility_ok untouched), so object snap keeps finding them.
- Feedback: a small padlock is drawn by the crosshair when hovering a
locked object, and clicking one prints "Object is on locked layer …".
New Scene::is_layer_locked / locked_layer_name helpers centralise the
check (mirroring layer_hidden).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Selecting an INSERT with attributes now adds an editable "Attributes"
section to the Properties panel — each tag is a row whose value can be
changed inline (Enter commits, undo works, the attribute text repaints).
Previously attribute values could only be reached through ATTEDIT, which
didn't let them be changed (#192).
Attribute tags are runtime strings, so they can't ride the geometry
edit path whose field key is `&'static str`; a dedicated PropValue::
AttrText variant plus PropAttrInput/PropAttrCommit messages carry the
tag. Values are stored verbatim (no expression evaluation, since
attribute text is free-form).
Also guard the panel's in-progress edit buffer: it now only carries
across a rebuild when the selection is unchanged (a commit-triggered
refresh), so a typed-but-uncommitted value can't display or commit onto
a different entity — e.g. two title blocks sharing a REV1 tag. This
closes a latent stale-buffer leak that affected geometry fields too.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Layer color/linetype/lineweight and MATCHPROP edits set the
document dirty flag but never bumped geometry_epoch. The wire
caches key on that epoch and bake a ByLayer entity's resolved
color at tessellation time, so they returned stale wires until
a new entity forced a rebuild. Bump geometry on those four
handlers so the edit repaints immediately.
Also remove REGEN/REGENALL/REDRAW/REDRWALL: the GPU raster
pipeline keeps the display continuously in sync, so on-demand
regen/redraw is a no-op concept in OCS.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Opening / navigating a heavy 3D DWG (e.g. a BIM model with ~10k ACIS solids)
was effectively frozen. Several independent costs, all addressed here:
- Face3D edge/fill buffers were re-uploaded on every camera move (keyed on
camera_generation). They are world-space and selection-independent, so gate
them on (geometry_epoch, fill_mode) instead — a pan no longer re-walks every
wire.
- Solid meshes were drawn one buffer + one draw call per solid (~10k draws/
frame), which strangled the GPU front end. Concatenate every solid's LOD0
into a few large buffers (split only to stay under the 256 MB buffer cap) and
draw the whole set in a handful of calls. Built once per geometry epoch, so
selection / hover never re-pack it.
- Selection / hover highlight is now a small tinted overlay of just the picked
solids, drawn last with an Always-depth pipeline so it shows on top even when
the solid is occluded — instead of re-tinting and re-uploading all meshes.
- Hover / pick hit-testing scanned every triangle of every solid each move
(seconds on a heavy model). Add a 3D-AABB screen-rect broad phase
(MeshLodSet now carries z_aabb) and reuse the renderer's cached expanded mesh
set, so picking is O(solids) cheap projections plus the few solids actually
under the cursor.
- Block-internal hatch hit-testing exploded every INSERT on every hover.
Cache the result per geometry epoch and skip blocks that contain no hatch.
- Curved-surface (cone/sphere/torus) tessellation tolerance is now radius-
relative (CURVE_REL_TOL) so a cylinder's facet count is size-independent and
matches the circle/arc wire tessellation, instead of exploding on large radii.
Also adds docs/tessellation.md mapping every EntityType to its tessellation
path (truck B-rep vs truck curve topology vs direct).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Bare BACKGROUND now opens an interactive colour prompt (via
ValuePromptCommand, same idiom as PDMODE/LTSCALE) instead of just
printing usage, so the command works both step-by-step and as a
one-shot `BACKGROUND <colour>`. Options reordered to
Default/Black/DarkGray/Gray/LightGray/White; DEFAULT restores the app
default, now unified to rgb(33,40,48) across startup, load and reset.
Also drop the unreachable OPTIONS arm in dispatch_display.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Entities inside a block definition that sit on layer "0" must take on the
properties of the layer the block reference is inserted on; every other
layer is "sticky" and keeps its own identity. OpenCADStudio resolved a
block child's ByLayer color/linetype/lineweight against the child's own
layer in all cases, so layer-0 content stayed layer-0 (white) instead of
inheriting the instance layer.
Resolution now follows the standard rule for block children — explicit
wins, then ByBlock → the insert's own style, then layer-0 + ByLayer →
the insert's *layer* style, else the child's own layer. The insert's
layer style threads through the whole block-expansion chain (and chains
correctly through nested inserts), with the child's own transparency
preserved.
Covered across every block-render path:
- wires (line/arc/circle/polyline/text) via the block cache + the
explode fallback + insert attributes
- hatch / solid fills (also fixes the PDF/print export path)
- 3D solid block children (per-instance mesh recolor)
- Dimension / Table baked-block sub-entities
New shared helpers in scene::view::render: InheritStyle, layer_render_style,
lineweight_to_px; render_style_for_block_sub gained the layer-0 branch.
Adds unit tests covering the layer-0, sticky-layer, ByBlock, explicit and
transparency cases.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
- expand_defn: dedup the <1px baseline LOD branch the same way the <5px
greek branch does, so colour-split MTEXT no longer stacks N overlapping
baselines (one per \C segment) over the same OBB.
- sysfont: memoise canonical_family_name process-wide. resolve_font calls
it once per word on the MTEXT measure hot path and Face::resolve re-runs
it right after; the fontdb query + linear family scans were uncached.
- dimension/multileader/table: reword the 6 fill_tris_low FIXMEs. They
cited a stale line and the wrong consumer — these fills render on the
top-level path (panic-safe .get().unwrap_or), not the block cache, so
they cannot trip emit_wire's debug_assert. State the real status:
latent f32-precision debt, not a crash. Also fix the mis-indented
text-fill WireModel in dimension.rs.
- tests: add a host-independent colour-split test (two \C segments in a
block keep ≥2 distinct wire colours — guards the per-wire colour fix)
and a TTF-gated UTM fill test (paired, non-zero fill_tris_low). Note the
arial-style test's LFF-fallback limitation.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
emit_wire reconstructed fill verts via a raw `fill_tris_low[idx]` index,
guarded only by a debug_assert. Slice bounds checks are not gated by
debug-assertions, so a Leader with a filled arrowhead nested in a block
(populated fill_tris, empty fill_tris_low via the low-level tessellate
early-return) panicked in release as well as debug.
Use the panic-safe `.get(idx).copied().unwrap_or([0.0; 3])` — the same
"empty = all-zero low half" contract the other fill consumers
(face3d_gpu, xclip) already honour. The debug_assert stays as a tripwire.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Colour-split MTEXT produces one outline wire per inline \C/\c override. At <5px zoom each outline independently emitted the full OBB greek rectangle — N overlapping boxes with last-colour-wins. Track last_lod_obb and skip duplicate substitutes.
Also corrects the fill-only LOD bypass: suppress fill-only wires at <5px instead of sending them unconditionally through emit_wire. The outline sibling's baseline or greek box suffices at sub-pixel zoom.
Three related bugs in tessellate_sub_local and expand_defn:
1. Wire folding lost per-segment colour. Multi-wire tessellator output
was concatenated (NaN-separated) into a single LocalWire using the
first wire's colour. Inline \C/\c MTEXT colour overrides inside block
definitions rendered in the entity colour instead of the override colour.
Fix: tessellate_sub_local returns Vec<LocalWire>; each wire from the
tessellator becomes its own LocalWire with its own colour.
2. fill_tris_low was never forwarded. The double-single precision path
was silently degraded on cached wires. Fix: fill_tris_low forwarded
from WireModel through to LocalWire and BatchEntry. debug_assert_eq!
added at emit site to catch future invariant violations.
3. Fill-only wires hit the text LOD ladder. MTEXT outline/fill pairs
produce separate LocalWires; the fill-only wire was routed through
emit_text_baseline/emit_greeked_text which ignore fill_tris (invisible
double-draw). Fix: is_fill_only field on LocalWire bypasses the LOD
ladder. Also fixes StyleKey::fill_only being hardcoded false at both
emit_wire call sites (latent batch misclassification).
OCS wrote an app-specific "OpenCADStudio_Camera_Model" entry to the VIEW
table and preferred it when restoring the model view. That record polluted
the file for other CAD programs and its DXF round-trip was broken, so a
drawing saved in TOP reopened from DXF at an isometric-ish angle.
The model view now persists and restores entirely via the standard *Active
VPORT entry (view direction / target / twist), which round-trips natively in
both DWG and DXF. The custom View record is no longer written; existing ones
in older files are left untouched. Paper space already used the standard
sheet viewport and keeps a read-only fallback for very old files.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Replaces the opt-in `rmb_enter` setting (and `RMBENTER` command) with
always-on behavior. While a command is active, the first viewport
right-click acts as Enter (commit / close) and a second consecutive
right-click opens the context menu instead. Idle right-click still opens
the menu; right-drag still orbits.
The cycle is tracked per tab (`right_click_entered`) and restarts on any
other interaction — a left-click pick, a new command, an orbit, or
leaving the viewport — so the next right-click is Enter again.
Refines #185.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Adds a configurable right-mouse-button behavior without disturbing orbit
or the context menu.
- New persisted setting `rmb_enter` (command `RMBENTER [ON|OFF]`, default
off): when on, a right-click while a command is active acts as Enter
(commit/close); when idle it still opens the context menu. Right-drag
always orbits, so nothing is lost.
- Right-click vs orbit is now time-based: orbit engages only after the
button is held >=150ms and the pointer moves, so a quick right-click is
no longer swallowed by slight pointer jitter (was an 8px distance gate).
- The right-click Repeat menu now lists commands from every source
(command line, ribbon, context menu, shortcuts) via a unified
recents list recorded at the dispatch choke point, instead of only
typed commands.
Closes#185
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Replace the custom model_tiles tiling with iced 0.14 pane_grid as the layout
source of truth, while paper-space floating viewports are left untouched (they
overlap/free-place, which pane_grid cannot model).
Each Model pane is its own shader widget rendering its camera into the pane
bounds; the renderer Primitive carries a base_slot so several shader widgets
share the type-keyed MultiPipeline storage on disjoint slots. A second pane_grid
of transparent mouse_areas (plus on_resize) layers above the crosshair overlay
to receive input — the overlay's Hidden cursor interaction otherwise levitates
the cursor and starves any layer beneath it. The active pane follows the cursor
(hover-activate), and grid / crosshair / UCS icon render per pane while the
ViewCube and render-mode picker stay on the active pane only.
Split / close / VPORTS presets and the VPort table round-trip now drive
pane_grid (guillotine reconstruction on load). Adds drag-to-swap via a controls
bar handle with a ghost-card preview, visible pane dividers from the pane_grid
split regions, a default model background of RGB(33,40,48), and removes all the
superseded custom-tile code.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Reduce the ViewCube 30% (VIEWCUBE_PX 120→84) and tighten the nav region
(NAV_INSET_F 2.2→2.0) so the home / roll / nudge controls hug the cube in the
corner instead of floating in a large dead area, with sizes scaled to keep them
from overlapping. Bump the home and roll buttons back up slightly, shift the
UCS picker down below the region, and give it a fixed width so it centres
exactly under the cube centre.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
The UCS icon now anchors to the projected UCS origin when it is on-screen
(falling back to the bottom-left corner), and becomes a selectable object:
hover highlights it, a click shows draggable grips, and dragging the origin
grip relocates the UCS while the X/Y tip grips rotate it. Drags snap to
geometry/grid through the existing snapper, and the new UCS is persisted on
release. The icon stays selectable while parked in the corner.
Also make the ViewCube compass (N/E/S/W) world-fixed: the cube body still
reorients with the active UCS, but the cardinals project through a camera-only
rotation and snap in world frame, so the directions no longer spin with the UCS.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Two names collided with the pick subsystem and misdescribed their files:
- scene/hittest.rs -> scene/camera_ops.rs. Its contents are camera/view
operations (zoom, fit-all, named-view restore, camera<->document sync,
per-tile grid/snap), not hit-testing — the real screen-space hit-test
lives in scene/pick/hit_test.rs.
- scene/pick/selection.rs -> scene/pick/selection_state.rs. It holds only
the SelectionState struct; the name clashed with scene/selection.rs
(the Scene selection operations).
Callers are unaffected (the crate::scene::SelectionState re-export path is
unchanged). No behavior change.
Co-Authored-By: Claude Opus 4.8 <[email protected]>