Commit Graph
2905 Commits
Author SHA1 Message Date
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
2026-09-08 16:42:21 +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
Hakan Seven 5b663bfe38 Fix issues found while integrating contributor PRs 2026-09-08 12:14:28 +03:00
Hakan Seven 6a2782f891 Merge pull request #1069 from UserDevtec/fix/architectural-inch-unit 2026-09-08 12:09:21 +03:00
Hakan Seven 5dcf2bde0b Merge pull request #1067 from UserDevtec/fix/align-bylayer-dropdowns 2026-09-08 12:09:14 +03:00
Hakan Seven 08cd0041a5 Merge pull request #1064 from UserDevtec/main 2026-09-08 12:09:07 +03:00
JonathanandCopilot 6616111fbf Fix Architectural and Engineering inch unit formatting
Suppress leading 0 feet notation when feet = 0 in architectural (LUNITS 4)
and engineering (LUNITS 3) formats. Values less than one foot should display
as inches only (e.g. 9 1/4 inches instead of 0 feet 9 1/4 inches).

- For Architectural: 9.25 inches now displays as 9 1/4 inch mark instead of 0 feet 9 1/4 inch mark
- For Engineering: 11.94 now displays as 11.9 inch mark instead of 0 feet 11.9 inch mark
- Proper feet notation is preserved for values >= 1 foot
- Updated tests to reflect correct expected values

Fixes issue 1066

Co-authored-by: Copilot <[email protected]>
2026-09-08 10:18:53 +02:00
JonathanandCopilot 725a7b3e7e Align ByLayer property dropdowns consistently in Properties panel
Use SELECTOR_PAD_V (calculated from ROW_H and FONT_SZ) instead of
hardcoded [3, 6] padding for color selector buttons. This matches the
padding used by combo_box widgets for Lineweight and Linetype,
ensuring all three ByLayer dropdowns align vertically and have
consistent height.

Fixes #1047

Co-authored-by: Copilot <[email protected]>
2026-09-08 09:26:28 +02:00
JonathanandCopilot 92ac3f60e5 Fix crash when overwriting an existing PDF file
Use atomic file operations (write to temp file, then rename) instead of
direct File::create() to avoid crashes when the target PDF is locked by
another process (e.g., open in PDF reader). The new write_pdf_atomically()
function ensures the file is either fully written or left unchanged, with
a clear error message if the file is in use.

Fixes #1062

Co-authored-by: Copilot <[email protected]>
2026-09-08 09:04:52 +02:00
JonathanandCopilot 7a706e24ac Improve Dutch CAD terminology translations
- Multiverwijslijn → Multiverwijs Lijn (proper spacing)
- Multiline → Multilijn Tekst (better clarity)
- Unie → Samenvoegen (more accurate for boolean union)
- Aftrekken → Verschil (better term for boolean subtract)

These terms align better with Dutch CAD industry standards.

Co-authored-by: Copilot <[email protected]>
2026-09-08 08:52:18 +02:00
JonathanandCopilot 8497abecfd Fix ribbon command labels wrapping in the middle of words
Replace Wrapping::WordOrGlyph with Wrapping::Word to prevent labels
like 'Dimensions' from breaking as 'Dimensio' + 'ns'. Word-only wrapping
ensures labels only wrap at word boundaries.

Fixes #1046

Co-authored-by: Copilot <[email protected]>
2026-09-08 08:34:58 +02:00
gianlucafiore febedad916 Merge pull request #1061 from gianlucafiore/fix/fillet-lines
fix(fillet): improve line and polyline fillets
2026-09-07 19:51:29 -03:00
gianlucafiore 4e46deee49 feat(fillet): add polyline fillet option 2026-09-07 19:46:43 -03:00
gianlucafiore cbfdcd9ecd fix(fillet): support parallel line fillets 2026-09-07 18:48:18 -03:00
gianlucafiore 0cd0662bf7 Merge pull request #1060 from gianlucafiore/fix/issue-1050-hatch-stretch
fix(hatch): refresh associative hatch during geometry edits
2026-09-07 18:19:25 -03:00
gianlucafiore b7c2ce2e13 fix(hatch): refresh associative hatch during geometry edits 2026-09-07 18:18:36 -03:00
Hakan Seven 67163de15d Update kernel for reliable wall opening cuts 2026-09-07 20:27:42 +03:00
Hakan Seven ce68999cf9 Set the default Cargo binary 2026-09-07 19:47:38 +03:00
Hakan Seven 6b527cb0e5 Merge remote-tracking branch 'origin/main' 2026-09-07 19:34:47 +03:00
Hakan Seven d35a93c6e8 Avoid reserved MCP session argument 2026-09-07 19:34:31 +03:00
Karim Jerbi d988a09987 feat(render): split mixed polyline bulge arcs into GPU analytical curves and tag benchmark 2026-09-07 17:22:01 +01:00
Hakan Seven 8922a5a25b Merge updated PR #1055 2026-09-07 19:10:55 +03:00
Hakan Seven e51c4d0932 Merge updated origin/main 2026-09-07 19:01:54 +03:00
Hakan Seven 8f2b2586ac Update paper size coverage 2026-09-07 18:58:27 +03:00
Hakan Seven ceaf9ebe5b Merge PR #1055: handle macOS Finder document opens 2026-09-07 18:53:29 +03:00
Felix RiestraandClaude Sonnet 5 a39c7b8f71 macOS launcher: re-assert Accessory policy after handling an event
Handling a document-open Apple Event (a warm "Open With" while the app
is already running) silently promotes this process from
NSApplicationActivationPolicyAccessory to a normal foreground app —
confirmed with lsappinfo, not assumed: its own entry flips from
type="UIElement" to type="Foreground" the instant it handles a second
Apple Event (a cold launch's own first event doesn't trigger this).
That gave the launcher its own, contentless Dock icon sitting next to
the real editor's, both apparently "the same app" to the user but
actually two separate Dock entries.

Re-set Accessory explicitly right after relaying, in the one place
every open (cold fallback, document open, and reopen-while-running)
already funnels through.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-07 17:51:23 +02:00
Hakan Seven d5047b49d9 Merge PR #1053: complete INTERSECT behavior 2026-09-07 18:43:49 +03:00
Hakan Seven 7c2a4d63ff Merge PR #1051: complete SUBTRACT behavior 2026-09-07 18:36:04 +03:00
Hakan Seven 050fbb0a6b Merge PR #1045: complete UNION behavior 2026-09-07 18:36:04 +03:00
Hakan Seven 0a7dfe6066 Merge PR #1044: match visible LOFT options 2026-09-07 18:35:53 +03:00
Hakan Seven 96eeb9c29d Merge PR #1040: inset glyph atlas UVs 2026-09-07 18:35:53 +03:00
Hakan Seven e889864855 Merge PR #1036: add paper sizes 2026-09-07 18:35:44 +03:00
Felix RiestraandClaude Sonnet 5 24e9a7663d macOS launcher: handle reopen while already running with no window
Quitting the real GUI binary (Cmd+Q) leaves the launcher itself still
running — that's by design, so it can keep relaying opens for the
session. But reopening the app afterward (Dock icon, double-clicking
it in Finder again) reactivates that same long-lived launcher process
rather than triggering a fresh cold launch, since Launch Services
still considers the bundle running. Neither of the delegate methods
already handled covered that: applicationDidFinishLaunching: only
fires once, at the launcher's own original cold start, and
application:openURLs: only fires when a document was named. The net
effect: the Dock icon would appear, but no window ever showed.

applicationShouldHandleReopen:hasVisibleWindows: is exactly the
callback AppKit fires for this reactivation case. Reproduced and
verified the fix against the real sequence (cold launch, kill only the
GUI process to simulate Cmd+Q, reopen) rather than assuming it from
the delegate method's docs alone.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-07 17:34:57 +02:00
Karim Jerbi e603ef6104 test(bench): add end-to-end analytical rendering benchmark 2026-09-07 16:14:17 +01:00
Felix RiestraandClaude Sonnet 5 e767507850 macOS: add a launcher shim so a cold Finder open also works (#1039)
The previous commit's Apple Event handler only ever catches an open
sent to an already-running process. A cold double-click (app not
running yet) still failed: every registration point tried inside the
real binary — before app::run(), on the Subscription's first poll,
even the literal first statement of main() — reliably caught a warm
open but never the original cold-launch event. A raw
AEInstallEventHandler in a separate, minimal, non-AppKit process (no
winit/iced, just a manual CFRunLoopRunInMode pump) still missed it
too, ruling out "the real binary's heavy dependency graph loading
before main() wins the race" as the explanation.

What does reliably catch a cold launch, verified against real
Finder-equivalent opens: a from-scratch NSApplication with its own
delegate implementing application:openURLs: — in a process where we
own setDelegate: from the start, with no contention over the single
delegate slot winit already claims. That has to be a separate process
from the real GUI binary (only one -[NSApplication run] call can own a
process), so src/bin/ocs_launcher.rs is now the .app's actual
CFBundleExecutable: a small, dependency-light binary (no iced, wgpu,
or cadkernel) whose only job is to catch the open and relay it.

Relaying by replacing this process's image (exec, same PID) or by
spawning a child and exiting turned out to both be dead ends: Launch
Services tracks "the running instance of this bundle" by the
Application Serial Number it handed out to whichever process it
actually launched — this launcher, not whatever it execs into or
spawns. Once the launcher's own ASN stopped pointing at a live,
listening process, every later open Finder tried to deliver silently
went nowhere (measured with lsappinfo against a real second "Open
With", not assumed). So the launcher now stays running for the app's
session — invisible, via NSApplicationActivationPolicyAccessory, since
it owns no window — and relays every open (cold or warm) over the same
channel a second CLI invocation already uses: io::single_instance.

That relay only worked once single_instance's rendezvous key stopped
being computed from the running executable's own path. That was fine
when one binary always did the job, but the launcher and the real
binary are different executables and independently computed different
ports, so they could never find each other. Anchor the key on the
enclosing .app bundle instead, shared by both.

packaging/build_macos_signed.sh and .github/workflows/release.yml now
build ocs_launcher and place it at Contents/MacOS/OpenCADStudio (the
CFBundleExecutable Info.plist already names), with the real GUI binary
renamed to Contents/MacOS/OpenCADStudio-App alongside it.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-07 16:45:02 +02:00
Felix RiestraandClaude Sonnet 5 b617734d5c macOS: catch a Finder open aimed at the already-running app (#1039)
Double-click / "Open With" never puts the file on argv — Finder always
delivers it as a kAEOpenDocuments Apple Event instead, which nothing in
this app listened for, so it just showed "cannot open files in this
format" even though the file association itself was registered
correctly.

The standard fix, implementing application:openURLs: on this app's own
NSApplicationDelegate, isn't available here: winit already installs
its own delegate to own window/lifecycle events, NSApplication has
exactly one delegate slot, and winit's class doesn't implement the
open-document method.

This bypasses the delegate protocol and registers directly with the
lower-level Apple Event Manager (AEInstallEventHandler) instead — the
same mechanism AppKit's delegate forwarding is itself built on, and
independent of who owns the delegate. Registration happens on the
iced Subscription's first poll rather than in main() before app::run()
— registering earlier gets silently overwritten by winit's own
NSApplication startup, confirmed by testing against a real `open -a`
rather than assumed from Apple's docs.

This handles a file opened while the app is already running. A cold
launch (app not running yet) needs a different mechanism, in the
following commit.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-09-07 16:44:44 +02:00
Karim Jerbi 75ec08dc55 feat(render): GPU analytical curve extraction for multi-arc polylines and blocks 2026-09-07 14:59:50 +01:00
Karim Jerbi 6e01d464e5 fix(workspace): resolve clippy warnings in ocs_plugin_api and ocs_doc_api 2026-09-07 14:52:58 +01:00
Karim Jerbi 822b0afce5 Convert polyline bulge segments to analytical TangentGeom::Arc
- Add bulge_arc_to_tangent helper to convert 2D BulgeArc into a 3D
  TangentGeom::Arc preserving center, plane axes, radius, and CCW sweep angles.
- Replace full TangentGeom::Circle emission with TangentGeom::Arc across
  LwPolyline and Polyline2D paths.
- Enables single-arc polylines to be extracted and rendered as analytical
  GPU arcs with circle.wgsl.
- Add unit test verifying single bulge polyline extracts as analytical GPU
  arc.
2026-09-07 14:48:56 +01:00