Wires the SDF glyph atlas into the render loop end-to-end for top-level
TEXT entities, gated by the OCS_TEXT_SDF env var so the default path is
unchanged. Verified rendering on a real drawing: glyph quads land exactly
over the existing stroke text (position, size, rotation), confirming the
atlas → shader → pipeline → screen path and the RTE / UV math.
- text::text_run_placement — extracted from to_truck (behaviour-preserved)
so the stroke and SDF-quad paths compute an identical run placement.
- sdf_atlas::text_atlas — process-wide GlyphAtlas (Mutex/OnceLock), shared
by the collector (bakes glyphs) and the GPU upload (reads texels).
- Scene::sdf_text_vertices — collects per-glyph TextVertex for the frame's
TEXT entities via layout_glyph_quads + push_glyph_vertices. Empty unless
OCS_TEXT_SDF is set.
- ViewportData.text_verts + Pipeline text_pipeline/atlas/vbuf, upload_text
(rebuilds the R8 atlas texture when it grows), and a text render pass
drawn over the wires.
Still additive over the stroke text; only top-level TEXT is wired (MTEXT,
attributes, and block-internal text are next), and the collector re-runs
per frame (vertex caching by geometry epoch is a follow-up).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Groundwork to render CAD text as per-glyph SDF quads (box-quad snap,
perf, no LOD) instead of stroke-polyline wires. CPU side fully tested;
GPU side compiles but is not yet wired into the render loop.
- scene::text::sdf_atlas — GlyphAtlas: shelf packer + R8 SDF tiles baked
per (font, char). Unified field so one shader draws both kinds: TrueType
= signed distance to the filled outline, LFF stroke = pen band. Uses only
crates already in the tree (ttf-parser/lyon via the existing Glyph).
- scene::text::glyph_quads — layout_glyph_quads: lays a run out as per-glyph
quads using the exact tessellate_text_run transform, so quads land where
the strokes would. Per-char advance (TTF shaping is a follow-up).
- shaders/text.wgsl — SDF fragment shader (smoothstep over fwidth), RTE
vertex path mirroring image.wgsl, per-vertex colour + draw-order bias.
- scene::pipeline::text_gpu — TextVertex + double-single vertex builder,
R8 atlas texture upload, bind group, and the image-cloned pipeline.
13 unit tests across the three CPU stages, all green. Modules carry
#![allow(dead_code)] until the render-loop integration lands.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Three defects found reviewing the just-merged PRs:
- PLOTWINDOW forced corner snap (#293) only ran in the on_tick preview
path, so the marker highlighted an endpoint but the click committed a
plain snap — with the global OSNAP master (or Endpoint mode) off the
corner landed at the raw cursor, defeating the feature. Apply the same
snap_forced_corners branch at the click-commit recompute.
- build_dxf_pattern (#292) wrote the pattern line's LOCAL step into the
world-frame HatchPatternLine.offset. The new prebaked reader
inverse-rotates offset assuming world frame, so app-created hatches
(HATCH command) collapsed their spacing by cos(angle) — ANSI31 at 45deg
rendered 2.245 instead of 3.175 on both viewport and PDF/plot export.
Rotate the local step into world here so it round-trips and the stored
offset is format-correct for other CAD apps.
- far_from_origin_pattern_hatch_still_fills placed its offset ALONG the
45deg lines (projects to k~0), so it never exercised the span-cap fix and
passed even with the old absolute-index clamp restored. Move the offset
perpendicular to the lines so |k| >> the cap. Add
app_created_hatch_roundtrips_catalog_spacing to guard the offset-frame
fix (verified: fails at 2.245 without it, passes at 3.175 with it).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Hatched content — especially fills nested inside a block INSERT, such as a
title-block logo — plotted incorrectly: missing, mis-spaced, or with phantom
bars. Five related fixes make the PDF/plot output match AutoCAD.
1. Export block-internal hatch fills.
The export collected only hatches owned directly by the layout block, so a
hatch nested in a block INSERT was dropped and printed as bare monochrome
outlines. The insert-explosion the viewport already does is extracted into a
shared `exploded_insert_hatch_models()` and called from both the viewport
(`synced_hatch_models`) and the export (`paper_canvas_hatches`), so a plot
draws block-internal hatches identically to the screen.
2. Honour the hatch's own stored pattern-line spacing.
`hatch_model_from_dxf` re-derived pattern spacing from the name-matched
catalog entry x pattern_scale, ignoring the resolved line geometry the DWG
stores on the hatch. When a drawing was authored against a different base
spacing (imperial 0.125 vs the catalog's metric 3.175 for ANSI31), lines
came out up to ~25x too coarse and a dense fill collapsed to a few stray
lines. When the hatch carries its own line geometry it is now used directly
(identity scale/angle); the catalog path remains the fallback.
3. Fill far-from-origin pattern hatches.
`pattern_segments` clamped the ABSOLUTE scan-line index to
+/-MAX_LINES_PER_FAMILY. A fine-spaced hatch far from the pattern origin has
large-magnitude indices at both ends but a small span, so the clamp inverted
the range and emitted nothing — silently dropping the fill. Cap the line
count (span) instead of the absolute index.
4. Skip TEXTBOX boundary paths.
TEXTBOX boundary paths (flag bit 3) are text bounding-boxes AutoCAD derives
for island detection; they are never drawn or filled. Treating one as a fill
boundary painted its rectangle solid — a phantom bar. It is now skipped when
building the fill boundary.
5. Print white/ACI-7 hatch fills black on paper.
Hatch fills arrive adapted to the dark screen background, so a white/ACI-7
fill would vanish white-on-white on the sheet. Mirror the wire pass:
near-white/near-yellow -> black, near-cyan -> dark blue, matching AutoCAD's
colour-7-on-white plotting. Wipeouts keep their paper-white mask.
Adds `tests/block_hatch_export.rs` covering: block-internal hatch reaches the
export set, stored-line spacing is honoured, far-from-origin fills are not
dropped, and TEXTBOX paths are not filled.
- Reword the two new explanatory comments to describe the behaviour on its
own instead of citing another product.
- window_to_sheet: drop the unused window_min parameter (the caller already
derives its coordinate offset from the returned min-corner position) and fix
the doc comment to match what the function returns.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
XLine/Ray tessellate as +/-1e6 display segments (entities/ray.rs), and
both fit_all outlier defenses miss them: the IQR reject passes because a
construction line through the drawing has its centroid at its base
point, inside the consensus cluster; and the per-point lim filter passes
because local_extent_max is computed once at document load and stays at
the 1e9 default for drawings created fresh in-app. The far endpoints
then poison the bounds and the view fits +/-1e6, shrinking real
geometry to a dot.
Exclude XLine/Ray wires from the extents up front (AutoCAD likewise
ignores infinite lines in ZOOM Extents), falling back to fitting their
base points when the drawing holds nothing else.
Regression tests verified to fail without the fix (camera distance
3,000,000) and pass with it.
Co-Authored-By: Claude Fable 5 <[email protected]>
A user-defined hatch (angle + spacing + optional cross) carries the
placeholder pattern name "_USER", which isn't in the predefined catalog,
so it fell to a fallback that forced every one to flat horizontal lines
at the wrong spacing — ignoring the angle, the spacing and the double
flag.
Build base line families (angle 0, plus 90 when `is_double`) with unit
perpendicular spacing and let the HatchModel's existing angle_offset
(= pattern_angle) and scale (= pattern_scale) rotate and space them, the
same way predefined .PAT patterns are applied — so the angle/scale is
applied once, not doubled. Verified against the sample (0°/30°/90°-cross/
315°-cross at spacing 5/10).
Closes#278
Co-Authored-By: Claude Opus 4.8 <[email protected]>
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.