Commit Graph
345 Commits
Author SHA1 Message Date
Hakan SevenandClaude Opus 4.8 02b0fc4dcf chore: drop brand names and orphaned PICK_HIGHLIGHT colours
Neutralise vendor references in comments to plain behavioural descriptions
(object acquisition / object snap). Remove the PICK_HIGHLIGHT and
PICK_HIGHLIGHT_DIM wire colours: their only consumer was the stripped
storm-sewer add-on, and the retained plugin acquisition pathway draws the
ObjectPick snap glyph and per-command hover previews instead, so the
constants are dead.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-18 21:28:07 +03:00
Hakan SevenandClaude Opus 4.8 5a36494ed7 chore: clear compiler warnings
Remove accidental dead code (unused imports, the superseded paste tool()
helper, a never-read TORIENT handles field, a duplicate let binding, an
unneeded mut) and mark intentional-but-unwired scaffolding with
allow(dead_code): the web-only save_to_bytes, the plugin loadable() probe,
the planned PICK_HIGHLIGHT colours, the DynFieldSpec label, and the layout
ribbon helpers. Native build is now warning-clean apart from the upstream
nom/quick-xml future-incompat notices.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-18 21:18:09 +03:00
Hakan SevenandClaude Opus 4.8 47f74a9b98 feat(clipboard): paste menu with paste-to-original and paste-as-block
Turn the ribbon Clipboard "Paste" button into a dropdown:
- Paste — existing pick-a-point paste.
- Paste to Original Coordinates — drops the clipboard at its source
  coordinates with no prompt.
- Paste as Block — wraps the clipboard contents in a new block definition
  and starts an interactive insert (prompts for the drop point, with the
  geometry rubber-banding under the cursor).

Also fixes two bugs the new commands exposed:
- entities_centroid summed the NaN separator points wire models carry,
  so the paste base point came out NaN; non-finite points are now
  skipped. A NaN base previously froze block tessellation outright.
- PASTEORIG added clipboard entities with their original handles
  (duplicates on same-document paste); it now goes through
  add_entity_clone and merges referenced records like the other pastes.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-18 16:09:47 +03:00
Hakan SevenandClaude Opus 4.8 446430d2b3 fix: regenerate inline sub-entity handles when copying entities
`document.add_entity` only assigns the top-level handle, leaving inline
sub-entities that carry their own — INSERT attributes and 3D-polyline
vertices — with the source's handles. Copying such an entity therefore
produced duplicate handles in the document, which corrupts the saved DWG
so other CAD apps (QCAD, DWG TrueView) refuse to open it.

Add `Scene::add_entity_clone` / `reset_clone_subhandles`, which allocate
fresh handles for the entity and every inline sub-entity, and route all
duplication paths through it: COPY/ARRAY/MIRROR-copy (via copy_entities)
and clipboard paste. (#129)

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-18 14:45:06 +03:00
Hakan SevenandClaude Opus 4.8 96ec672d5b feat(points): point style commands + DDPTYPE dialog, viewport-aware size
Points could only render as a 1px dot — no way to change PDMODE/PDSIZE.

- PDMODE / PDSIZE commands set the header value and rebuild the glyphs.
- DDPTYPE opens a Point Style dialog: a 5x4 grid of rendered glyphs
  (shape x enclosure), a size field, and relative/absolute radios.
- Relative (<=0) PDSIZE now sizes from the current world-per-pixel so the
  marker stays a roughly constant on-screen size across zoom, rebuilding
  on zoom via the existing camera-generation cache key. Absolute (>0)
  scales with the world as before. Switching to absolute with a zero size
  seeds a positive value from the on-screen size so it stays representable.

Part of the #100 extensibility epic (point style surfaced in #106).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-17 11:13:12 +03:00
Hakan SevenandClaude Opus 4.8 6996343e84 feat(wasm): open files in the browser
Wire up file open for the web build:

- `io::pick_and_load_web`: browser file picker → read bytes → parse via
  `load_bytes` → build caches, returning the same payload as the native
  loader so it feeds the shared `Message::FileOpened` handler.
- `Message::OpenFile` cfg-dispatches: native picks a path then loads on a
  worker thread; web picks + parses in one async step. The web branch also
  sets `opening` (the handler drops results when no open is in progress).
- Disable the cosmic-text shaping / fallback paths on wasm: the web has no
  system fonts, so cosmic-text panics with "no default font found". LFF
  stroke fonts (embedded) still render; LFF-missing glyphs are skipped.

Native build + 75 tests unchanged. Hatches still don't draw on the web.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-16 18:08:57 +03:00
Hakan SevenandClaude Opus 4.8 c49ad887b8 fix(wasm): make the web build actually run in the browser
With the wasm bundle building, get it rendering in a browser:

- Enable iced `webgl` so wgpu uses WebGL2 where WebGPU is absent
  (e.g. Firefox by default), and `fira-sans` so UI text has an
  embedded font (the web has no system fonts) — without it every
  label was blank.
- Replace `std::time::Instant` with `iced::time::Instant` (web-safe);
  `Instant::now()` panics ("time not implemented") on wasm and crashed
  at boot via the command line.
- Skip the batched-hatch pipeline on wasm: its bind group needs
  vertex-stage storage buffers, which WebGL2 lacks (validation error
  on "New drawing"). Fields become Option; upload/draw guard on them.
  Hatches don't draw on the web for now; everything else does.
- Fix the trunk feature flag (`data-cargo-no-default-features`) and
  ignore the `dist/` build output.

Native build + 75 tests unchanged.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-16 17:53:56 +03:00
Hakan SevenandClaude Opus 4.8 9ca19a9db8 feat(wasm): sequential rayon shim for the web build
wasm32 has no threads, so rayon's global pool would panic at runtime.
Add `crate::par::prelude`: native re-exports `rayon::prelude`, wasm
provides sequential `std`-iterator equivalents (par_iter / into_par_iter
/ flat_map_iter over slices, Vec, HashMap, HashSet). All call sites now
`use crate::par::prelude::*;` instead of rayon directly.

- wire_gpu's rayon `reduce` (2-arg, no std equivalent) is cfg-split to
  a sequential flat_map concat on wasm.
- rayon moves to a non-wasm target dependency.

Native still runs in parallel; all 75 tests pass. wasm bin compiles.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-16 17:11:20 +03:00
Hakan SevenandClaude Opus 4.8 b2e20c95fe feat(wasm): compile the library for wasm32 (issue #45 groundwork)
First step toward a web build: the library now compiles for
wasm32-unknown-unknown with --no-default-features.

- Add a `solid3d` feature (default on) gating truck-meshalgo /
  truck-shapeops / lzma-sys. These pull vtkio → xz2 → lzma-sys (C),
  which cannot cross-compile to wasm. Without the feature the 3-D
  solid path (tessellation, booleans, ACIS import) becomes no-ops;
  2-D CAD is unaffected. Native builds keep the feature by default.
- Move native-only crates (ureq, open) to non-wasm target deps and
  add cfg shims: update-check is a no-op on wasm, and a new `sys`
  module provides open_url + handle_path (rfd FileHandle → PathBuf;
  synthesizes a name-based path on the web).

Native build + all 75 tests unchanged. Browser runtime wiring
(entry point, trunk, real file I/O) is a follow-up.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-16 13:01:19 +03:00
Hakan SevenandClaude Opus 4.8 3666c7fa69 refactor(modules): rename home module to draw, restore first-tab order
The Home tab was already titled "Draw"; rename the module to match:
folder modules/home → modules/draw, HomeModule → DrawModule, id "home"
→ "draw". The inner draw/ command folder is kept (modules::draw::draw).

Update build.rs PRIORITY ("home" → "draw") so the tab returns to the
front of the ribbon; registry.rs regenerates accordingly.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-16 11:49:14 +03:00
Hakan SevenandClaude Opus 4.8 c6152ef705 refactor(scene): rename model_solid to solid_model
Match the `*_model` naming of the sibling render models (wire_model,
hatch_model, mesh_model, image_model). Also realign the related Scene
API for consistency: model_solids → solid_models, add_model_solid →
add_solid_model, register_model_solid → register_solid_model.

No behavior change; build and tests are green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-16 11:39:31 +03:00
Hakan SevenandClaude Opus 4.8 7fe7e0230d refactor(scene): group modules into role-based subfolders
Move the 30 flat modules under src/scene/ into six folders by role:
convert (entity→geometry), text (fonts/shaping), model (render models),
pick (hit-test/selection/grips/xclip), view (camera/transform/render),
cache (block + property caches). pipeline/ is unchanged.

Full path migration — all `scene::X` references become
`scene::<group>::X`, no compatibility re-exports. render.rs items that
were `pub(super)` (parent was scene) become `pub(in crate::scene)` to
keep their original scene-wide visibility.

No behavior change; build and tests are green.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-16 11:33:46 +03:00
Hakan SevenandClaude Opus 4.8 cd048165a9 feat(fonts): system TrueType support with shaping and fallback
Add a TrueType/OpenType render path alongside the LFF stroke engine so
users can pick fonts installed on their machine for text and MText.

- Discover system families via fontdb (sysfont) and list them in the
  text-style picker.
- Extract glyph outlines with ttf-parser, flattened into wire strokes
  normalized to the same 9-unit cap-height space as LFF glyphs.
- Shape runs with cosmic-text (ligatures, Arabic joining, kerning, bidi)
  plus automatic font fallback: a glyph missing from the chosen family
  is taken from another installed font.
- Unify both sources behind a single Face type; a shared run tokenizer
  drives the stroke (per-glyph) and shaped (TTF) renderers identically.
- Stroke fonts now fall back to a system TTF (cosmic-text picks it) for
  characters no LFF font covers, replacing the unicode/iso3098 chain.

Also trim the embedded LFF set from 47 to 26, keeping the AutoCAD-SHX
equivalents and dropping LibreCAD-only fonts now covered by TrueType.

LFF builtins keep priority over same-named system fonts so DXF text
renders identically across machines.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-16 11:18:17 +03:00
Hakan SevenandClaude Opus 4.8 e2be8197b4 feat(stretch): live ghost preview while dragging the new point
STRETCH now shows a live ghost where vertices inside the crossing window
follow the cursor and the rest stay anchored, instead of only a rubber-band
line. Add WireModel::stretched and pass the selection's wires into the
command.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-15 23:43:07 +03:00
Hakan SevenandClaude Opus 4.8 540d5f7b78 fix(xclip): clip hatch fills and render nested-block hatches
Hatch fills render through the separate HatchModel pipeline, so the
block wire clip never touched them: hatches outside an XCLIP boundary
were still drawn. Clip each hatch boundary loop to the clip polygon
(Sutherland-Hodgman, even-odd islands preserved) when the insert carries
a spatial filter.

Also fix hatches inside the boundary that were never drawn: the hatch
collector exploded each insert only one level, so hatches nested in
sub-blocks were dropped entirely. Walk the full block tree (depth
guarded) so nested hatches render and clip like the rest.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-15 13:02:55 +03:00
Hakan SevenandClaude Opus 4.8 dd1ec3ad40 feat(xclip): clip block references to their SPATIAL_FILTER boundary
Block references carrying an XCLIP spatial filter were drawn in full,
ignoring the clip boundary. Resolve the filter through the INSERT's
extension dictionary (ACAD_FILTER -> SPATIAL) and clip the expanded
block geometry to the boundary polygon: polylines are split into the
inside runs, fill triangles are clipped, and snap/key vertices outside
the boundary are dropped.

The boundary vertices live in the clip-definition space, so they are
placed with world = T_insert * (inverse_block_transform * vert); this
keeps the clip correct even when the insert was rescaled after the clip
was created. Bumps acadrust to the build that decodes SPATIAL_FILTER.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-15 12:29:10 +03:00
Hakan SevenandClaude Opus 4.8 0badf48454 feat(scene): tessellate loaded ACIS solids via the truck B-rep kernel
Rebuild every ACIS face as a truck topological Face and mesh it with truck,
routing loaded 3DSOLID/BODY/REGION/SURFACE through the same kernel the Model
tab uses. plane→try_attach_plane, cone/cylinder/sphere/torus→revolution faces
(truck's Surface has no cone/sphere/torus), spline→BSplineSurface grid sample.
Per-vertex normals are computed analytically outward per surface type and
triangle winding is oriented to match, since truck's own orientation isn't
consistent across independently built faces. `tessellate_solid3d|body|region|
surface` try this first and fall back to the bespoke sampler.

Light the mesh shader with three spread directional lights plus ambient
(abs(dot) per light, two-sided) so faces — including the inner walls of an
open lofted surface — never read as a flat dark mass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-15 00:30:18 +03:00
Hakan SevenandClaude Opus 4.8 9d823363f1 feat(scene): render 3D surface entities (loft/sweep/extrude/etc.)
ACAD_SURFACE entities now load and render. Their NURBS faces are
tessellated by building a truck BSplineSurface from the ACIS `spline-surface`
control net and knot vectors, then sampling its parametric grid — reusing the
same NURBS kernel the Model tab already builds on. Analytic faces
(plane/cone/sphere/torus) continue through the existing tessellator.

- New spline_tess.rs: parse the ACIS `nubs` block (degrees, knots with the
  clamped end-multiplicity fix, control grid) into a truck BSplineSurface and
  mesh it.
- tessellate_surface + Surface wired through the mesh-build, fallback-wire,
  and entity-classification paths.
- Bump acadrust to the commit that adds the Surface entity.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-14 20:11:44 +03:00
Hakan SevenandClaude Opus 4.8 b173f32d5a fix(scene): tessellate cone and cylinder 3D solids
Cone and cylinder bodies rendered empty because their geometry depends on
circular edges the previous boundary walk couldn't use:

- Sample ellipse/circle boundary edges into multiple points so circular caps
  build a real polygon instead of a single degenerate vertex.
- Recover a cone/cylinder lateral face's height span from the solid's coaxial
  circle rims (plus a true cone's analytic apex) when the face boundary
  collapses to one height.
- Wind cone-side quads and plane-face fans so their face normals point
  outward, keeping flat-shaded and Gouraud lighting consistent.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-14 15:43:24 +03:00
Hakan SevenandClaude Opus 4.8 a2cb833c83 fix(scene): render raster images at correct position and size (#98)
Match the image shader's uniform layout to the shared 112-byte Uniforms
struct (was 144) and project quad corners via view_proj so texture-quad
draws no longer fail uniform binding validation.

Offset image quad corners by world_offset, matching all other Model-space
geometry, so rasters land in the same local space instead of being shifted
away. Downscale oversized images to the GPU's max texture dimension (8192)
to keep texture creation from failing.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-14 15:06:06 +03:00
Hakan SevenandClaude Opus 4.8 f6b894813e fix(zoom): fit 3D solids in ZOOM EXTENTS (fit_all)
ZOOM EXTENTS routes through fit_all, which built its bounds from the
wire set and returned early when there were none. A drawing of only 3D
solids has no wires, so ZOOM EXTENTS did nothing. Collect the
layout's mesh AABBs alongside the wires, keep going when only meshes
exist, and fold them into the fitted bounds.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-12 00:42:50 +03:00
Hakan SevenandClaude Opus 4.8 8eeb155bfe fix(zoom): include 3D solid meshes in model-space extents
ZOOM EXTENTS / auto-fit derived the drawing bounds from wire AABBs and
tessellated key-vertices only. 3D solids (Solid3D/Region/Body) render as
meshes with an empty wire path, so they contributed nothing and a
drawing containing only solids zoomed to nothing. Fold each mesh's
offset-relative XY AABB into the extents on both the wire-cache and
tessellate-fallback paths.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-12 00:09:05 +03:00
Hakan SevenandClaude Opus 4.8 5fa93b9cb0 fix(solid3d): stop drawing a placeholder line beside modeler solids
Solid3D / Region / Body have no truck wire conversion, so the wire path
fell through to the generic fallback, which returns a 1-unit placeholder
segment at the origin — a stray little line next to every 3D solid. Give
these types their own fallback that emits only the entity's pre-computed
edge wires (empty for binary SAB) plus an insertion snap, never the
placeholder.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-12 00:04:55 +03:00
Hakan SevenandClaude Opus 4.8 876474f5aa fix(solid3d): place ACIS solids by their body transform
ACIS keeps a solid's geometry in body-local space and records the world
placement in a `transform` record (3×3 affine + translation + scale).
The tessellator ignored it, so solids rendered at their local origin
instead of where the drawing puts them. Apply the body transform to the
mesh vertices (and rotate the normals) after tessellation.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-11 23:57:40 +03:00
Hakan SevenandClaude Opus 4.8 f366aae33c fix(blocks): hide invisible sub-entities in block definitions
A dynamic block with a visibility-state parameter stores the geometry
for every state in one anonymous block and marks all but the active
state's entities invisible. The block-cache builder tessellated every
sub-entity regardless, so a visibility block rendered all variants
stacked on top of each other instead of the one selected profile.
Skip invisible sub-entities in both the render pass and the precision-
offset centroid pass.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-11 19:06:55 +03:00
Hakan SevenandClaude Opus 4.8 28a30de15d fix(wipeout): close rectangular fill loop so the mask stops bleeding
The GPU in_polygon ray-cast walks sequential boundary pairs and never
wraps last→first, so it needs an explicit closing vertex. The polygon
fill branch already appends one, but the rectangular branch returned a
bare 4-corner quad — the v3→v0 (left) edge went untested and the solid
mask bled past the boundary. Newly created wipeouts take this branch,
so the fill rendered wider than the clicked box while the border/grips
(drawn as wires) stayed correct. Repeat corner 0 to close the loop.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-11 18:10:47 +03:00
Hakan SevenandClaude Opus 4.8 d09614be39 fix(ellipse,mirror): WCS ellipse rendering + delegate hatch MIRROR upstream
Two follow-ups from the boundary-arc convention audit:

- ELLIPSE is a WCS entity in DXF (codes 10/11 are world coordinates,
  unlike ARC/CIRCLE). The renderer ran center and major_axis through the
  arbitrary-axis OCS, which misplaced any ellipse whose normal isn't Z-up
  — e.g. the (0,0,-1) result of a mirrored-block explode. Read them as
  WCS; pairs with acadrust 569bd4d, which stores WCS on its explode
  paths. Z-up ellipses are unaffected (the conversion was an identity).

- The MIRROR command's hand-rolled hatch closure swapped boundary-arc
  angles itself — only valid for ccw arcs and ignorant of the stored
  mirrored-angle / wrap-encoded-sweep conventions. It now builds a
  reflection Transform (new reflection_about_xy_line helper) and
  delegates to acadrust's transform_hatch, the single source of truth
  for those conventions. Covered by a Line→Arc→Line continuity + sweep
  regression test.

Note: full effect of the ellipse change lands once the acadrust pin is
bumped to 569bd4d; until then only the (previously broken) non-Z-up
corner differs.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-11 09:03:18 +03:00
Hakan SevenandClaude Opus 4.8 14b5067e25 fix(hatch): mirrored-block arcs — bump acadrust, document angle convention
Hatches inside mirrored INSERTs (x_scale = -1, e.g. the *U blocks in
xref_Plan-Sade) drew some boundary arcs swept the wrong way, covering
huge complementary regions — in the pattern clip before EXPLODE, and in
both pattern and boundary wires after (the exploded entity bakes the
broken angles).

Root cause was in acadrust's transform_hatch, not the renderer: DXF
stores CW (ccw=false) boundary-arc angles MIRRORED (verified against
AutoCAD output by endpoint continuity — mirrored interpretation gives
Δ=0.0 against adjacent edges), and the transform stored geometric angles
after flipping the direction flag. The legacy (TAU-θ) sampling flip in
arc_signed_span is exactly right for file data and stays; its comment now
records the verification so the convention isn't "fixed" away again.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-11 00:41:16 +03:00
Hakan SevenandClaude Opus 4.8 25715d1b03 fix(grips): compute grip positions in f64 so they sit on the wire
Grip markers drew visibly offset from the entity on large-coordinate
(UTM-scale) drawings: every Grippable impl cast the f64 entity coordinates
to f32 BEFORE the world-offset subtraction, and at 1e7 magnitude an f32
holds ~0.9 drawing units of rounding error. The wires subtract the offset
in f64 first (precision-preserving), so the grips landed up to ~1 unit
away from the geometry they belong to.

GripDef.world is now DVec3: producers fill it straight from the f64
entity data, refresh_selected_grips subtracts the world offset in f64,
and only the small offset-local result is cast to f32 for screen-space
projection (grips_to_screen / find_hit_grip) and drag state.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-11 00:04:37 +03:00
Hakan SevenandClaude Opus 4.8 ed29933d87 fix(hover): drop the geometry-only pick cache — it served stale culled wires
hit_test_wires got a cache keyed on geometry_epoch alone (462d9b4), but
the underlying wire set IS view-culled (wires_for_block culls to
view_world_aabb). After zoom-in + edit, the cache pinned the set culled
to that close-up view; zooming back out, blocks that had been off-screen
were missing from the pick set, so hover/selection stopped working on
them until the next geometry change.

Revert to the camera-keyed entity_wires_arc for picking — it re-culls
when the view changes. The actual 0.5.1 hover regression fix (hatch
rebuild/re-upload on rollover) stays.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-10 23:41:51 +03:00
Hakan SevenandClaude Opus 4.8 462d9b4b66 perf(hover): don't rebuild/re-upload hatches on every rollover (0.5.1 regr)
Selected hatches carry a tint (#71), so 0.5.1 keyed both the hatch model
cache and the static GPU buffer re-upload on `selection_generation`. But
that counter also bumps on hover, so moving the cursor over a new entity
rebuilt EVERY hatch model and re-uploaded the hatch + face3d buffers each
time — the large-file hover stutter introduced between 0.5.0 and 0.5.1.

The hatch tint depends only on the *selected set*, never on hover. Key
both on an order-independent signature of `selected` instead, so a
rollover keeps the caches warm; an actual select/deselect still rebuilds.

Also cache the picking wire set on `geometry_epoch` alone (separate from
the camera-keyed render cache) so a pan/zoom no longer re-tessellates the
whole un-culled model on the next mouse move.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-10 21:20:45 +03:00
Hakan SevenandClaude Opus 4.8 14e85171b3 perf(hover): stop scanning every wire on hover for large drawings
Two per-hover O(N) costs on big drawings, both keyed off the cursor:

- The selection/hover xray overlay rebuilt by scanning ALL wires and
  string-parsing each `name` into a handle on every hover change. Index
  handle → wire slots once per wire upload instead, so the overlay gathers
  only the highlighted entity's wires (O(highlighted)).

- click_hit (runs on every mouse move) projected every vertex of every
  wire to screen. In the flat top-down ortho view it now pre-rejects a
  wire by its world-space AABB (four corners projected, so a Z-rotated
  plan view is still correct) and only projects points for wires actually
  near the cursor. Tilted/orbit views fall back to the full test, so no
  pick is ever missed; covered by a unit test.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-10 21:01:12 +03:00
Hakan SevenandClaude Opus 4.8 9cdb5ea5fd fix(io): purge degenerate arcs/splines that OOM on load
Large survey drawings (105k entities, multi-million unit extents) could
exhaust all RAM + swap on one core during load and get OOM-killed before
display. Two parser-garbage entities slipped past the corrupt-entity
purge and blew up truck's parameter_division tessellation:

- Near-zero-sweep arcs: the zero-sweep guard only caught sweep < 1e-9,
  but a tiny non-zero sweep (e.g. 1.6e-6 rad) still places start/mid/end
  within truck's coincidence tolerance and recurses unboundedly. Gate on
  arc length (radius x sweep) instead so large-radius small-sweep arcs
  (still visible curves) survive while sub-precision arcs drop.
- Desync splines with 100k control points and an invalid knot vector had
  no guard at all. Add a Spline arm rejecting the 100k desync signature,
  non-finite control points, degree < 1, and bad knot vectors.

Also cap complex-linetype dash expansion (complex_lt::apply_along) at
1e6 pattern repeats, falling back to a solid stroke — a latent OOM of
the same class on map-scale entities carrying a finely-scaled pattern.

Adds regression tests for the arc-length and spline guards.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-10 08:39:50 +03:00
Hakan Seven 6a70e99dd5 Merge pull request #80 from mf4633/feature/plugin-host
Add plugin host (Phase 1)
2026-06-10 00:37:37 +03:00
Hakan SevenandClaude Opus 4.8 3f9c29d6df fix(hatch): render dot patterns (zero-length dashes)
A zero-length dash entry is a dot (e.g. the DOTS pattern), but every
render path treated only positive entries as marks and skipped it, so
dot patterns drew nothing.

- GPU shaders: light exactly the one pixel containing each dot's lattice
  centre. The dot grid rotates with the pattern angle while the lit pixel
  stays a steady single pixel, so dots no longer flicker at oblique
  angles or zoom (an earlier coverage/round test still aliased).
- PDF export (pattern_segments): walk the dash sequence with absolute
  phase and emit a short mark for each dot.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-09 23:15:45 +03:00
Hakan SevenandClaude Opus 4.8 6b4272521c fix(hatch): deselect refresh, boundary offset, fill-click
Three hatch fixes:

- Selected hatches are tinted, but the hatch model cache and GPU upload
  were keyed only on geometry_epoch, so Esc cleared the selection set
  without repainting — the hatch stayed blue (#71). Key both on
  selection_generation too.

- add_hatch stored the boundary entity in local render space, so the
  boundary wire re-projected `world_offset` away from the fill on
  offset (large-coordinate) drawings. Build it in WCS.

- Clicking a hatch only selected it on its boundary wire, not its fill.
  Two causes: visible_hatches_for_click used a narrower block-visibility
  test than the render path (fill drawn but not clickable), and
  point_in_polygon closed every NaN sub-path — degrading the explicit
  2-point edge encoding of a boundary into zero-area loops. Mirror the
  render's visibility test and only auto-close sub-paths of >=3 verts.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-09 22:47:48 +03:00
Michael Flynn e46701456f Add plugin host architecture and Storm Sewer add-on package
Generic plugin runtime (src/plugin/, HostSession, per-tab plugin_state, command router). QGIS-style add-on layout with plugin.toml and single PluginRegistration. Storm Sewer refactored as reference consumer with dispatch.rs, catchments, LandXML, headless tests (43 passing). Core update.rs decoupled from storm_sewer via CadCommand object-pick hooks. Docs: plugin-architecture.md, plugin-template, PR and issue-78 reply drafts.
2026-06-09 13:41:21 -04:00
Hakan SevenandClaude Opus 4.8 99b7ab1176 perf(scene): incremental tessellation for MOVE / COPY / ERASE
Extends the Phase 2.2 per-entity memo to the common modify commands, which
still did a full bump_geometry (whole-model re-tess + block rebuild):

- transform_entities (MOVE / ROTATE / SCALE / MIRROR) marks each
  transformed handle dirty, then bump_geometry_no_blocks;
- erase_entities drops the deleted handles from the memo and
  bump_geometry_no_blocks;
- copy_entities' new handles are natural memo misses, so just
  bump_geometry_no_blocks.

All three are top-level operations (including on inserts) that can't change
a block definition, so keeping the block cache is sound. Each now
re-tessellates only the touched entities and reuses the memoized wires for
the rest, matching the grip / line-commit path.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-05 16:16:44 +03:00
Hakan SevenandClaude Opus 4.8 eb89717b47 perf(scene): per-entity tessellation memo for the render path
Phase 2.2. wires_for_block_culled re-tessellated every visible entity on
any geometry bump, so a single-entity edit cost a full model re-tess
(~30ms baseline after the block_epoch fix).

Add a per-entity memo (tess_memo: Handle -> Arc<Vec<WireModel>>) on the
culled Model render path. It's guarded by a hash of the tessellation
parameters (tol / view / anno / world_offset / bg / entered viewport); a
mismatch (zoom, layout switch) clears it. The render path now classifies
the visible set against the memo, materializes hits and tessellates only
the misses in parallel, then stores the misses back.

- bump_geometry clears the memo (structural change — safe default);
- incremental edits drop just the changed handle via mark_entity_dirty
  and bump with bump_geometry_no_blocks: line commit (new handle is a
  natural miss) and grip commit / Esc-cancel (mark the dragged handle).

The hit-test (view_aabb == None), paper and per-viewport paths bypass the
memo so their differing cull parameters don't thrash it. A single-entity
edit now skips the per-entity geometry math for the entire unchanged set;
the Vec assembly + batched-buffer upload stay O(N).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-05 15:46:31 +03:00
Hakan SevenandClaude Opus 4.8 3b2f8431a3 perf(scene): keep block cache across non-block edits
The block-definition tessellation cache (BlockCache::build — every block
defn) was keyed on geometry_epoch, so every edit rebuilt all block defns.
On a block-heavy drawing that turned the ~30ms visible-wire re-tess into a
~400ms spike at line-commit, grip first-move and grip release.

Key the block cache on a separate block_epoch. bump_geometry bumps both
geometry_epoch and block_epoch (safe default — xref, block create,
explode, REFCLOSE, undo, open all keep rebuilding blocks). Operations that
provably can't change a block definition now call bump_geometry_no_blocks,
which bumps only geometry_epoch so the visible wires re-tessellate against
the reused block cache:

- add_entity for a non-Insert/Block entity (line, arc, text, hatch, …) —
  scene.add_entity only ever routes to model/paper space, never block
  content;
- grip drag hide / commit / Esc-cancel — moving a top-level entity or an
  insert never edits a block defn.

Line-commit and grip start/end drop from ~400ms to the ~30ms baseline.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-05 15:24:02 +03:00
Hakan SevenandClaude Opus 4.8 e37dfa5890 perf(scene): draw preview/grip overlay from a separate GPU buffer
Command-preview / interim / grip-drag wires were appended into the main
wire buffer (all_wires), so every drag frame re-packed and re-uploaded the
entire batched base set — O(N) per move even though only the small overlay
changed.

Split them out: build_primitive keeps `wires` as the cached base only and
passes the overlay as a separate `preview_wires` Arc. The base keeps the
stable tile content id (no per-frame nonce), so its buffer stays resident
and is not re-uploaded during a drag. The overlay uploads to its own small
gpu_preview_wires buffer each frame and draws on top in the wire pass
(same pipeline, same order/depth). The dragged entity still renders
highlighted via its selected-path tessellation.

With the no-re-tess changes, a grip / command drag now costs a single
small overlay upload per move and nothing else.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-05 10:57:38 +03:00
Hakan SevenandClaude Opus 4.8 8df9bef6f8 perf(scene): grip drag previews the entity instead of re-tessellating
Phase 2.2 (partial). Dragging an entity's grip called scene.apply_grip on
every mouse move, which bumped geometry and re-tessellated the whole model
per move (plus an O(N) clone of every wire to exclude the edited one from
snapping). On a large drawing that's the 30ms->400ms spike, per frame, for
the duration of the drag.

apply_grip no longer bumps. The drag flow now:
- first move: hide the edited entity from the base tessellation (one
  re-tess) and record it in grip_preview_handle;
- each move: apply the grip to the doc (no bump) and show the entity as a
  one-entity overlay preview (set_preview_wires) — the base is a cache hit;
- commit on release / Esc: un-hide, drop the preview, one final re-tess.

Because the edited entity is hidden, it's already absent from
hit_test_wires, so snapping runs against the cached set directly — no
per-move clone and no self-snap. Two re-tessellations per drag instead of
one per move.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-05 10:33:08 +03:00
Hakan SevenandClaude Opus 4.8 ccb9bb832d perf(scene): stop re-tessellating on preview/interim overlay updates
Phase 2.2 (partial). set_preview_wires / clear_preview_wire /
set_interim_wire called bump_geometry, so every rubber-band frame of a
drawing command invalidated the wire cache and re-tessellated the whole
model — the same 30ms->400ms spike as selection, but while drawing.

Preview / interim wires are an overlay appended to the cached base wire
set in build_primitive; they are not part of the tessellation cache, so
they no longer bump geometry. The base model-tile tessellation stays a
cache hit (static camera, no geometry bump), and the overlay still forces
a GPU wire re-upload through the has_overlay content-id path. Real commits
(commit_entity / transform / copy) bump geometry on their own, so finished
edits and preview-clear still render correctly. Snap/hit-test via
entity_wires_arc also stops re-tessellating per preview frame.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-05 10:21:32 +03:00
Hakan SevenandClaude Opus 4.8 ff9f49511b perf(scene): decouple selection from wire tessellation
Picking an entity called bump_geometry, which invalidated the wire cache
and re-tessellated the entire model just to repaint one entity — 30ms to
400ms on a large drawing (visible as a tess-ms spike on the PERF HUD).

Selection highlight is no longer baked into tessellation. wires_for_block_
culled now feeds tessellate_entity an empty selection set, so wires are
always base-coloured and the wire cache is selection-independent. The
highlight is applied in the GPU xray overlay: prepare filters the cached
wires by the live `selected ∪ hover` handle set, recolours them to
WireModel::SELECTED, and batches them — rebuilt only when a new
selection_generation (or the underlying wire content) changes.

Pure select / deselect / hover paths now call bump_selection instead of
bump_geometry, so a pick refreshes just the overlay — no re-tessellation,
no main wire re-upload (geometry_epoch and wire_content_id are unchanged).
Geometry edits, undo and file-open still bump_geometry and rebuild
everything. Grips/properties refresh through their existing app paths.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-05 10:04:22 +03:00
Hakan SevenandClaude Opus 4.8 0d18e0d1bf perf(scene): batch the selection-overlay wire pass too
Follow-up to 3.3. The selection xray pass still built one buffer + one
draw call per selected wire, so select-all over a large drawing issued a
draw per entity. That pass applies neither scissor nor mesh-edge skip, so
all selected wires merge into one order-preserving run via from_run.
Removes the now-dead WireGpu::new / build per-wire constructors.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-05 09:44:12 +03:00
Hakan SevenandClaude Opus 4.8 c6e17deb7e perf(scene): batch the wire pass into per-run draw calls
Phase 3.3. upload_wires built one GPU instance buffer + one draw call per
WireModel — tens of thousands on a large drawing, a major per-frame CPU /
driver cost. Merge maximal runs of consecutive wires that share scissor +
mesh-edge state into one concatenated instance buffer each (WireGpu::
from_run); the existing draw loop then issues a single draw per run. A 2D
model (uniform None scissor, no mesh edges) collapses to one buffer + one
draw.

Runs are consecutive, never globally regrouped, so the sorted wire order
is preserved exactly — depth-biased overlap and alpha blending both
resolve in submission order, so the image is unchanged. from_run
guarantees instance order (index-ordered collect + sequential flatten).
The WireInstance layout, shader, scissor projection and draw loop are
untouched; only buffer packing changed. Selection overlay stays per-wire.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-05 09:35:18 +03:00
Hakan SevenandClaude Opus 4.8 65eba3887a perf(scene): memoize Face3D wire split across frames
build_primitive ran split_face3d_wires every frame — for each wire a
string-parse + document handle lookup + clone, to separate Face3D wires
from the rest — even when a pan had just reused the tessellation. The
split is content-only, so memoize it by the Model-tile wire content id:
a pan-reuse (same id) reuses the split, and the common no-overlay frame
returns the cached `other` Arc with zero clone. Preview/interim frames
clone once to append. Non-tile paths split inline as before.

Completes the cheap-pan path: a pure pan now reuses the CPU
tessellation, the Face3D split, and the GPU wire upload — only the
view_proj uniform and per-frame scissors recompute.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-05 09:23:52 +03:00
Hakan SevenandClaude Opus 4.8 0f40cfa054 perf(scene): skip wire GPU re-upload when only the camera moved
Phase 3.2 (wire pan path). Wire vertex buffers are world-space, so a
camera move changes only the view_proj uniform (uploaded every frame),
never the wire data — yet upload_wires re-ran on every camera_generation
tick, re-packing vertices and re-writing the GPU buffer on each pan.

Stamp every Model-tile re-tessellation with a monotonic content id
(WIRE_CONTENT_GEN), reused when a pan reuses the tessellation. The
pipeline records the resident buffer's id and skips upload_wires when the
incoming id matches. The gate is pulled out of the (epoch, camera_gen)
block so a preview/interim wire change still uploads even with a static
camera; non-tile paths and overlay frames force a fresh id, preserving
current behaviour. Monotonic id sidesteps the ABA hazard a freed/realloc'd
Arc pointer would carry.

Pairs with the pan tessellation reuse: a pan now skips both the CPU
re-tessellation and the GPU wire upload.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-05 09:16:44 +03:00
Hakan SevenandClaude Opus 4.8 d74cb891f9 perf(scene): reuse model-tile tessellation across pure pans
Phase 3.1 (partial — pan). The Model-tile wire cache keyed on the exact
camera hash, so every pan re-tessellated the whole tile even though pan
changes neither the curve tol nor the culled set (beyond a shift). Re-key
on (geometry_epoch, pan_invariant_hash, tessellated_region):

- pan_invariant_hash covers rotation + wpp (tol) but excludes the pan
  target, so a pure pan keeps it stable;
- the wires were culled to a 1.25x-margin region, recorded with the entry;
- a hit now also requires the tile's current visible rect to still fit
  inside that region, guaranteeing every on-screen wire is present.

Result: panning within the existing margin reuses the tessellation (tess
ms -> ~0 on the PERF HUD) with no missing geometry. Zoom, orbit and edits
change the signature/epoch and rebuild as before; the cull margin is
unchanged so miss cost — and zoom behaviour — is identical. GPU still
re-uploads per camera_generation (diff upload is Phase 3.2).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-05 09:06:12 +03:00
Hakan SevenandClaude Opus 4.8 44eea23c3f perf(scene): parallelize block AABB post-pass
Extends Phase 2.1. compute_block_aabbs resolved each defn's union AABB
serially via defn_aabb_recursive, which only reads the finished defns map
and re-walks shared nested defns with no memoization — real cost on
block-heavy drawings. Split into a parallel read phase (per-name resolves
fan out over rayon) and a serial write-back phase. BlockCache is Sync, so
sharing &self across the read phase is sound.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-05 08:52:16 +03:00