Commit Graph
2947 Commits
Author SHA1 Message Date
Felix RiestraandClaude Sonnet 5 86e3a0c794 Downscale oversized material maps to the GPU texture limit
Material map images (diffuse/specular/reflection/opacity/bump/
refraction/normal) were decoded at their original file dimensions and
uploaded to wgpu without checking device.limits().max_texture_dimension_2d,
unlike raster-image rendering, which already tiles to that same limit.
An oversized material map produced a wgpu validation error and left
the material unusable; the global error handler prevented a process
abort, but never produced a valid texture either.

A mesh material is sampled by UV across a surface, so it can't use the
same per-quad tiling trick raster images use. Instead,
upload_rgba_texture now checks the decoded image against the device's
texture-size limit and downscales it (aspect-preserving, via
image::imageops::resize) before upload when it's oversized, falling
back to the existing 1x1 material color if the decoded buffer is
malformed (its length doesn't match its reported dimensions).

3 new tests cover the resize math (aspect ratio preserved, both
dimensions respect the limit) and the malformed-input fallback.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 10:13:14 +02:00
Felix RiestraandClaude Sonnet 5 d0025d6ddf Make automation "open" run the same finalization as a UI open
Automation's "open" op called io::load_bytes directly and installed
the result as-is, skipping everything the canonical path-based loader
(load_file_for_open / finalize_loaded_outcome) runs after parsing:
block-origin normalization for legacy non-zero-origin blocks,
relative raster/material path resolution, source_path assignment,
and the corrupt-entity purge the UI open path always applies. The
same file opened two ways behaved differently, and headless/automation
use was more exposed to malformed geometry with no filter at all.

Added io::load_bytes_finalized(path, bytes), which runs load_bytes
plus that same normalization and purge using the real path (not just
its basename, which the old code passed to load_bytes as the "name"
parameter — losing the directory needed for raster-path resolution
and source_path). automation.rs's "open" handler now uses it and
reports the purge count as a "purged" field in the response.

load_bytes itself is unchanged, since the byte round-trip tests
(sketch_persist.rs, named_parameters_persist.rs) deliberately want
the simpler byte-only path with no real filesystem path behind it.

New test constructs a document with one normal and one corrupt
(zero-radius) entity, saves it, opens it through automation, and
checks the corrupt entity was purged and source_path was set —
proving the automation path now matches the UI path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 10:13:06 +02:00
Felix RiestraandClaude Sonnet 5 8bc9e83c2e Fix unbounded MINSERT allocation and scale-multiplied spacing
Two independent MINSERT (array INSERT) bugs found while auditing
render_graph.rs:

1. array_offsets() reserved and filled one Vec element per array
   instance, with row_count/column_count parsed as unchecked u16s.
   A corrupt or adversarial 65535x65535 pair requests 4,294,836,225
   instances (~96 GiB) before any GPU-side chunking limit can help,
   and before the render graph even reaches the GPU. Fixed with a
   documented instance budget (20,000) enforced at both the load-time
   corrupt-entity filter (is_entity_corrupt, matching the existing
   polyline/spline vertex-count guards) and a failsafe clamp in
   array_offsets itself, for an insert built or edited some other way.

2. insert_instance_transform composed the per-instance row/column
   offset translation *before* the INSERT's own transform, so the
   block's x/y scale multiplied the spacing along with the block
   contents — an INSERT with x-scale 2 and column spacing 10 rendered
   20 units between columns instead of 10. The pinned CAD model's own
   Insert::array_points() confirms the correct behavior: spacing
   rotates with the insert but is never scaled. Fixed by orienting the
   offset with rotation + OCS only, then translating the already-
   composed (scaled) instance transform by that world-space vector.

7 new tests cover both: pathological/reasonable instance counts at
the load-time filter and the render-graph failsafe, and instance
transforms for scaled and rotated arrays.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 10:12:53 +02:00
Hakan Seven 4ddd19f6e8 feat(mcp): describe complete CAD record schemas 2026-09-09 01:01:44 +03:00
Hakan Seven 8f574c8388 feat(mcp): expose complete drawing records 2026-09-08 23:59:13 +03:00
Hakan Seven 89572e5ca9 fix: keep valid small drawing curves on open 2026-09-08 22:42:49 +03:00
Hakan Seven 2d55b90b0e chore: update DWG save dependencies 2026-09-08 22:20:07 +03:00
Hakan Seven c5503e4758 Merge PR #1083: implement solid shell workflow 2026-09-08 21:02:01 +03:00
ramox81 7913f8e2da Merge remote-tracking branch 'origin/main' into codex/shell-parity
# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	crates/ocs_doc_api/Cargo.toml
#	src/modules/model/mod.rs
2026-09-08 19:49:35 +03:00
Hakan Seven cadd506f76 Merge pull request #1079 from mechpanos/pr/first-edit-patches
Carry the loader-built resident wire layout into the installed scene so the first edit can use the incremental patch path. Add a regression test that exercises the full prepare/install/edit handoff.
2026-09-08 19:45:18 +03:00
ramox81 be06a756da Implement solid shell workflow and properties 2026-09-08 19:37:35 +03:00
Hakan Seven f611ceb638 Update CAD dependency revisions 2026-09-08 19:34:39 +03:00
Hakan Seven fd3ddc1608 Finish Ramox edge and slice integration 2026-09-08 19:02:08 +03:00
Hakan Seven d3f0d0ec42 Merge pull request #1081 from ramox81/codex/chamferedge-split 2026-09-08 18:33:50 +03:00
Hakan Seven 39492860d3 Merge pull request #1077 from ramox81/codex/filletedge-split
# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	src/app/model_ops.rs
#	src/scene/model/solid_history.rs
2026-09-08 18:33:20 +03:00
Hakan Seven 7ec07f3222 Merge pull request #1070 from ramox81/codex/filletedge-parity
# Conflicts:
#	Cargo.lock
2026-09-08 18:30:34 +03:00
Hakan Seven 001605baa1 Merge pull request #1065 from ramox81/codex/slice-command-parity 2026-09-08 18:28:01 +03:00
Karim Jerbi 08891816be fix(render): highlight untinted analytical curves and guard uniform bind group
- Fall back to WireModel::SELECTED when selected_tint is None (classic mode) so selected circles, arcs, and ellipses are not omitted from the highlight pass.

- Explicitly rebind uniform_bind_group before drawing analytical curves in selection_xray pass.

- Skip invisible wires (display_visible == false) during selection and hover wire extraction.
2026-09-08 16:21:28 +01:00
ramox81 4f22d998e7 Complete independent CHAMFEREDGE workflow 2026-09-08 18:08:52 +03:00
Hakan Seven 9868158e27 Merge pull request #1080 from mf4633/registry-stormsewer-pro
registry: note Storm Sewer free/Pro split in description
2026-09-08 18:00:37 +03:00
Hydro Engineer e6e60c136a registry: note Storm Sewer free/Pro split 2026-09-08 10:56:19 -04:00
Karim Jerbi 058cd02042 fix(pick): require all sub-geometry inside window selection for multi-wire and mesh entities 2026-09-08 15:49:54 +01:00
PanosandClaude Opus 5 18517a20a3 perf: let the first edit after opening a file patch instead of rebuild
The first drawn point costs 946 ms against 17-39 ms for every one after it,
and `build=587.67ms` is most of that — a full wire assembly where the
following points patch in 1.2 ms.

`try_resident_patch` refuses any resident set whose `layout` is `None`, and
`install_prepared_open_geometry` inserted the loader's set with exactly
that. So the set the file open hands over could never be patched, and the
first edit rebuilt all 236 954 wires to add one.

The layout is a pure function of the wire order, and the loader already
built it for those very wires — `resident_wires_for` stores it in the
temporary scene's resident set. It now travels in `PreparedOpenGeometry`
like the tessellation memo and the draw-order map, taken by `Arc::ptr_eq`
identity so it cannot be paired with a different assembly.

`try_resident_patch` also reports why it declines. Each refusal costs a
full assembly, and until now the only one that said anything was the shared
`Arc`; `no-layout`, `no-entry` and `journal-too-old` were silent.

**Not verified.** The headless harness opens through the synchronous path,
so `install_prepared_open_geometry` never runs there, and `ZOOM E` does not
reach `try_resident_patch` at all — no skip line is emitted for it. This
needs a GUI run on the reproducer: the first drawn point should drop well
below 946 ms, and if it does not, `resident-patch-skip reason=` now says
what is still refusing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 17:44:38 +03:00
ramox81 f16423ef8e Keep primitive properties editable after fillet 2026-09-08 17:35:51 +03:00
Hakan Seven 4972bbed70 Fix issues found while integrating performance PRs 2026-09-08 17:33:02 +03:00
Hakan Seven 2b510c1a1e Merge pull request #1076 from mechpanos/pr/skip-partition-on-patch 2026-09-08 17:17:59 +03:00
Hakan Seven 1d9244177e Merge pull request #1075 from mechpanos/pr/open-and-selection-cost 2026-09-08 17:17:53 +03:00
Hakan Seven d710868d89 Merge pull request #1074 from mechpanos/pr/selection-filter-perframe 2026-09-08 17:17:45 +03:00
Hakan Seven 2ba5415479 Merge pull request #1073 from mechpanos/pr/edit-latency-metric 2026-09-08 17:17:37 +03:00
ramox81 261ab66afd Restore primitive properties after edge operations 2026-09-08 17:11:23 +03:00
PanosandClaude Opus 5 eef007d080 perf: report a skipped partition without printing zero counts
The skip branch printed `circles=0 ellipses=0`, which reads as "there are
no circles" when it means "not recomputed this frame" — and those counts
were the evidence that confirmed the skip works, so the wording nearly
cost a misreading of it.

The skip now prints its own line with what it retained.

Measured effect of the skip itself, on the reproducer: a drawn point goes
from 98 ms to 17-39 ms, `prepare` from 89 ms to 11-23 ms. Frames that draw
a circle still re-partition, and the instance counts show it — 40868,
40869 … 40874, one per circle, with the line frames skipped in between.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 16:42:39 +03:00
PanosandClaude Opus 5 5eb762e88d fix: forget the partition record with the keys that describe it
`forget_cached_keys` is the one place that means "this slot holds nothing
you can trust", and it left the partition record behind. A reused slot
rebuilds regardless — its content id changed, so the arena takes the build
path and the record is overwritten — but that made the safety an accident
of another code path rather than something this function guarantees.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 16:42:39 +03:00
PanosandClaude Opus 5 d46c00281f fix: decide partition membership by extraction, not by shape
`skipped=false` on every frame of a real session — the skip added in the
previous commit never fired once.

The membership test used the same cheap structural filter that
`partition_wires` uses before attempting extraction: a tangent geom, no
fill, pick or text triangles. That filter is a pre-check, not a decision.
An ordinary drawn line carries a tangent geom for snapping and no
triangles, so it passed, and every drawn line was counted as feeding the
analytical uploads. Nothing was ever skippable.

It now decides the way the walk decides, by attempting the extraction.
`draw_depth` only rides along in the extracted instance, so the value
passed for the test cannot change the answer, and it runs only for the
handful of wires a patch changed.

Drawing a circle or an ellipse still forces the walk, correctly — those
are contributors, and the session that exposed this contained both. What
changes is that the lines drawn afterwards no longer do.

Tested on real tessellated geometry: a line must not force the walk, a
circle must.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 16:42:39 +03:00
PanosandClaude Opus 5 03c64e3d38 perf: skip the wire partition when a patch cannot change its output
`arena-post partition=55.2ms blocks=5.9ms curves=2.5ms` — the partition is
almost the whole of the 73 ms an arena patch spends after patching, and it
walks all 236 956 resident wires to serve a one-entity change.

Its three consumers here — the instanced, circle and ellipse uploads —
depend only on wires that are block references or analytical curves. A
patch that touches none of those cannot change any of them, so the walk is
skipped and the uploads already on the slot are kept.

Two things make that sound, and both are checked rather than assumed:

**Depths hold still.** The instances bake a draw depth.
`inserted_draw_depth_label` allocates into a reserved gap instead of
renumbering, so adding an entity leaves every other label alone — the
existing doc comment says so and there is now a test. A rebuild from
scratch *can* move them, so `draw_depth_generation` bumps only there, and
a slot reuses its uploads only while that generation is unchanged.

**Contributors are remembered.** `partition_wires` records which handles
fed those three outputs, and the skip requires that no changed handle is
among them and that no changed handle's new wires would join them. That
catches an entity entering the set and an entity leaving it. The
membership test is shared with the walk it decides to skip, so the two
cannot drift, and it is deliberately conservative: a wire that looks
analytical but fails extraction forces a needless re-partition, never a
stale upload.

`arena-post` gains `skipped=` so a run says how often this actually fires
rather than leaving it to be inferred from the timing.

Not verified on a device yet — the headless harness never reaches
`prepare`, so this needs a GUI run on the reproducer to confirm both the
saving and that nothing goes missing on screen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 16:42:38 +03:00
PanosandClaude Opus 5 b68989768d perf: look the draw depth up only where it is used
`arena-post partition=55.2ms blocks=5.9ms curves=2.5ms` — the partition is
the whole of the 73 ms an arena patch spends after patching, and it runs
on every drawn point over the entire resident set.

Inside it, `wire_draw_depth` is a hash lookup whose result feeds only the
analytical circle and ellipse extraction, and it was computed before the
test that decides whether either will run. So every patch paid 236 956
lookups to serve the ~56 000 wires that are circles or ellipses.

Moving it inside that branch is behaviour-preserving: the value was
unused on every path that skipped it.

This does not make the partition incremental — it still walks the resident
set for a one-entity change — but that walk is memory-bound and a
structural fix needs the cached partition to survive a patch, which the
borrows in `PartitionedWires` make its own piece of work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 16:42:38 +03:00
PanosandClaude Opus 5 feb3ca4d28 perf: split the arena patch's post-work into its three steps
With the selection-filter rebuild gone, a drawn point costs 98 ms and
`prepare` is 93 % of it:

    wire  73.0ms  arena-patch  wires=236961 gpu_instances=405551
    wire-cull      5.1ms
    face3d-upload  3.9ms
    text-upload    3.0ms
    resident-patch 1.0ms  changes=1
    draw-depth-patch 0.0ms

`arena-base ok=true patch=Some(16) changes=1` — the arena patches
incrementally, one entity, and still takes 73 ms. The patch itself is
careful; the three steps that follow it are not, and all three are
O(resident set) rather than O(changes):

    partition_wires(&vp_wires, …)          236 961 wires, every frame
    upload_block_wires(…instanced…)         10 719 placements rebuilt
    upload_circles/ellipses_from_instances

`arena-post partition= blocks= curves=` says which dominates. Guessing
between three plausible candidates is what the earlier rounds of this work
paid for repeatedly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 16:42:38 +03:00
PanosandClaude Opus 5 6f01d4215b perf: fold a lone addition into the block-member index
`vis_index=19.4` of a 55 ms selection, on every build, because the index
is keyed on `geometry_epoch` and the epoch moves on every edit. The walk
visits every entity in the drawing to add one handle to one list.

A lone addition is folded in instead. **Only** a lone one: entities are
appended to the document, so a single new handle belongs at the end of its
block's list, whereas `replay_since` returns several changes in no
particular order and each list must stay in document order because that is
draw order. A removal, a modification that could move an entity between
blocks, or more than one change all take the full walk.

Measured on a 586 k-entity drawing, headless, opening then drawing two
lines with a rebuild after each:

    before   visible=50.7  vis_index=19.7      visible=48.2  vis_index=17.6
    after    visible=34.1  vis_index=0.0       visible=34.4  vis_index=0.0

A third of the selection, and it holds for each subsequent edit.

Tested against the full walk, including that the appended handle lands
last — putting it anywhere else would reorder the drawing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 16:42:21 +03:00
PanosandClaude Opus 5 5e5a810ee5 perf: stop rebuilding the selection filter list on every drawn point
`view-detail` on an edit frame:

    total=105.6ms  chrome=105.4   [every other section 0.0]

and the edit latency measurement puts ~102 ms of a 192 ms drawn point in
the gap between the handler returning and the scene primitive being built
— which is that same `chrome`.

The status bar asks for `entity_type_names_in_layout` on every frame to
populate the selection-filter menu, and the cache behind it was keyed on
`geometry_epoch`. Every drawn point bumps that epoch, so every drawn point
walked all 444 937 handles a model space owns to answer a question the
line did not change: adding a line to a drawing that already has lines
leaves the set of type names exactly as it was.

The cache now carries the set alongside the list, and folds edits in from
the delta journal — the same incremental shape `entity_index` already
uses. Additions can only grow the set, so they are applied directly and
the existing `Arc` is handed back untouched when nothing new appeared. A
removal can retire a type and cannot be resolved from a handle that is
already gone, so those still fall back to the full walk.

`entity_type_name` returns a literal for every arm but one — `Extended`
borrows its class name from the entity — so the set holds owned strings,
and the hot path tests `contains` before allocating.

Tested: a second line of an existing type must hand back the same `Arc`,
a new type must still appear, and the folded answer must equal what a
rebuild from scratch produces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 16:42:21 +03:00
PanosandClaude Opus 5 d267083a29 perf: say which message caused an edit's latency
The first measurement showed four consecutive edits at ~192 ms each with
a handler cost of 0.55 ms. That is below the `update` line's 5 ms
threshold, so the log reports the latency but not what asked for it —
and whether a live command preview is bumping the geometry epoch on every
mouse move is exactly the question the numbers raise.

`nav-prepare` now carries the message label alongside the op.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 16:42:21 +03:00
PanosandClaude Opus 5 41b794e9a7 perf: resolve each layer once per selection, not once per entity
`Table::get` normalises the name with `to_uppercase`, so every layer
lookup allocates a String and runs a Unicode pass.
`resident_entity_visible` did one per entity, and the selection calls it
once per candidate to look up a few hundred distinct layers.

The predicate splits so the layer can be passed in, and the selection
resolves each distinct name once into a small map keyed on the entity's
own layer string. Keys borrow from the document, so naming the lifetime on
`wires_for_block_culled` is what lets them outlive the closure call.

**Measured, and smaller than it looks.** On a 585 786-entity drawing with
37 826 candidates, A/B through `OCS_NO_LAYER_MEMO`:

    with memo   visible=55.1ms
    without     visible=57.1ms

3.5 %, not the order of magnitude the allocation count suggested. The
per-entity cost of that selection is dominated by traversing entities
scattered across the document arena, not by the work done at each one — a
resolve-only pass over the same handles measures 1.4 ms, and only because
it runs second, on a warm cache.

Kept because it is measured, cheap and correct, not because it is the
answer to the selection's cost. `OCS_NO_LAYER_MEMO` stays so the claim can
be rechecked on other drawings.

The keying is on the **raw** name while the table is case-insensitive, so
two spellings of one layer take two slots and both resolve to it — one
extra normalisation, never a different answer. Tested, along with the
predicate that reads it.

The early cheap checks moved into the layer-taking variant rather than
staying in the wrapper, so calling it directly cannot skip them.

`vis_index=` reports what `block_members` costs inside `visible`: 19.2 of
the 55 ms here, rebuilt by walking the document once per `geometry_epoch`
— which moves on every edit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 16:42:21 +03:00
PanosandClaude Opus 5 d5859a01a5 perf: separate the two costs inside the visible-entity selection
The first drawn point costs 1068 ms and `wires-build` owns 710 of it:

    visible=402.5  build=148.4 [classify=18.4 hits=127.5]  sort=41.9
    memo_hit=186455 memo_miss=1  candidates=444938

The memo is doing its job. `visible` is not, and two things in that loop
could own the 402 ms: resolving each handle through the document's hash
index, or the predicate — which reaches `document.layers.get(&c.layer)`,
a **string-keyed** lookup, so a string hash per entity, 444 938 times.

They call for different fixes, so `visible_probe=` times a resolve-only
pass and the remainder is the predicate. The pass repeats the resolve
rather than reading the clock inside the loop, where the measurement would
cost more than the thing measured, and it runs under PERF only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 16:42:21 +03:00
PanosandClaude Opus 5 6b03e4cf17 perf: hand the loader's draw-order map to the scene
The open sequence computes it twice:

    [perf] draw-depth 293.5ms entries=1166328    ← loader thread
    …
    [perf] draw-depth 302.0ms entries=1166328    ← main thread
    [perf] update    1427.3ms message=other

`DrawDepthCache` is keyed on `geometry_epoch`, the loader builds it on its
temporary scene, and `PreparedOpenGeometry` carried the wire set, the
tessellation memo and the block definitions across — but not this. So the
receiving scene walked all 1 166 328 entities again to rebuild a map that
already existed, inside the message that finishes opening the file.

It travels on exactly the terms the block definitions already do: the
document is the loader's own and has not been touched between the loader
finishing and the install, so its order labels describe the receiving
scene exactly. Only the epoch is restamped.

Tested by handing a computed map back into a scene through
`install_prepared_open_geometry` and asserting both that it is served
as-is and that it equals what that scene recomputes from scratch — the
second assertion is the one that matters, since serving a map that does
not describe the document would put every entity at the wrong draw order.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 16:42:21 +03:00
PanosandClaude Opus 5 b4ae4cad4e perf: measure an edit from the input to the frame that answers it
Drawing a point in a large drawing feels slow, and nothing currently
measures the thing the user actually waits for. `update` times the message
handler, `wires-build` and friends time the rebuild, but the span between
them — press to the frame that shows the new geometry — has no number.

Navigation already has exactly that plumbing: `record_nav_perf` stamps an
instant when the input arrives, `prepare` and `encode` pick it up, and
`nav-prepare` / `nav-render` report `input`, `build`, `prepare` and
`elapsed` from the original event.

`NavPerfOp::Edit` reuses it. `update` snapshots `geometry_epoch` before
dispatch and stamps the sample when it moved, so any message that changed
geometry — a drawn point, a grip commit, an undo — reports its full
latency rather than only the handler's share.

Diagnostic only: `record_nav_perf` sets a pending sample and nothing else,
so no LOD or settle behaviour changes.

Nothing is optimised here on purpose. Four call sites feed the snapper
along different branches, and picking one to instrument before knowing
which dominates is how the earlier rounds of this work were wasted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 16:42:20 +03:00
Karim Jerbi 116c4836a1 fix(render): render selection and hover highlights for analytical circles and ellipses 2026-09-08 14:39:37 +01:00
ramox81 43f97a3bcb Use analytic chamfer kernel support 2026-09-08 16:06:52 +03:00
ramox81 8822371e18 Complete CHAMFEREDGE workflow and history 2026-09-08 15:45:10 +03:00
Karim Jerbi a754fac6d4 Attach analytical arc to polyline Arc mode rubber-band preview 2026-09-08 13:16:49 +01:00
Karim Jerbi ec3b6b89f6 Expand GPU analytical curves to interactive previews and transformations 2026-09-08 13:16:49 +01:00
Karim Jerbi 84e95e4781 Configure 16 MB stack reserve on Windows for debug builds 2026-09-08 13:16:49 +01:00
ramox81 ed8cddc556 Pin analytic pcurve serialization and spherical corner fixes 2026-09-08 14:16:30 +03:00