Three reviewers over the in-place save, the paste/print/capture
bundle, and the upstream merge: two blockers, six majors — all fixed.
In-place save (blocker + 2 majors):
- A stale retained handle could receive bytes serialized for a
DIFFERENT format (Save As .dwg->.dxf whose re-pick failed wrote DXF
bytes through the .dwg handle) or silently save in place while the
user was told a copy was downloading. The serialized format now
travels to completion (tab-keyed pending map), the write requires a
matching handle extension AND a live tab, and a failed re-pick
drops the old handle before the download fallback.
- Discarding an unsaved tab leaked its handle, and a serialization
still in flight could then overwrite the real file the user just
declined to save. Discard drops the handle; a tab-gone completion
downloads instead of writing.
- Overlapping saves had no guard (native has one): a wasm in-flight
slot refuses a second save with the same localized message, cleared
on every exit path.
Paste/print/capture (blocker + 2 majors):
- pasted_image_name built its timestamp from Date::new(0) — a
CONSTANT name, so every paste overwrote every earlier paste's
image (the registry is last-wins; the first entity then displayed
the second paste's pixels). Real clock, milliseconds, and a
collision-suffixed unique name now, with the pure logic tested.
- The beforeprint snapshot read the canvas outside the frame — blank
on WebGL2 (no preserveDrawingBuffer). The print image is now kept
WARM: refreshed on redraw frames (throttled to 2 s, skipped when
hidden), so it is ready before the browser ever prints.
- A capture op in a hidden tab wedged the ENTIRE automation channel
(frames never fire, the pending op blocked every settle). Intake
now fails fast when hidden, and a 5 s watchdog expires a parked
capture with a clear error.
Merge residue (3 majors):
- The 9 upstream XREF-bind tests lost in resolution are restored
verbatim (the bind feature shipped untested).
- The XREF manager panel reported registry-loaded xrefs as NotFound
on web — stat now consults the registry first (wasm), with a
regression test.
- Snap: our endpoint map gained the closing-segment wraparound pair
(survey-scale closed polylines kept cm-level precision on the last
edge), and the 3D-snap gate no longer suppresses the 2D edge wire
when the F4 system is disabled (solid edges were snappable by
NEITHER system); the setting is mirrored to the scene with
invalidation, and the test that encoded the old bug now pins the
fallback.
Conflicts resolved as unions of behavior wherever possible:
- #1350 (set_properties numeric CAS): kept our stricter
json_values_match (guards u64>f64 precision loss); upstream's
duplicate removed.
- #1351 (web open-from-bytes): our implementation wins (guarded
queue, settle semantics, protocol 2) using upstream's validation
helper so both sides' tests pass.
- #1358/#1359 (Windows printer routing/media): merged both engines —
upstream's route planning, media tables, GDI raster path and driver
options on top of our level-2 enumeration fallback, true-default
resolution, quoted printto params, existence probe, and error
descriptions.
- #1360 (plot-style leniency): decompress_ctb now carries both our
CTBVER1/STBVER1 container tags and structured flag, and upstream's
lenient reader with Windows-1252 handling.
- 3D snapping precedence: upstream's F4 3D osnap system takes
precedence over our #1030 solid edge wires when the mesh cache has
geometry edges; our wires remain the fallback for triangle-only
caches and mesh entities (both test suites pass).
- Fixed upstream-side bugs surfaced by the merge: parametric arc
grips impl/test contradiction, psetupin test path separators, and
our pdf_raster regression where the new dpi argument was threaded
but ignored (GDI 300-DPI would have cached at 150).
- ocs_save repinned to the upstream acadrust rev; docs/command-
reference.md regenerated (the staleness test caught it).
The last high-severity web gap plus a small integration bundle.
In-place save: with the File System Access API (Chromium/Edge), Save
now works like desktop. Plain Save on a tab with a retained handle
writes straight through — no picker, no download, no "(1)" copies.
First save or format change opens showSaveFilePicker AT the confirm
gesture (the worker serialization that follows can outlast the
transient-activation window; retained handles need no activation to
write). Picker cancel cancels the save like desktop; picker or write
failure falls back to the legacy download with a note; Firefox/Safari
keep the download flow untouched (feature-detected). Handles are
keyed by tab id in a thread-local registry (Message must be Send —
JsValue cannot ride in messages or App state); closing a tab drops
its handle; Save As re-picks. Dirty clears only on successful
persistence, same race guard as before.
Image paste: Ctrl+V of a screenshot inserts it as an image reference
— the clipboard reader falls back to image/* items when neither the
entity clipboard nor text has anything (precedence preserved), the
bytes enter the image source registry as pasted-<timestamp>.<ext>,
and attach reuses the IMAGE command's model-creation path verbatim.
Browser print: beforeprint snapshots the WebGL canvas into a print-
only <img> (the capture_canvas technique), so the browser's own print
menu produces the drawing instead of a blank page.
Automation capture on web: the capture op no longer calls the
synchronous GPU-map screenshot (dead on wasm); it waits one presented
frame and reads via capture_canvas — the same workaround the save-
thumbnail path uses — delivering the PNG as a download.
Follow-up to the block-attribute grips picked from #1386.
- Movability no longer includes the invisible flag: an attribute ATTMODE 2
displays is grippable, so the grip the display-aware list shows can be
dragged. The document-less grips() still skips invisible attributes,
since it cannot consult ATTMODE.
- The grip sits on the displayed attribute, which an annotative insert
scales about its insertion point; Scene::apply_grip now unscales the
dragged target the same way before the attribute stores it, so the grip
no longer jumps to twice the drag at scale 2.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Selecting a block showed one grip, at its insertion point, so there was
no handle to pick an attribute up by: an attribute could only be moved
by moving the whole block.
Give the INSERT one grip per attribute, taken from the attribute's own
`Grippable` implementation, at ids `1 + index`. A drag on one moves that
attribute alone, through the same `apply_grip` the attribute already has
(which carries `alignment_point` along, so non-left alignments stay put
relative to the text). Grip 0 keeps translating the block and every
attribute with it (#255).
A constant attribute belongs to the block definition and an invisible one
is not drawn, so neither offers a grip; a locked-position attribute
declines on its own.
FixesHakanSeven12/OpenCADStudio#1259
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TQzYZsXBwPZ5HwBaGitEUV
(cherry picked from commit 7eb966f6a2)
An OTRACK crossing is the meeting of two tracking vectors, but
`OtrackHit` carried only the one a typed distance is measured along, so
the overlay had a single guide to draw. Snapping to the crossing of two
vectors worked and looked like an unexplained point on one line.
Carry the crossed ray out of `otrack_snap` as `OtrackHit::cross`, keep it
on the app beside `otrack_active`, and let the overlay take a list of
guides rather than one. `otrack_active` still drives typed-distance
entry on its own, so distance entry is unchanged.
FixesHakanSeven12/OpenCADStudio#1313
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TQzYZsXBwPZ5HwBaGitEUV
(cherry picked from commit 2f6caacece)
`sample_entity_xy` walks a polyline's exploded segments and drops the
vertex each segment shares with the previous one. A segment whose curve
plane is upright — any non-horizontal run of a 3D polyline — has no XY
curve, so `entity_curve_xy` declines it and the segment samples to
nothing. With nothing accumulated yet, the seam test compared the two
empty ends as equal and then sliced the empty sample from index 1:
panicked at src/modules/draw/modify/trim.rs:3982:42:
range start index 1 out of range for slice of length 0
On the web build that panic unwinds through winit's event-loop runner
and leaves its state borrowed, which is the "RefCell already borrowed"
follow-on in the same report.
Skip an empty sample instead, which leaves the seam handling to the
segments that do have a plan-view shape.
FixesHakanSeven12/OpenCADStudio#1318
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TQzYZsXBwPZ5HwBaGitEUV
(cherry picked from commit ac9a6ce91a)
- resolve_point, set_polyline_vertex and the solver's point_for_marker
treat the marker one past the last vertex of a closed polyline as
vertex 0, the way line_segment already did. Equal (#1366), the axis
pre-alignment (#1365) and the transform pin (#1361) address a picked
segment's end that way, so on the closing segment they either refused
the pick or pinned nothing.
- Page-setup dictionary keys compare case-insensitively: page_setup_save
replaces "site" when "Site" exists instead of adding a second entry
(PSETUPIN counted it as replaced, #1344), delete and rename match the
same way, and a rename that only changes case is allowed.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
The page merged from #1401 described --serve and --mcp as one surface.
Rewritten against src/app/automation.rs, src/app/control/mod.rs and
src/mcp.rs: the --serve readiness banner and --port mode, the two request
styles on that channel and how each treats a waiting command
(command_busy versus replacement), measure being available headlessly
while capture is not, --export writing DWG for every extension but .dxf,
startup dialogs belonging to the editor behind --mcp only, MCP sessions
being shared rather than one editor per connection, the MTEXT editor's
own actions, and the legacy run reply fields (blocked_by, unconsumed)
that only that path returns.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
- The acadrust DXF reader has converted ATTRIB/ATTDEF rotation to radians
since f226110 (in every codec rev OCS pinned from 2026-08-08 on), while
fix_dxf_dimension_rotations still applied to_radians a second time on
load. Only Shape needs the conversion here. Surfaced by #1391's hunk.
- Shape-file text styles are found by their is_shape_file flag instead of
an empty name (also from #1391).
- #1393's add_layer/modify_layer wrote an unresolved linetype name into
the LAYER record; a name the drawing (plus the standard set) cannot
supply is now refused, through one resolve_linetype helper.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Refs #1349. The automation README describes `--mcp` and never mentions
`--serve`, even though every native build accepts it and it is the only channel
that works without a display. It also leaves out the two startup dialogs, the
`document_id` routing, and what `waiting_input` actually means.
`docs/automation/native.md` covers, all measured on a Linux headless machine
with `OpenCADStudio 2026.37+165`:
- choosing between `--mcp` and `--serve`, and what each needs (display,
no-display, `--export` for one-shot conversion);
- the writable `HOME`/`XDG_CONFIG_HOME` requirement, without which
`ocs_sessions` answers `No user configuration directory`;
- the startup dialogs: `AssocPrompt` then `DonationPrompt`, `new` answering
`ok` with no document until `close_modal` clears them, and that the donation
prompt appears once per application version per profile;
- targeting a document: reads and writes follow the active tab, so
`document_id` has to be passed explicitly after `new`;
- feeding a whole line: `waiting_input` means more input is required (not that
nothing happened), `cancel` closes a command, and the `unconsumed` /
`blocked_by` fields added in #1392;
- the interactive `start`/`input` shapes, including that a typed value goes
through `kind: "text"` while a keyword uses `kind: "token"` with its payload
in a field named `text`, and that the `TEXT` content step belongs to the
in-place editor (`text_input` / `text_commit`);
- what only native builds have: `measure`, `ocs_capture` (inline base64, or a
PNG path), and `set_properties` with `collection` and colours as
`{"Index": n}` / `"ByLayer"`;
- the remaining gap: `MTEXT`, `HATCH` and `POLYGON` report `completed` with
`added: 0` on the `--serve` path (#1390).
The README gains one line pointing to the page, placed after the tool list
rather than at the end, so it does not overlap #1394.
Docs only; no code changes.
`run_command_line` feeds the tokens after the command verb to the interactive
command and then terminates it as if Enter were pressed. Two gaps left a
headless caller unable to tell what actually happened.
In-place text editor takes over, remaining tokens were dropped
----------------------------------------------------------------
The `TEXT` content step hands over to the in-place editor, at which point the
command has already ended (`active_cmd` is `None`). The feed loop stopped
there, so the rest of the line — including the text itself — vanished:
`TEXT 0,0 5 0 hello` reported `completed` with `added: 0` and created nothing.
The tail is now typed into that editor and committed, and the commit is done
synchronously so its result is observable: `text_inline_commit()` returning
false leaves the tokens in `unconsumed` instead of reporting a successful line.
Dispatch the commit as a queued message instead and a failed commit would look
exactly like a good one — the text dropped, `added: 0`, `unconsumed: []`.
Only an editor this line opens is taken over, and after a successful commit the
line is closed with an escape: `TEXT` re-arms for the next line
(`open_next_line`), which would otherwise let the *next* batch line be eaten as
the next text position.
Leftover tokens are dropped
---------------------------
Any token no prompt ever asked for is recorded in `CommandLine::unconsumed` and
surfaced by the automation `run` response, so over-typed or misplaced input is
visible instead of silently ignored: `CIRCLE 0,0 5 9` reports
`"unconsumed": ["9"]` while a fully consumed line reports an empty list.
Pending surfaces are reported instead of `completed`
----------------------------------------------------
`run` now carries `blocked_by` — `"command"`, `"text_editor"`,
`"mtext_editor"`, `"modal:<Kind>"`, or `null` — and answers `waiting_input`
when something is still open rather than claiming `completed`.
`blocked_by` is attributed to the line that opened the surface: a condition and
its snapshot are taken before the command runs, so an editor left open by an
earlier line, another tab, or a startup dialog does not turn a finished command
into `waiting_input`. Without that attribution a caller polling for `completed`
would wait forever on someone else's editor. `docs/automation` documented
`waiting_input` as "more input is required", which is exactly what a pending
surface is; the two new fields are documented there as well.
Tests
-----
* `batch_text_line_creates_text_and_consumes_every_token` — the regression case
creates exactly one `Text` entity and leaves nothing unconsumed.
* `multi_word_text_keeps_the_spaces` — asserts the committed string, so an
off-by-one in the consumed index or a wrong join cannot pass.
* `run_reports_leftover_tokens_instead_of_dropping_them`.
* `text_without_content_reports_the_open_editor`.
* `a_stray_editor_is_not_hijacked_by_the_next_line` — the next line neither
types into the stray editor nor inherits its blocker (`blocked_by: null`,
`status: completed`).
* `a_command_still_waiting_is_named_as_the_blocker`.
Verified over the `--serve` protocol: `TEXT 0,0 5 0 hello world` → `completed`,
`added: 1`, and the entity really holds `hello world`; `TEXT 40,0 5 0` →
`waiting_input` + `blocked_by: "text_editor"`; the following `CIRCLE 0,0 5 9` →
`completed`, `blocked_by: null`, `unconsumed: ["9"]`; `LINE` → `waiting_input`
+ `blocked_by: "command"`.
Full lib suite: 1497 passed, 2 failed — both failures
(`io::plot_style::…named_tables_and_missing_files_get_a_reason`,
`scene::parametric_constraints::…arc_grips_drive_center_start_and_end_but_not_midpoint`)
reproduce on the unmodified tree; run with `LC_ALL=C` since several xref tests
otherwise fail on a non-English locale.
Known remaining gap, unchanged here: `MTEXT` / `HATCH` still answer `completed`
with `added: 0` in this path (they need interactive surfaces the feeder has no
tokens to fill). Reported separately in #1390.
view_window() rebuilt the 256-bucket ACI layer-usage table inline on every
modal view: unconditional 256-Vec allocation plus sort_by_key (lowercase key
recomputed per comparison). Extract pure build_layer_usage(), build it only
when the CTB path needs it (STB path now allocates nothing), and sort with
sort_by_cached_key while skipping trivial buckets.
Tests:
- build_layer_usage_buckets_index_colors: ACI bucketing + case-insensitive order
- build_layer_usage_ignores_non_index_colors: truecolor layers excluded
Bench (cargo bench ui_plotstyle_layer_usage, 200 layers): ~13us median, PASS.
A/B vs pre-change inline code: ~15.7us -> ~12-13us (~22% faster).
CmdResult::Dispatch and CmdResult::Relaunch end the active command and run the
assembled line through dispatch_command, then discarded the Task it returned
(`let _ =`). A command whose effect is a message carried by that Task was
announced but never applied. VSCURRENT's keyword picker is one: choosing
"Gouraud Shaded" printed "Visual style: Gouraud Shaded." and left the
drawing in wireframe, because SetRenderMode travels in that Task.
Hold the Task and batch it with the focus task that apply_cmd_result_inner
returns. A regression test drives both results through VSCURRENT and checks the
tab's render mode.
acadrust follows hakanaktt/acadrust main through #107, entity-record
passthrough included; cadkernel pins the same codec rev so one acadrust
stays in the graph. Upstream added BlockFlags::is_xref_unloaded, which
XATTACH now sets to false for a freshly attached reference.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Describe the two JavaScript functions the web build exposes
(ocs_control_submit / ocs_control_take), how to poll them, the queue and reply
limits, opening a drawing from bytes with data_base64, and closing the startup
dialog before new. Everything is taken from src/app/control/mod.rs,
src/app/startup.rs and docs/automation/web_save_smoke.cjs.
- Add LayerConfig struct and add_layer / modify_layer methods to HostApi trait
- Implement IPC request/response serialization for AddLayer and ModifyLayer
- Implement HostSession methods with CAD property defaults and selective modification
- Refresh layer panel UI and invalidate scene layer dependencies upon layer changes
- Add unit tests for add_layer with defaults and partial modify_layer
- tests/fixtures/plot/.page-setups-metric.dxf.ocs.lock was the app's own
single-instance lock file, committed by accident in #1343; remove it and
ignore *.ocs.lock.
- load_plotsettings_into_dialog: #1343's refresh_custom_scale_fields borrowed
self while #1360 still wrote through the plot_dialog borrow; write the
style error first.
- The wasm list_printers stub returns the Result #1358 gave the native one.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
codesign -d --entitlements - on current macOS runner images prefixes the
plist with text headers, so the sandbox entitlement assertion died with
plistlib InvalidFileException and took the .dmg build down with it.
--xml prints the bare plist on both old and new codesign builds.
A persisted ISODRAFT flag turned every drawing's crosshair into the
isoplane pair (one vertical, one diagonal arm) on every launch, with no
visible control to switch it back off — F5 (ISOPLANE) only cycles the
plane. Drop the flag when saving and ignore it when an old settings
file still carries it; turning it on stays a per-session choice.
Opening a drawing that another program or machine holds without read
sharing used to fail with a raw OS error, and the memory-mapped fast
path could kill the process outright: a mapped file that shrinks
mid-parse (a writer saving over it) or a cloud placeholder that cannot
page in raises an in-page exception no Rust code can catch.
Read the DWG through a guarded fast path instead: mmap when possible,
and fall back to one in-memory snapshot read — opened with the most
permissive share mode Windows offers (FILE_SHARE_READ | WRITE |
DELETE) — whenever the map fails or an I/O error surfaces mid-parse.
A snapshot can never fault: a concurrent writer just ends the read
early. Sharing violations (os error 32/33) now answer with a message
that names the file and the remedy, and OneDrive-style placeholder
attributes skip mmap entirely.
Top Front Right/Left kept Y (north) as up when starting from Top,
tilting the base because orbit is turntable (never banks). Make
snap_to_direction deterministic: world +Z horizon except near +-Z
where north +Y is used, sign-preserved only for intentionally
inverted views. Expand opposite() from faces-only to all 26 regions
so already-there flip works for edges/corners.