Enable Point in Authoring Palettes and BPARAMETER. Pick parameter and label
positions, then create the native Point, XY grip and position expressions in
one undo transaction. Draw editor-only markers and clear placement previews.
Share graph append construction with Visibility so both types coexist in
either order. Allocate node, expression and edge IDs independently; preserve
existing objects, nodes and relationships and reject malformed/opaque graphs.
Reframe parameter, label and grip with the edited definition. Include authoring
positions in editor framing while keeping reference geometry extents unchanged.
Retain save/discard and parent saved-edit undo. Action association/evaluation
and parameter property editing remain separate milestones.
Validation: 10 targeted, 286 block and 60 dock checks passed (overlapping
filters). Appending a Point to an existing native graph passed DWG/DXF
roundtrips in all 17 local fixtures; old objects and original checksums stayed
unchanged. Real-view palette interaction and rendered marker/label verified.
Add the current-state selector, Visibility States dialog and mode/show/hide
controls to the contextual ribbon, using shared icons, tooltips, modal UI
and adaptive flyouts. Disable the controls until a Visibility parameter exists.
Stage state creation, rename, deletion, current-state changes and reordering
until OK. Support copied, show-all and hide-all new states in one undo step,
preserve native graph metadata, and restore current-state identity on undo/redo.
Implement nonsaved BVMODE and display-only reveal during BVSHOW selection.
Cancelling selection leaves saved visibility flags and history unchanged.
Keep the existing command-line state workflows available.
Validation: 6 targeted tests; 277 block, 31 app dock, 35 ribbon and 12 history
checks passed (overlapping filters). All 17 local DWG fixtures passed in-memory
DWG/DXF preservation checks with unchanged original checksums.
Milestone 1: enable Parameters > Visibility and add interactive
BPARAMETER VISIBILITY placement using the existing native visibility
parameter, grip and state engine. Bare BPARAMETER currently offers only
Visibility; other parameter, action and set entries remain unavailable.
Show the placed parameter in the editor and support cancel, local undo/
redo, save/discard and saved-edit undo. Bind placement to its document
and definition, reject duplicates and invalid points, and protect existing
authoring graphs from replacement.
Keep visibility parameters and their grips aligned with geometry when a
BEDIT UCS change reframes the definition, including parameter-only blocks.
Validation: 9 targeted tests, 272 block checks and 58 dock checks passed
(overlapping groups). All 17 local DWG fixtures passed in-memory DWG/DXF
placement roundtrips and graph-preservation checks; originals unchanged.
Add a dockable Block Editor palette with selectable Parameters, Actions,
Parameter Sets and Constraints pages, reusing shared dock controls, icons
and ribbon-style hover bubbles.
Parameters, Actions, Parameter Sets and Block Properties Table are UI
catalogs only. Their entries remain disabled; this commit does not add
parameter placement, dynamic action behavior, parameter-set authoring or
block properties table functionality.
The Constraints page connects its geometric and dimensional entries to
existing ribbon commands. It reuses their implementations and adds no new
constraint behavior.
Keep the palette specific to BEDIT and retain its placement and selected
page across closing/reopening and Test Block. Opening, closing, switching
pages and clicking disabled entries preserve the document, undo history
and any running command.
Validation: 7 palette tests, 57 dock checks and 46 ribbon checks passed
(overlapping groups); rendered pages inspected and native debug build passed.
Add the no-selection Block section with annotation, orientation, scaling, exploding, units, description and hyperlink controls. Integrate definition edits with editor history, save checkpoints and discard, and keep staged text tied to its definition.
Persist annotation and hyperlink metadata on the block record, migrate legacy marker values on edit, and honor orientation and hyperlink descriptions during block creation. Cover native DWG/DXF roundtrips and replacement after reopening.
Avoid rebuilding derived caches and clearing tessellation memos when closing the block editor on clean exit or discard. Scope entity delta computation to block record members instead of scanning and cloning the entire drawing, and add a fast path to the dirty check when no local commands were executed.
The union precompute assigned each shared object to whichever copied
tree reached it last while descending only from the first reacher, so
a root ref'd by a later-sorted sibling lost its owner and the true
owner's clone pass skipped its own root. Seed every root's ownership
up front and make the walk first-wins with a claimed set, so ownership
stays consistent with the per-entity clone passes in any selection
order. Also wrap the new tests to rustfmt and drop product names from
added comments.
A multi-entity COPY cloned each entity's extension-dictionary tree
independently, so refs crossing between the copied trees (root to
sibling root, nested sibling objects, entity handles inside XRecords)
stayed pointed at the sources. Precompute the union closure of every
copied tree, preallocate authoritative clone handles, skip re-cloning
sibling-owned objects during each per-entity pass, and rewrite
entity-valued refs through the completed copy map afterwards. Trees
outside the copy set are deep-cloned inline, matching the reference
product, which clones an out-of-set dictionary target rather than
keeping it verbatim.
Open the Edit Block Definition dialog when BEDIT is invoked without an active selection, listing candidate block definitions alphabetically while filtering layout records, anonymous blocks, and external references. Allow typing a new valid block name to start an empty block editor session that only commits to the drawing block table upon explicit save.
Preview the current unsaved block in a disposable drawing with independent history. Keep normal Draw tools available, append Close Test Block, and return to the original editor without saving. Guard persistence paths and handle refresh and parent-close lifecycle by stable document identity.
Synchronize the native parametric graph only when replay changes constraints or named parameters. Geometry and visibility history preserve existing imported network identities and ownership. Add a regression for a rejected block copy followed by local undo and redo.
Create a new named definition from current session edits, restore the source checkpoint, and continue editing the copy with fresh local history. Add the name dialog and Save dropdown, clone supported owned metadata onto independent handles, and refuse unsupported relationships atomically.
A copied entity used to keep its source's extension-dictionary
handle, so the copy and source shared one tree and edits crossed
between them. In-drawing copy now clones the whole tree onto fresh
handles: dictionary entries, owners, reactors, nested xdictionary
links, and XRecord handle references that land inside the tree are
remapped; references leaving it stay pointed at the originals. A
dangling or missing root leaves the copy without an extension
dictionary rather than sharing. Annotative context trees and the
paste/export clone path are unchanged. Undo stays on the full
snapshot path when a tree is cloned.
- Add root CHANGELOG.md as master index with executive release summaries
- Add dedicated changelogs for last 4 releases (v2026.38, v2026.39, v2026.40/40.1 unified, v2026.41) and unreleased
Delegate INSERT and MINSERT explosion to the document-aware codec implementation, removing duplicate attribute and base-point handling. Update workspace, kernel and graph pins together and cover rotated arrays with a nonzero base.
Introduce would_create_cycle in block_service to prevent recursive block definitions with case-insensitive matching across block records. Enforce cycle refusal at INSERT commit in BEDIT and during block redefinition (redefine_block_raw) with a shared error message. Ensure render-time recursion truncates safely with a one-time session warning on cyclic documents.
Enforce constant attribute flag in ATTEDIT dialog by presenting constant attributes as disabled and ignoring modifications. When exploding an insert, restore block-definition attribute definitions as transformed ATTDEF entities while dropping instance attribute values.
Expose creation fields, constraints and examples for the 11 supported types without expanding startup tool schemas. Share the creation contract and constructor with execution to keep accepted inputs consistent.
Validation: MCP, contract/factory and entity creation regression tests.
add_entity_clone reset handle, owner and mode but kept the source extension dictionary handle, so a copy claimed a dictionary its document does not own. The clone now gets no extension dictionary, like baked and transformed-block children. Failing-first test included.
Add opt-in response_detail=summary with step and change counts, compact state, warnings, and actionable errors or prompts. Return an ocs_read batch_result request for full cached details without resuming mutations.
Keep compact/full behavior and idempotent batch retries intact. Preserve active-step context and report unavailable cached results explicitly. Document cache scope and retention; update schemas and digest.
Validation: 47 MCP unit tests, live stdio smoke test, rustfmt, and diff checks passed.
Share operation definitions between individual requests and batch steps, reducing the execute request schema by 39%. Place definitions at the tool input schema root so local references resolve correctly.
Update the schema digest and smoke assertions. Verified with 45 MCP tests, the live stdio smoke test, and 228 schema validation comparisons.
new src/scene/block_service.rs (references, delete, rename, purge candidates, name validation, one error type and message). The command-line RENAME, palette Remove, plugin API, control/MCP block_delete and PURGE all use it. Layout, xref, dynamic-block, anonymous and open-in-editor records are protected; PURGE removes an anonymous block only when nothing references it. PROTOCOL CHANGE: control/MCP block_delete refuses a referenced block unless force=true (erases referencing INSERTs in one undo step). New REST route DELETE /api/v1/blocks/{name}?force=true. MCP tool digest re-pinned. Undo labels are Delete block X, Rename block X to Y and Purge.
world = insert_point + Rot * Scale * (P - base_point). Attribute placement, EXPLODE, XCLIP storage, REFEDIT's working copy, the UCS reframe of attributes and the dynamic-block visibility grip ignored the base point and were offset by Rot*Scale*base. They now share insert_transform_with_base. Creation stores a ZERO base and redefine resets it; a no-op redefine moves nothing; the Block Definition dialog prefills the base from the definition. XCLIP filters stored by older builds keep their old displacement.
bake_dimension_blocks copied the dimension's whole EntityCommon, including its extension dictionary handle, into every baked child, so many entities claimed one dictionary and file validation reported an error per entity. Baked children now get no extension dictionary. The same defect is fixed in define_transformed_block and the cross-document paste clone. Each has a test.
style_new/copy/delete funneled through a load_style_bufs no-op for Table/MLine, leaving stale editor buffers pointed at the fresh style; both arms now route to the existing loaders. Table cell text-style typos persisted as dangling names; unknown names are now rejected with command-line feedback, keeping the buffer text for correction. Silent numeric parse-drops deliberately untouched (codebase-wide keep-old-valid norm). Tests: table_new_reloads_cell_buffers plus table_cell_unknown_text_style_is_rejected incl. retention assert (RED each verified by temporary revert). Full suite: 2048 passed / 0 failed / 25 ignored, zero warnings.
Tests previously ran on Ubuntu only, so Windows- and macOS-specific breakage went unseen. Add a 3-OS test matrix and a separate lint job for rustfmt and clippy. Lint is continue-on-error because the tree is not yet fmt-clean or clippy-clean.
Also add BEDIT characterization tests that pin current session, undo and refusal behavior ahead of the editor refactor. Locked tests guard behavior that must survive; documenting tests record questionable behavior expected to change.
The edge-stack closure duplicated dock_panel_visible arm-for-arm; it now delegates (tab is provably the active tab at that site). Ribbon.layer_names was written but never read outside one test; deleted, test reads via layer_infos. Faces and combo-refresh sub-items investigated and dropped (already unified upstream; no live stale path). Tests: dock visibility parity pins incl. Start-tab. Full suite: 2046 passed / 0 failed / 25 ignored, zero warnings.
XrefManagerSelect/RightClick/RowOp/PathPick/FindReplace carried positional display indices that rescans could retarget. New XrefRowId{key,saved_path} (the identity refresh uses) in all 5 variants, resolved via row_index at dispatch with graceful no-op; panel selection stays index-based. display.index remains for view-time styling/lookup only. Tests: xref_rowop_unknown_id_is_noop (RED first); existing dialog/simulator/click tests migrated to ids. Full suite: 2044 passed / 0 failed / 25 ignored.
AnnoObjectScaleToggle had three silent no-op paths (no target, locked layer, unknown scale); each now pushes command-line info mirroring the open-path guards. Xref empty-state hardcoded grey now uses muted_style. DD_BOUNDS eviction and attr-clamp changes investigated and deliberately dropped (no reachable defect in either). Tests: anno_scale_toggle_tests cover all three paths (RED: silence). Full suite: 2032 passed / 0 failed / 25 ignored, zero warnings.
editor_layer_row and view_editor took Vec<String>, cloning N strings per row per frame (times two build-closure runs). Both take Arc<[String]> now; a plain borrow cannot cross the twice-run closure (E0373), hence the Arc. Cow empty-filter short-circuit for both search queries. Test: view_editor_accepts_shared_linetypes (RED first). Full suite: 2029 passed / 0 failed / 25 ignored, zero warnings.
VP-freeze columns used the global-freeze glyph; shift-ranges swept filtered-out rows into bulk ops. Glyph swapped to layer_vp_freeze (ribbon precedent); new visible_indices helper shared by the view loop and the shift arm. Test: shift_select_skips_filtered_out_rows (RED: hidden row in range). Full suite: 2028 passed / 0 failed / 25 ignored.
The pill number used the first-content fallback while the label and sync badge used explicit-only resolution, showing e.g. a 1:50 number beside a 1:1 label with no badge. Label and badge now resolve through target_viewport_handle like the number; render-path handle fn, set-paths and Model branches untouched. Tests: scale_pill_sources_agree_without_targeted_viewport (RED: live probe Some(0.02)/1:1/None) plus Model-unchanged pin. Full suite: 2027 passed / 0 failed / 25 ignored.
props_asym_scale was app-global keyed by raw handle values, but fresh documents restart numbering at seed 1, leaking one tab's unchecked Uniform box into another tab reusing the value. The set moves to DocumentTab; all uses tab-scoped. Also removes a dead destructure left in overlay.rs. Test: asym_scale_opt_out_does_not_leak_across_tabs with handle-reuse premise assert (RED first). Full suite: 2025 passed / 0 failed / 25 ignored, zero warnings.
draw_axes projected with raw project_point3, so a behind-camera axis endpoint wrapped to a finite mirrored point (proven: clip.w=-100 projected to screen centre). Extracted shared project_point with the eye-plane guard (grid_segments delegates verbatim) and axes now skip when an end is unprojectable plus clip_seg. Legacy canvas offset preserved pixel-identically; remaining raw project_point3 sites elsewhere are out of scope. Tests: project_point_rejects_behind_eye plus axes_segments geometry pins. Full suite: 2024 passed / 0 failed / 25 ignored.
LayerPanel.editing and color_picker_row were positional indices, so a sort/sync/filter between start and commit retargeted renames and swatches. Both are Option<String> now; commit resolves fresh with graceful no-op on deleted layers. Existing rename tests migrated. Tests: layer_rename_commit_follows_name_across_resort (RED first) plus deleted/empty no-op and picker-resort pins. Full suite: 2019 passed / 0 failed / 25 ignored.
rename_style_storage and rename_symbol rewrote entities and table rows but left dimtxsty stale, so the next Apply resolved a NULL handle. The follow now lives in both shared functions; the plugin workaround is removed as redundant. Tests: text_rename_follows_dimtxsty drives the manager path (RED first); pre-existing plugin rename test stays green via the shared path. Full suite: 2015 passed / 0 failed / 25 ignored.
StyleKey::TableStyle only updated the ribbon mirror, so inserts kept using the header value and resync re-seeded from the mirror. The arm now writes current_table_style_name plus dirty when the style exists, and sync_ribbon_styles seeds from the document like Text/Dim. Tests: tablestyle_ribbon_pick_writes_header_and_dirties plus pick_flows_into_table_insert covering pick-to-dialog (both RED first). Full suite: 1996 passed / 0 failed / 25 ignored.
- Add Section 19 benchmarks covering camera pan viewport uniform updates,
spatial hover candidate search, and viewport data assembly scaling
- Add navigation telemetry probes in src/perf.rs (active only when
PERF=1) to track frame interval, batched input moves, and layout-draw gap
underlay_resolution_stale() was requesting continuous redraw frames at
display refresh rate even when no raster underlays were present in the
drawing. Cache has_underlays conservatively on Scene and early-return
false when no underlays exist, eliminating the idle tick leak.
LayerTransparencyEdit applied without begin/commit_layer_undo, so the edit was permanent while every sibling arm is undoable. Mirrors the color arm inside the non-empty guard. Test: layer_transparency_edit_pushes_undo asserts the entry plus undo-restores (RED first). Full suite: 1991 passed / 0 failed / 25 ignored.
DimensionPreview drew identical generic triangles regardless of dimblk/dimblk1/dimblk2. It now resolves each end through the shared arrow_from_block_name mapping (widened to pub(crate)) and draws per-end glyphs, keeping DIMTSZ tick precedence. Tests: arrow_name_mapping_covers_preview_kinds pins the shared contract; 2 preview_arrow_tests pin per-end resolution (RED first). Full suite: 1989 passed / 0 failed / 25 ignored.
DsSetHandle for the first arrowhead also overwrote the second, destroying split-arrowhead configurations. The second write was a drive-by from overhaul 25515fa3; the Dimsah toggle has its own null-guarded init. Test: dimblk1_pick_leaves_dimblk2_alone pins per-end independence (RED first). Full suite: 1989 passed / 0 failed / 25 ignored.
SelectionState now holds view, gesture, input, menu and orbit sub-structs; all 450 access sites updated mechanically with no behavior change. Deletes three write-only dead fields (box_last, box_last_crossing, poly_last_crossing). Hand-rolled clears in commands and dim_viewport route through the new clear methods; bench fixture updated. Full suite: 1976 passed / 0 failed / 25 ignored.
The two per-frame ViewCube blocks rebuilt the named-UCS name list (N String clones plus a Vec) every frame; now Scene::cached_ucs_names memoises it behind ucs_epoch and the picker takes the Arc, so frames cost a refcount bump. Bumped on UCS SAVE, UCS DELETE, RENAME UCS, record-API ucss patch, file-open, and undo Full-swap. Tests: cached_ucs_names_returns_same_arc_until_bump pins the hit path; cached_ucs_names_invalidated_by_bump pins rebuild with new content. Full suite: 1976 passed / 0 failed / 25 ignored.
Closes the view-to-dispatch resort race: the nine Layer* variants plus ColorPickTarget::Layer now carry the layer name and resolve to the current panel row at dispatch time via layer_panel_index; unknown names no-op. View sites (layer panel, ribbon dropdown) and the CLI/plugin path send names, so the CLI position lookup and the ribbon enumerate are deleted. Panel-internal selection state stays index-based. Tests: layer_toggle_visible_targets_layer_by_name_after_resort pins name targeting across a re-sort; layer_toggle_visible_unknown_name_is_noop pins the graceful no-op. Full suite: 1974 passed / 0 failed / 25 ignored.
Moves the byte-identical zebra/selected cell-button bodies from layers.rs and xref_manager.rs plus the layers header-button body into one shared module. All six call sites now use the shared helpers; toolbar_btn helpers untouched (different semantics). No new tests per owner request (pure move, verified by build plus full suite before test removal).
The before-manifest was taken pre-bake while save mints *D geometry on its clone, so every new dimension failed verification 1-vs-7. Compute before on a bake-simulated clone: baked-vs-baked proves roundtrip stability (bake correctness itself is pinned by the explode geometry tests). Pinned by save_verified_accepts_new_dimension_with_baked_block.
The DXF reader keeps marker identities on the block record but never materializes the entities, so block_entity_handle/block_end_handle dangle after every DXF load (reopened baked *D dimension blocks hit this every time). Re-create missing sentinels at load finalize, reusing the file handle when free and minting otherwise. Idempotent and DWG-safe: loads whose markers already resolve are untouched. Pinned by dimlinear_survives_save_reload_with_baked_geometry.
resources/* vary as sessions open and snapshots land, so an hour of advertised freshness was optimistic. protocol_result now takes the TTL value: tools/list and discovery keep 3600000, resources/list/read/templates get 60000, on every protocol version. No tool surface change; digest untouched.
The GUI requires request_id and document_id on every non-query op, but the bridge sent neither on capture: every ocs_capture failed with request_id_required, then document_required. request() now always mints the GUI request id for capture (a client-supplied id wins and is echoed in metadata), and capture carries the active document. The op=capture read path answers guidance pointing at ocs_capture instead of a missing-path failure. Live-proven against the open window: plain and annotated captures return image content.
The cached PID snapshot predates a GUI spawn, so the newborn pid missed from it read dead and the bridge deleted its just-written descriptor inside the launching call itself: every fast launch reported still-starting forever while a healthy window sat undiscoverable. Two guards: the snapshot cache is invalidated right after spawning, and deletion now waits out a 120s file-age grace (unknown age counts as newborn). Slow starters that write late were the only ones surviving before, which is why this was intermittent. Covered by stale_snapshot_may_delete predicate tests.
Runs the official MCP Inspector against a fresh release build on every push and PR: initialize purity, four titled tools, headless resources/list, both resource templates. Headless-safe methods only; drawing-dependent coverage stays in unit tests and the pinned digest. Server addressed via config file (positional CLI targets break on Windows shells); TTL assertions stay at unit level (the CLI normalizes result envelopes on output).
Short interactive waits returned a bogus 10-minute timeout on first expiry, killing the re-poll pattern and blocking task creation. The dismiss branch now fires only when the requested wait actually reaches the ceiling; shorter waits return running as before. Predicate covered by interactive_timeout asserts.
tasks/cancel marks the task so polls report terminal cancelled with neither result nor error; late completions are discarded. Task envelope errors move from grandfathered -32000 to -32603. Agent schema sync on bridge start is now skippable via OCS_SKIP_SCHEMA_SYNC and logs one line when it writes.
resources/templates/list serves the two genuine parameterized URIs (pyramid tiles, snapshots) with TTL hints instead of -32601. Tool titles move top-level per the 2026-07-28 Tool shape (dropped from annotations); annotation keys are allowlist-tested. resources/list ids are sorted so client and prompt caches hit. Tool schema digest rolls to fbba987a; pin updated deliberately after reviewing the titles-only surface diff.
How to iterate the bridge: fast builds, digest pin, session model, discovery, launch serialization, cancellation, error routing, safety model, rebuild swaps, conformance gates. No diary entries, no harness names, no build-specific data.
run() reads stdin on a feeder thread into a channel (CancelPump); wait loops harvest lines in 50ms quanta instead of sleeping blind. A notifications/cancelled naming the in-flight MCP id dismisses the GUI prompt with a second cancel exchange, drops the late result, and sends nothing back. Cancels arriving between polls resolve via the inflight map; re-polls for dismissed ops answer cancelled without touching the GUI. Interactive picks (user_select/getpoint) get a 10-minute single-call ceiling ending in isError timeout text; other ops keep the 60s clamp. Ping-during-block stays unanswered while one exchange occupies the loop (bounded residual).
Unknown tools become -32602 Protocol Errors carrying the four valid names (SEP-1303 keeps them there; the isError shape hid them from strict clients). Bridge/GUI infrastructure failures (no sessions, dead TCP, unwindable startup) become -32603 via the CallError split; tool-domain validation stays isError so the model keeps self-correcting. Verified observable in a live client session: bogus session surfaces readable -32603 naming ocs_sessions, model recovers.
resources/read reports -32602 on the modern path (2026-07-28 forbids emitting -32002) and keeps -32002 on legacy, where it was the code. Non-object top-level input (batches included - no batch semantics defined) gets -32600 Invalid Request instead of silence, so senders never hang. Id-less objects stay silent (notifications). Ping arm deliberately untouched: the method was removed only on the modern path, and legacy health checks may rely on it.
serverInfo carries only name/title/version so strict clients never reject the handshake. Build extras (build_rev, build_profile, tool_schema) live canonically in bridge_identity, surfaced via capabilities.experimental and the in-band bridge stamp - no copies to drift. Adds conformance guards: spec-pure initialize with unknown-version fallback, and JSON-RPC errors for unknown methods, missing tool names and missing resource URIs.
Global MCP instructions don't reliably reach agents (fresh sessions never announced the build). The directive now lives in the ocs_sessions description every agent reads first, pointing at the bridge object. Tool schema digest rolls to ede58332; pin updated deliberately after reviewing the description-only diff.
List results (tools/list, resources/list, resources/read) carry ttlMs and cacheScope on the legacy 2025-11-25 path too; legacy clients ignore unknown fields per the SEP's backward-compat rule. The modern envelope (resultType, _meta.serverInfo) stays negotiated. Legacy-shape pin updated deliberately to assert presence.
Discovery skips pid-verified dead sessions and deletes stale descriptors; probes are bounded so dead sessions delay discovery by at most one timeout. The bridge exits after the in-flight request once it detects a rebuild, and error results omit structuredContent so strict clients surface real errors. Concurrent launches are serialized with automation/starting.lock: the first caller spawns the GUI, later callers wait for the same descriptor instead of opening another window. Session states and hello/capabilities responses now carry the bridge build identity (version, build_rev, tool_schema), and the agent instructions point the build announcement at it.
Real BT/Tj text objects ride alongside the vector outlines (Tr 3,
zero visual change): runs preserved at layout time, TTF styles embedded
as allsorts subsets with ToUnicode, width-matched Tm, clip-culled.
WireModel literals moved to ..Default::default() tails.
- Pillar 1 (Runtime Auto-Sync):
* Add sync_agent_tool_schemas() in src/mcp.rs to atomically export live tool schemas & instructions to ~/.gemini/antigravity/mcp/opencadstudio/
* Automatically invoke sync on MCP startup (mcp::run()) and GUI automation startup (transport::listen())
* Add --sync-mcp-schemas CLI flag in src/cli.rs and src/main.rs
- Pillar 2 (Development Hook):
* Strengthen export_agent_tool_schemas test in src/mcp.rs to assert all schema files and instructions.md exist and are in sync on every cargo test run
- Pillar 3 (In-Band Dynamic Introspection):
* Add 'tools' to READ_OPS in src/mcp.rs and query match in src/app/control/mod.rs
* Enable agents to dynamically query live tool definitions and instructions via ocs_read (op: 'tools') over MCP or raw socket
- Pillar 4 (Non-Blocking Validation):
* Set additionalProperties: true on ocs_capture schema in src/mcp.rs to prevent rigid schema rejection of emerging options
- Add \PyramidLevelInfo\, \PyramidManifest\, \compute_pyramid_manifest\, \compute_tile_bounds\, and \point_to_tile\ in \ision.rs\
- Expose DeepZoom pyramid manifest resource at \cad://session/{session_id}/pyramid/manifest.json\
- Support on-demand and cached pyramidal tile resources at \cad://session/{session_id}/tile/{level}/{x}/{y}.png\ and \.json\
- Add \ ile: { level, x, y }\ and \pyramid_manifest: boolean\ to \ocs_capture\ with regional camera framing
- Enable LLM agents to inspect dense CAD drawings via multiscale resolution levels without exceeding context/tile token budgets
- Add unit tests for pyramid tiling math, manifest generation, resource listing/reading, and structured metadata formatting
- Standardize spatial metadata under \_spatial\ with \CAD_WCS\ CRS, document units, forward/inverse affine matrices (\pixel_to_world_matrix\, \world_to_pixel_matrix\), camera projection, and world bounding bounds
- Implement visual differential engine (\compute_visual_diff\) detecting pixel-level changes between viewport frames
- Map dirty pixel bounding box directly to CAD world coordinates (\dirty_world_bounds\) using the spatial transform
- Add \diff\, \diff_mode\ ('highlight' | 'crop' | 'metadata_only'), and \
eset_diff_baseline\ parameters to \ocs_capture\
- Support high-efficiency dirty region cropping to drastically minimize vision token and bandwidth consumption
- Add unit tests for spatial projection, diff computation, tool schema, and structured metadata formatting
- Expose MCP resources/list and resources/read for active viewports, drawing state, and cached snapshots
- Introduce ResourceStore with SHA-256 content-addressed URIs (cad://session/{session_id}/snapshot/{hash}.png)
- Add delivery parameter (inline, resource, both) to ocs_capture tool schema
- Return standard MCP EmbeddedResource content block when delivery: 'resource' to avoid base64 context bloat
- Add comprehensive MCP unit tests for resource listing, blob reading, and tool result delivery modes
- Add Set-of-Marks (SoM) visual grounding (src/app/control/vision.rs):
* Render high-contrast numbered badges on entity visual centroids in viewport captures
* Project 3D entity bounds to 2D image coordinates with priority ranking (selected/size)
* Return bidirectional grounding manifest (annotations / visible_entities) in capture and ocs_capture payloads mapping each badge to entity handle, layer, type, and world center
- Add regional viewport framing (src/app/control/mod.rs, src/mcp.rs):
* Support view: region and bounds: [min_x, min_y, max_x, max_y] in capture and ocs_capture
* Enable programmatic zoom and high-resolution inspection of specific rooms or local geometry
- Add 2-point raster image calibration (src/app/control/actions.rs, src/mcp_ops.rs):
* Extend embed_image with optional source_points (pixel coords) and target_points (CAD coords)
* Automatically calculate scale, world dimensions, and insertion origin at
* Add unit test embed_image_calibrates_from_source_and_target_points
Bug:
SETVAR LUPREC and SETVAR AUPREC stored any parseable i16 verbatim, and
the header value flows through UnitContext into the per-call
formatters, which applied only a lower bound (.max(0)) with no upper
bound: format_length and format_angle rendered {:.*} with the raw
precision. SETVAR LUPREC 30000 therefore made format_length emit
30003-character strings on every properties and annotation formatting
call - a ~30 KB allocation per formatted value - while format_area in
the same file had long since capped itself with .min(15) and the units
dialog only ever offers 0-8 places. Negative values were also stored
verbatim.
Reproduce:
1. Command line: SETVAR LUPREC 30000 (or AUPREC 30000), then select
something so the properties palette formats lengths/angles.
2. Before: the header stores 30000 and each formatted value is ~30 KB
(the test measured 30003 chars for one length); -3 is stored as-is.
3. After: the setter stores 8 (0-8 range, echoed truthfully), -3
stores 0, 5 stores 5 - and even a file carrying LUPREC 30000 in its
header formats short output, because the formatters cap themselves.
Fix:
Two layers. The formatters get the cap format_area already uses:
format_signed_length and format_signed_angle now clamp with
.max(0).min(15), so every source - SETVAR, the units dialog, and
values carried in DWG/DXF files - produces bounded output on the hot
paths (15 matches the point past which f64 decimal output is
meaningless, which is why format_area was written that way). The
setters store x.clamp(0, 8) with the clamped value echoed back, the
same stay-inside-the-app's-own-UI-range rule SETVAR ISOLINES follows
against the Options slider. Architectural and fractional formats
ignore precision (fixed 1/64-inch resolution) and are unaffected.
Regression tests mirror the existing patterns: one forces hostile
precision through the formatters while saving and restoring the
thread-local unit context, one drives SETVAR through the command line
and checks the stored header values.
Bug:
The typed ensure_plugin_state helper and the object-safe trait method
it wraps turned two recoverable conditions into process deaths. On the
in-process host, the helper downcast the stored state with
.expect("plugin state type mismatch for plugin_id"), so registering
state under one plugin_id as one type and later asking for another
(e.g. a reloaded plugin version changes the type while the per-tab
state map survives) panicked the host. On out-of-process hosts
(ipc/client.rs, ipc/v4/client.rs) and on NullHost, the trait method
itself was implemented as a bare panic!("ensure_plugin_state is not
supported for out-of-process plugins..."), so any out-of-process
plugin calling the documented helper crashed its own runner process -
even though the sibling plugin_state_any and plugin_state_any_mut
methods on those same implementations already degrade to None
gracefully. The helper also used .expect("init called once") on the
initialiser closure.
Reproduce:
1. Out-of-process: a plugin whose runner calls ensure_plugin_state
(&mut host, "my.id", ...).
2. Type mismatch: in an in-process plugin, ensure state under one id
as u32, then ensure a different type under the same id.
3. Before: step 1 kills the plugin runner at the trait panic; step 2
kills the host at the downcast expect. After: both get None, keep
running, and existing state is untouched.
Fix:
Give the trait method a failure channel: ensure_plugin_state_any now
returns Option<&mut (dyn Any + Send + Sync)>, mirroring the sibling
plugin_state_any_mut on the same implementations, and the typed helper
returns Option<&mut T> (any?.downcast_mut). The panicking
implementations (ipc v3, ipc v4, NullHost, and the three never-called
test dummies) return None; the in-process app host wraps its real
storage in Some with unchanged insert-or-return semantics. The init
closure is now guarded by take() with a unit-Any fallback, so even an
implementation invoking it twice fails the typed downcast instead of
panicking. Object safety, argument types and vtable slot are
unchanged; external plugin sources get a compile error at the helper
and adopt Option handling on rebuild. Regression tests use catch_unwind
so they compile against both signatures: one per panic site (out-of-
process, NullHost, type mismatch), each additionally asserting the
None result and that pre-existing state survived.
Bug:
The MCP adapter advertises "ttlMs": 3_600_000 on every task it hands
out and answers "Unknown or expired taskId" for missing ids, but the
task store itself had no expiry logic: insert was a bare push_back with
no cap, no TTL and no cleanup anywhere, and get_mut the only other
method. Every tools/call that returns accepted/running stores a task
including a clone of the full request arguments JSON, so a client
looping on long-running calls grows the store without bound and the MCP
server process slowly leaks until it OOMs. The advertised number was
also hardcoded in two separate response sites with nothing behind it.
Reproduce:
1. Start OpenCADStudio --mcp, connect a client with the tasks
capability, and issue tools/call calls that return accepted
repeatedly - each one stores a task.
2. Before: the tasks queue grows monotonically for the process
lifetime; a task inserted an hour ago is still handed back by
tasks/get even though the server promised a one-hour ttl.
3. After: tasks older than the advertised ttl are gone, tasks/get on
them returns the already-existing "Unknown or expired taskId"
error, and the store holds at most one hour of task traffic.
Fix:
Enforce the value the server already advertises. Each McpTask is now
wrapped in a StoredTask with an expires_at deadline of insertion time
plus TASK_TTL_MS (3_600_000), a single constant that also feeds both
"ttlMs" response literals so the promise and the eviction rule cannot
drift apart. insert and get_mut both evict expired entries before
looking (retain on a monotonic Instant), so writes reclaim stale tasks
and reads stop resolving expired ids - the store only ever holds tasks
created within the last hour. The ttl runs from insertion rather than
being refreshed by polls: poll_task rewrites last_updated_at on every
working poll, so a sliding window would keep a stuck task alive forever
and reintroduce the leak. The expiration test injects the clock through
insert_at/get_mut_at, so it checks fresh, expired and evict-on-insert
behaviour without sleeping.
Bug:
The keep-recent-files footer on the Start page builds its [-] [count]
[+] buttons eagerly while constructing the widget, and the [+] step was
written as plain shown + STEP while the [-] button next to it already
used saturating_sub. shown comes from the input box, whose handler
keeps every ASCII digit with no length cap - so typing
18446744073709551615 (2^64-1) parses to usize::MAX and the addition
overflowed during view(), before the button was ever clicked. Debug
builds panic with "attempt to add with overflow"; release builds (no
overflow checks) wrap to a small number and request a nonsense limit.
The clamp in set_recent_limit runs only after the value is computed.
Reproduce:
1. Start page -> "keep recent files" box -> type
18446744073709551615.
2. Before: the next frame panics at widget construction (on render, no
click needed); release builds wrap the step silently.
3. After: no panic; pressing Enter or [+] applies the clamped limit and
the box resyncs to it.
Fix:
Mirror the [-] button: shown.saturating_add(STEP). At usize::MAX the
message carries usize::MAX, which the handler's existing
clamp-and-resync treats exactly like any other over-max entry, so both
step buttons are now saturating and the release-build wrap is closed as
well. on_submit has no arithmetic and needs no change. The regression
test calls start_page_content directly with the hostile string - it
fails at the same line instead of requiring a GUI session.
Bug:
$ISOLINES (header i16) and Surface.u/v_isolines (file-controlled i16)
were only ever floored at 0, never capped, and were handed to the
tessellation kernel as the per-face isolate count - which draws
0..count isolines per face with no ceiling of its own. SETVAR ISOLINES
32767 (or a DWG carrying that header, or a Surface with u_isolines =
32767) turned every rebuild and every per-cursor preview of
PRESSPULL/REVOLVE/SWEEP/LOFT into a multi-million-vertex computation:
measured 4,259,966 edge vertices for one 12-unit cylinder preview.
Reproduce:
1. SETVAR ISOLINES 32767 (or open a DWG whose $ISOLINES is large, or
edit a Surface whose u_isolines is 32767).
2. Draw a circle and start REVOLVE (or PRESSPULL/SWEEP/LOFT), or shade
any 3D solid.
3. Before: the app allocates without bound - abort on memory failure or
a permanent freeze; previews recompute on every mouse move.
4. After: previews stay interactive; a hostile header renders exactly
like the largest value the Options slider can request.
Fix:
Cap each value at the ceiling this product already agrees on, where the
type changes from i16 to the kernel count: header $ISOLINES <= 64 (the
Options slider's own "Isolines per surface" range), Surface
u/v_isolines <= 200 (the plugin editor's writable range). One helper
(clamp_header_isolines) replaces all 11 copies of .max(0) as usize
across the entity/scene/control/display feed sites,
surface_isoline_counts clamps both axes, and SETVAR ISOLINES stores
x.clamp(0, 64) so the echo, slider and re-saved files agree with what
renders; file-sourced headers keep their raw stored value and are
clamped at consumption. Clamping is density-only: geometry, bounds and
saves are untouched. Three regression tests cover hostile surface
counts, the SETVAR entry, and a file-sourced header measured through
display tessellation (hostile renders like the slider ceiling, negative
renders like zero).
Bug:
--serve in socket mode serves one client at a time: the accept loop
blocks in the per-client line read before it reaches the next
connection, and this path never called set_read_timeout - unlike
rest.rs, http_bridge.rs and control::transport, which all use 15
seconds. A client that connects and sends nothing held the read open
forever, wedging the port: every later client hung waiting for the
ready banner (the banner is written before the read loop, so the server
looked healthy while it was stuck).
Reproduce:
1. Start OpenCADStudio --serve --port 8765.
2. From any shell, connect and send nothing:
python -c "import socket,time;socket.create_connection(('127.0.0.1',8765));time.sleep(3600)"
3. From a second shell, connect and wait for the banner.
4. Before: the second client hangs forever; the port never serves
another connection.
5. After: the idle first client is disconnected 15 seconds after its
last read, and the second client receives the ready banner.
Fix:
The one timeout this transport was missing: SERVE_IDLE_TIMEOUT = 15 s
(same value as the other transports), set on each accepted socket via
serve_socket_with_idle - a seam that also lets the regression test run
with 300 ms. A timed-out read surfaces as an error from lines_capped,
which the loop's existing let Ok(line) ... else break already turns
into "drop this client and accept the next one"; a partially read line
is discarded, which is correct because it was never dispatched. The
timeout only runs while a read is pending, so long server-side
operations do not spend the idle budget, and the document session
survives the reconnect. Regression test: an idle client must see EOF
and the following client must still get its banner.
Bug:
evaluate_table_formula walked for row in r1.min(r2)..=r1.max(r2) across
columns with no bound, and table_cell_reference parses any usize -
=SUM(A1:A18446744073709551615) names about 1.8e19 rows. table.cell()
returns None outside the table, so results were correct; the loop just
never finished. The walk runs during tessellation, i.e. on the render
path - the app freezes permanently and cannot even be closed.
Reproduce:
1. Insert a table, double-click a cell.
2. Type =SUM(A1:A18446744073709551615) (or
=SUM(A1:A1000000000) - a minutes-long freeze) and press Enter.
3. Before: the next repaint hangs forever.
4. After: the value appears immediately.
Fix:
The walk is clamped to the table's own rows/columns: row_end =
r1.max(r2).min(rows - 1) (same for columns). Every skipped cell was a
None, so the collected values - and every function's result
(SUM/AVERAGE/COUNT/MIN/MAX) - are identical; the loop now runs at most
rows x columns. start > end after clamping is an empty range, so
out-of-table references simply collect nothing (as before). The
regression test also asserts total evaluation time under 5 seconds,
turning any future un-clamped loop into a fast failure.
Bug:
The properties panel's Rows/Columns fields feed apply_geom_prop's grow
loops: number.round() as usize with no upper bound - and parse_f64
accepts up to 1e15. while rows.len() < requested { add_row() } pushes
rows until the allocator aborts. The magnitude validation in this path
governs geometry, not iteration counts.
Reproduce:
1. Insert a table, select it, open Properties -> Rows (or Columns).
2. Enter 1000000000000000 and commit.
3. Before: grows until "memory allocation failed".
4. After: the table grows to at most 1,000,000 cells (e.g. 100,000 rows
x 10 columns) and stays responsive; a legitimate shrink to a smaller
number still works exactly.
Fix:
One product budget, MAX_TABLE_CELLS = 1_000_000, applied per dimension:
grow_to = requested.min(budget / other_dimension) clamps the grow loops
only - the shrink loops keep the raw requested count, so shrinks stay
exact and the two loops cannot fight (grow_to <= requested by
construction). A table loaded over the budget is preserved: growth is
skipped and only an explicit smaller request shrinks it. The product,
not per-dimension caps, is what bounds memory regardless of which field
is set first. Tests cover overshoot at cap+10 and the 1e15 input for
both fields.
Bug:
The DGN compound-file reader walked FAT chains with a fixed
1,000,000-iteration guard - a step budget, not a byte budget. A
self-referential FAT entry (fat[s] = s) burns the whole budget reading
the same sector: 4.1 GB per call at 4 KiB sectors. Directory, mini
stream and miniFAT chains are alive simultaneously (about 12 GB peak) ->
allocator abort while merely selecting the file in the attach dialog.
Separately, models() inflated every model directory's streams (512 MB
each) with no total, and directory entries can all point at one shared
payload - 1.2 GB retained from a 4.3 MB file.
Reproduce:
1. Craft a ~1.5 KB CFB (sector shift 9) whose directory start sector's
FAT entry points at itself (zeroed FAT sector).
2. ATTACH and pick the file (any listing path parses the CFB).
3. Before: the directory chain grows to 4,000,000 entries from a
1536-byte file, then abort.
4. After: every chain is bounded by the file's own sector count -
instant, memory at most the file size.
5. For the models path: a file with 300 model dirs sharing a 4 MB
payload inflated to 1,258,291,200 bytes before; now it stops at the
file-scaled budget (max(file x 64, 256 MiB)).
Fix:
chain bounds itself by file bytes / sector size: a chain can only read
in-file sectors, and after that many steps it must have repeated one -
the walk is deterministic, so a repeat is a cycle that yields nothing
new. That is the strongest bound the format allows and it never
truncates a legitimate chain (each sector is read at most once). The
mini-stream loop uses the same argument over mini.len() / 64. models()
checks a file-scaled total budget before inflating each dir; later dirs
are dropped (earlier ones still load, name/table discovery degrades
gracefully). Tests: the hostile 1.5 KB file keeps all bytes <= file
size; the 300-model file stays within budget.
Bug:
defn_inline_point_cost_recursive and defn_metrics_recursive guard against
cycles (a visited set) but not against depth: a long legal chain of
block definitions (A inserts B inserts C, ...) recurses once per level
and overflows the stack. A document with about 20k-deep nesting kills
the process the first time block metrics are computed (opening the
drawing / measuring), even though there is no cycle.
Reproduce:
1. Build a drawing whose block definitions nest deeply - a chain of
20,000 INSERTs, each into the next.
2. Open it (metrics are computed on access).
3. Before: STATUS_STACK_OVERFLOW (0xc00000fd) - process death.
4. After: traversal stops at 33 levels (depth > 32), matching
expand_defn's existing limit exactly, and metrics fall back to the
default for the truncated branch.
Fix:
Both entry points now bail when visited.len() > MAX_NESTING_DEPTH (32)
alongside the existing cycle check - the same depth + 1 / depth > 32
convention expand_defn has used (33 levels), so the two walks agree on
what "too deep" means; the cycle guard stays. The depth test runs first
(O(1)) so deep-but-cyclic graphs still short-circuit. Tests: two
deep-nesting cases that overflowed before now complete.
Bug:
EMF polyline/polygon records carry a ring count as a raw u32.
pts16/pts32 did Vec::with_capacity(count) on it directly - a crafted
4294967295 pre-allocates about 4 billion slots and the allocator aborts.
The existing total > 400_000 guard only checked the sum of ring counts,
never an individual ring's vector.
Reproduce:
1. Craft an EMF whose POLYPOLYLINE/POLYGON count is 0xFFFFFFFF.
2. Attach/import it.
3. Before: "pre-allocated 4294967295 slots" then memory allocation
failed (multi-GB pre-allocation).
4. After: the vector is sized from the bytes actually present in the
record; the parse consumes what the record holds.
Fix:
pts16/pts32 size their capacity as count.min(available_bytes / point_size)
- the record's own remaining length is the ceiling, so allocation is
O(record). The iteration logic is untouched: it stops when the bytes run
out, so output for valid records is byte-identical (the guard is a pure
allocation bound). Tests: the u32::MAX ring no longer pre-allocates;
valid single- and multi-ring records still parse.
Bug:
arc_cubics turned a raw sweep angle from the DGN file straight into a
piece count - ceil(sweep ...) as usize fed with_capacity, and an extreme
sweep (huge, or +/-inf) produced a count that overflows usize
allocation, causing a capacity overflow panic while attaching the file.
Legal DGN arcs top out near 104 radians.
Reproduce:
1. Craft a DGN with an arc element whose sweep angle is enormous (1e18+,
+/-inf).
2. ATTACH it.
3. Before: "capacity overflow" at raw_vec during model build.
4. After: the arc clamps to +/-4096 quarter-turns (sign preserved, NaN
passes through, +/-inf handled) and renders as many cubics as fit.
Fix:
The clamp happens in the one funnel (arc_cubics) before ceil: sweep is
limited to +/-4096 x 90 degrees, far above the legal 104 rad but orders
below what overflows the count. The sign of the sweep is preserved so
arcs keep their direction, and NaN passes through to the existing
empty-arc handling. Tests cover three sweep magnitudes (including
+/-inf) building without overflow.
Bug:
The PDF underlay renderer sized its pixmap from page size x requested DPI
with no ceiling: a large page at a high raster DPI (display or print)
computes a multi-gigabyte image - testing reached a 17 GB pixmap and a
543-second render attempt before the allocator gave up.
Reproduce:
1. Attach a large-page PDF; set a high raster DPI (print above 150 DPI,
or display with DPI raised).
2. Before: minutes of rendering and a multi-GB allocation, then abort.
3. After: display rasters are clamped so the longest side is at most
3072 (like DWF/DGN, the effective DPI is stored); print rasters over
268,435,456 pixels are refused with an error instead of rendered.
Fix:
Display (at or below 150 DPI): the scale is clamped to
RASTER_SIDE/longest before the pixmap is allocated, and the DPI actually
used is stored for downstream math. Print (above 150 DPI): refused when
width x height exceeds MAX_PRINT_RASTER_PIXELS (256 Mpx) or is
non-finite - print DPI is never silently rescaled because the
sheet-mm-from-exact-DPI conversion must stay exact; the user gets an
error instead of a hang. A regression test covers the hostile page x DPI
case (about 1 second, previously 543 seconds and 17 GB).