Commit Graph
4183 Commits
Author SHA1 Message Date
Hakan SevenandClaude Opus 5.5 443b935a3f chore: bump codec d28fd71, kernel a03e4f0, graph 79bdc4b
Codec: PRs #71-#74 merged (MPOLYGON layout, multiline ATTDEF/ATTRIB DXF,
DXF MATERIAL/ACSH matrix, restorable SAT/SAB + history roots) and issues
#55 #64-#69 fixed. EntityType now boxes Surface, Extended, Table,
MultiLeader and Helix (2344 B -> 848 B per entity): construction sites
wrap in Box, the entity traits forward through Box, and the python
plugin generator converts with into(). Kernel: PR #70 (cuboid ring
winding).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 00:08:55 +03:00
Hakan SevenandClaude Opus 5.5 8c1471078d fix(control): unbounded entities no longer overflow the capture candidate area
An XLINE or RAY projects to saturated i32 corners; their difference
overflowed and panicked the debug build on every capture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 00:08:55 +03:00
Hakan SevenandClaude Opus 5.5 f08eb77b97 chore: bump kernel 8b4980b, graph 5384cbe (#1563 boolean speed)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 20:23:19 +03:00
Karim Jerbi 9d53502b19 feat(mcp): implement 4-pillar auto-sync, dynamic introspection, and non-blocking validation
- 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
2026-10-01 17:13:24 +01:00
Karim Jerbi 3c1c1364c6 fix(control,mcp): auto-restore minimized window on capture, add switch_document alias, and export schemas 2026-10-01 17:06:16 +01:00
Karim Jerbi 3075784f63 feat(mcp): implement multiscale pyramidal tiling protocol (DeepZoom)
- 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
2026-10-01 15:17:00 +01:00
Karim Jerbi 350742f2b0 feat(mcp): add standardized spatial metadata schema and visual differential streaming
- 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
2026-10-01 14:48:31 +01:00
Hakan SevenandClaude Opus 5.5 c8caac2e10 chore: bump kernel 605612f, graph efe8b2c (#1563 booleans)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 16:19:05 +03:00
Karim Jerbi bf7f37aaf4 feat(mcp): support MCP resources and resource delivery for captures
- 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
2026-10-01 14:03:34 +01:00
Karim Jerbi 6cc53c1001 feat(mcp): add Set-of-Marks grounding, regional framing, and 2-point image calibration
- 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
2026-10-01 13:27:22 +01:00
Hakan SevenandClaude Opus 5.5 9fc4e381b3 chore: bump kernel 7d106bf, graph 9c3af92 (#1563 booleans)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 10:38:32 +03:00
Hakan SevenandClaude Opus 5.5 3dcebf0dc6 Merge pull request #1553 from ramox81/codex/pointcloud-attach
Point clouds: drawn from their scans, attached, cropped and stylized.

The fork's codec patch is resolved to the upstream pins: the point cloud
records landed in opencadcodec 5ef9376, carried through opencadkernel
68eeda1 and opencadgraph e204966.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 01:49:25 +03:00
Hakan SevenandClaude Opus 5.5 34dfee032d refactor(snap): keep the ellipse-arc parameter test private and plain
Only snap.rs uses it; the full-turn checks collapse into one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 01:22:43 +03:00
Hakan Seven 66ca17e8fc Merge pull request #1551 from yztink/fix/ellipse-arc-tangent-snap-break
fix(snap): do not snap tangent across ellipse arc break
2026-10-01 01:21:34 +03:00
Hakan SevenandClaude Opus 5.5 0aefc5ff79 chore: bump kernel c637716, graph dd40a10 (#1563 folding tool)
Every face of the #1563 folding tool draws (balls bounded only by slits,
periodic spline edges stored reversed), and its bearings, hubs, washers
and shafts union and subtract: coaxial torus/sphere/cone intersections,
ray casts through tori and whole-surface faces, annulus imprints.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 01:20:21 +03:00
Hakan SevenandClaude Opus 5.5 9dd9b3eb8d test(icons): run the themed-handle timing benchmark only on request
It asserted a wall-clock race between two loops, which the parallel
suite's load lost now and then. The cache's behaviour stays pinned by
the entry-count tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 00:27:42 +03:00
Hakan SevenandClaude Opus 5.5 95a4419d0d fix(blocks): each dashed line in a block starts its own pattern
Block children of one style are batched into a single wire, and a
plinegen wire carries its dash distance across the joins. Every LINE
wire is plinegen, so each line in a block began mid-dash wherever the
previous one had stopped. PLINEGEN governs a wire's own breaks; a member
without any now enters its batch as non-plinegen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 00:27:42 +03:00
Hakan SevenandClaude Opus 5.5 1f5a7e97a1 fix(dimension): a dimension drawn live matches its baked block
Once edited, a dimension is drawn live instead of from its *D block;
on the #898 drawing that differed in three places:

- Oblique / architectural-tick strokes were DIMASZ/2 to each side, the
  block's are DIMASZ/sqrt(2) (its unit stroke runs corner to corner).
- Arrowheads and ticks shared the dimension line's wire, so a dashed
  dim line broke its ticks into stubs. They get their own solid wire
  (and explode as lines as before).
- A diameter's user-positioned text was lifted by the DIMTAD offset even
  when the stored point already was the text's middle, as a file saved
  elsewhere writes it; it is lifted only when it lies on the leader
  (#1323's case).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 00:27:42 +03:00
Hakan SevenandClaude Opus 5.5 59651052f8 fix(dimension): oblique and architectural-tick arrow blocks fit as ticks
Only DIMTSZ > 0 counted as ticks, so a style drawing its strokes with
the _OBLIQUE / _ARCHTICK arrow block gave each stroke a full arrow's
room: an 18-long dimension with 4.8 arrows moved its strokes outside
and ran the dim line past the extension lines once it was drawn live
(after any edit, #898). Tick arrow blocks now count as ticks for fit
and DIMDLE, like the file's own baked block shows them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 00:14:13 +03:00
Hakan SevenandClaude Opus 5.5 2a15a370f6 fix(dimension): an edited dimension keeps its layer's linetype
A dimension loaded from a file draws from its baked *D block, whose
lines are ByBlock and so take the dimension's linetype (ByLayer: the
layer's). Any edit - text colour, MATCHPROP, a grip - drops that block
and the dimension draws live, where a style with no DIMLTYPE/DIMLTEX1/2
drew solid: an edited dimension on a dashed layer turned solid (#898).
None or ByBlock now takes the dimension's own linetype, ByLayer its
layer's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 22:39:33 +03:00
Hakan SevenandClaude Opus 5.5 1fec34d84a fix(automation): TCP automation requires a session token
--http (headless and GUI-hosted) and --serve --port answered anything
that reached the loopback port, and the REST responses allowed every
web origin. Loopback is shared by every process and user on the
machine, so the port proved nothing.

- Every REST request now needs Authorization: Bearer <token>, and every
  --serve --port request line a "token" field; otherwise 401 /
  unauthorized (the socket is closed).
- The token is OCS_API_TOKEN when the launcher sets it, else 32 random
  bytes printed on stderr at startup; compared in constant time.
- No CORS headers are sent, so a web page cannot read an answer.
- stdio --serve and --mcp are unchanged (private to their parent / the
  GUI's own token).

GHSA-c27p-xfx2-9r5x

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 21:48:20 +03:00
Karim Jerbi d0bfa48037 fix(units): clamp precision vars and their formatters
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.
2026-09-30 19:15:50 +01:00
Karim Jerbi b1f4047f9c fix(plugins): degrade ensure_plugin_state instead of panicking
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.
2026-09-30 19:15:50 +01:00
Karim Jerbi c95d42a796 fix(mcp): evict tasks past their advertised ttl
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.
2026-09-30 19:15:50 +01:00
Karim Jerbi b19253374c fix(start): saturate the recent-limit plus step
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.
2026-09-30 19:15:49 +01:00
Karim Jerbi e15d801011 fix(tess): clamp isoline counts before they reach the kernel
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).
2026-09-30 19:15:49 +01:00
Karim Jerbi 438a46b80f fix(serve): drop idle socket clients so the accept loop keeps serving
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.
2026-09-30 19:15:49 +01:00
Karim Jerbi 9b3842c0f2 fix(table): clamp formula range walks to the table
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.
2026-09-30 19:15:49 +01:00
Karim Jerbi 2041d5f1ab fix(table): bound row and column counts by a cell budget
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.
2026-09-30 19:15:49 +01:00
Karim Jerbi 091fe0b87c fix(dgn): bound CFB chains and model inflation by the file
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.
2026-09-30 19:15:49 +01:00
Karim Jerbi fc6ae1c445 fix(cache): bound block metrics recursion at MAX_NESTING_DEPTH
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.
2026-09-30 19:15:49 +01:00
Karim Jerbi 8b5e5a3408 fix(gdi): size EMF poly point vectors from the record
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.
2026-09-30 19:15:49 +01:00
Karim Jerbi eabc7e216b fix(underlay): clamp hostile arc sweep before sizing pieces
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.
2026-09-30 19:15:49 +01:00
Karim Jerbi 86642bb639 fix(pdf): clamp display rasters and bound the print raster
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).
2026-09-30 19:15:49 +01:00
Karim Jerbi 21c4d6ab41 fix(dwf): bound XML element depth before roxmltree and Canvas walk
Bug:
Loading a DWFx package recursed twice on untrusted input:
1. roxmltree's tokenizer (parse_element <-> parse_content) recurses once
   per element nesting level (present in versions 0.20 and 0.21) - a
   page or descriptor nested about 100k deep overflows the stack inside
   Document::parse, before any application code runs;
2. the XPS walk recursed once per nested Canvas with no limit - about
   10k levels overflowed the stack.
Either one aborts the process during ATTACH.

Reproduce:
1. Build a DWFx (zip) whose .fpage / descriptor.xml nests elements
   (e.g. Canvas) about 100,000 deep.
2. ATTACH it.
3. Before: STATUS_STACK_OVERFLOW (0xc00000fd) - process death; a test
   calling only roxmltree::Document::parse on the same bytes reproduces
   it, proving the dependency's tokenizer dies first.
4. After: the file is rejected before parse (a log::warn records "XML
   nests deeper than 256 levels ... document rejected"), or - for Canvas
   nesting between 65 and 256 - the page renders with the too-deep
   subtree skipped while siblings and the rest of the page stay.

Fix:
A linear depth scan (xml_depth_within, skipping comments, CDATA, PIs,
doctype subsets and quoted attribute values so their angle brackets
never count; it may over-reject malformed markup but must never
under-count) gates all three Document::parse sites at 256 levels - real
pages nest single digits - and walk caps Canvas nesting at 64 (continue
skips that subtree, siblings unaffected), matching the compound-file
walker's cap. Rejections are logged so a rejected depth is
distinguishable from a corrupt parse.
2026-09-30 19:15:49 +01:00
Karim Jerbi 9bbee20c6c fix(gdi): reject DIB headers shorter than their fixed fields
Bug:
decode_packed (RLE-packed DIB decoding) guarded the header with a
12-byte check but then read fixed fields at offsets 14-16, 16-20 and
32-36. Any header shorter than 36 bytes that took the non-core path
panicked with an out-of-bounds slice - every input in that range.

Reproduce:
1. Open a BMP whose DIB header takes the non-core path but is shorter
   than 36 bytes (a 12-35 byte header claiming packed data).
2. Before: out-of-bounds panic at the fixed-field reads.
3. After: the header is rejected and the image skipped; valid headers
   (including a 1x1 DIB) still decode.

Fix:
The non-core branch requires len >= 36 before any of its fixed reads -
every input under 36 bytes panicked before the fix, so no non-panicking
behaviour changes. Valid packed DIBs (tests include a 1x1 image) decode
unchanged.
2026-09-30 19:15:49 +01:00
Karim Jerbi 8e4b405980 fix(gdi): skip undersized EMF polypoly records
Bug:
The EMF record reader accepts nSize = 28 (header-only) so legal empty
polylines load, but the sub-arm handling those iTypes also reads a u32
count at offset 28 for records that are not empty - rec.get(28..32) on a
28-byte record is out of bounds, panicking while importing the file.

Reproduce:
1. Craft an EMF with an EMR_POLYLINE (or related iType) record whose
   nSize is exactly 28.
2. Attach/import it.
3. Before: out-of-bounds panic reading at offset 28.
4. After: the undersized record is skipped; a full 36-byte record still
   renders.

Fix:
The sub-arm that reads at offset 28 now requires rec.len() >= 32 (its
first read), while the shared arm's guard stays at 28 so legal
header-only empty polylines keep loading. Undersized records are skipped
the way other malformed records already are. Tests: iType 7 and 91
records of exactly 28 bytes are skipped, a 36-byte record still renders.
2026-09-30 19:15:49 +01:00
Karim Jerbi ad5f46309b fix(plot-style): parse legacy color1 hex by boundary-safe slices
Bug:
Legacy CTB files carry color1 = #xx... colour values parsed with
fixed-offset byte slices (&value[2..4], ...) guarded only by a length
check. Multi-byte UTF-8 in the value (a value with a non-ASCII character
inside the hex section) passes the byte-length check but slices
mid-character, panicking while the style loads.

Reproduce:
1. Craft a legacy CTB whose color1 value passes the length check but
   contains a multi-byte character.
2. Open the Plot Style panel or plot with that style.
3. Before: panic at the char-boundary slice.
4. After: invalid pairs parse as 0 (black) and the style loads.

Fix:
Fixed-offset byte slices are replaced with str::get over the same
offsets - None (a non-boundary or short position) keeps the existing
unwrap_or(0) semantics, so every previously valid value parses
identically and previously panicking values now default to zero. Tests:
the crafted multi-byte value loads as black, valid values unchanged.
2026-09-30 19:15:49 +01:00
Karim Jerbi 790da218b5 fix(plot-style): bound custom lineweight table keys to u8 range
Bug:
Custom lineweight table entries in a CTB are keyed by a number parsed
from the file and stored without an upper bound, but the only reader
(resolve_lineweight) indexes through entry.lineweight: u8. A key beyond
u8::MAX (e.g. 18446744073709551615 in a crafted file) caused an
out-of-bounds-style failure or a multi-GB table allocation when the
style was applied.

Reproduce:
1. Craft a CTB whose custom lineweight table uses a huge key
   (usize::MAX).
2. Apply the style or open plot settings.
3. Before: overflow panic (or a giant allocation).
4. After: the key is out of range, the line is ignored like any other
   malformed CTB line, and the style loads.

Fix:
Keys are bounded to u8::MAX when parsed - exactly the range the
u8-typed reader can ever use. In-range keys behave as before; keys 256
and above are dropped. Tests: a usize::MAX key does not overflow, an
in-range key is kept and applied, 256 is ignored.
2026-09-30 19:15:49 +01:00
Karim Jerbi ac6aa750f6 fix(plot-style): cap CTB inflate output at 64 MiB
Bug:
CTB plot-style tables are zlib-deflated, and decompress_ctb inflated
them with a bare read_to_end - no output budget. A few hundred KB of
crafted deflate input expands to gigabytes (A/space runs compress about
1000:1), and the Vec grows until the allocator aborts; an abort is not a
catchable panic. Both the zlib path and the legacy raw-deflate fallback
were unbounded.

Reproduce:
1. Craft a CTB whose payload deflates from 128 MiB of spaces (tiny on
   disk), with a valid PIAFILEVERSION header or as legacy raw deflate.
2. Select it as the plot style (open/plot).
3. Before: RSS climbs past the limit, then "memory allocation failed".
4. After: the read stops at the 64 MiB limit and the load fails with
   "... inflated data exceeds the 67108864-byte limit".
5. Boundary: a payload inflating to exactly 64 MiB loads (the limit
   means "exceeds"); one byte more is refused.

Fix:
inflate_capped reads through take(MAX_INFLATED_BYTES + 1) and refuses
when the count is greater than 64 MiB. The extra byte is what makes
"exactly at the limit" distinguishable from "at least the limit": a bare
take(cap) stops at the cap, so read == cap is ambiguous, and a >= cap
comparison would wrongly refuse a legitimate boundary payload. All three
inflation sites (zlib, raw-deflate fallback, legacy decoder) route
through the helper; error strings are preserved. 64 MiB is far above any
real plot-style table. Four tests: zlib bomb refused, legacy bomb
refused, exactly-at-cap accepted, cap-plus-one refused.
2026-09-30 19:15:49 +01:00
Karim Jerbi a0308dfa88 fix(data-link): clamp cell-range column window to the data width
Bug:
crop_range bounded the row axis with skip/take over the file's real rows
but left the column axis unbounded: c0..=c1 collects one String per
column, and a range like A1:ZZZZZZ1 parses to roughly 308 million
columns, causing a capacity overflow while allocating a row.

Reproduce:
1. Data Link -> pick any CSV/xlsx, set Cell Range to A1:ZZZZZZ1 (or any
   range whose parsed column index is enormous).
2. Before: "capacity overflow" abort while building the preview.
3. After: the window is cropped to the file's widest row; columns past
   the data yield nothing beyond the data width.

Fix:
c1 is clamped to widest.saturating_sub(1) before the range is collected,
so the span is provably at most the file's own data - allocation is
O(file). Rows outside the file still come back empty (in-range blank
padding inside the data width is preserved), and c0 > c1 naturally
yields an empty window. Regression tests cover the 13-Z capacity-overflow
input and a 6-Z range that now crops.
2026-09-30 19:15:49 +01:00
Karim Jerbi c86746726b fix(config): parse colour hex digits as chars, not byte offsets
Bug:
parse_hex validated the colour string by byte length and then sliced at
fixed byte offsets. A multi-byte UTF-8 character inside the field makes
the byte offsets land mid-character, so committing the Options colour
keystroke field (or set_hex) panics at the char-boundary slice.

Reproduce:
1. Options -> any colour field; enter a value that passes the byte
   length check but contains a multi-byte character.
2. Commit the field.
3. Before: panic at the slice.
4. After: the value parses as far as valid hex digits allow or is
   rejected as invalid input - no panic.

Fix:
The parser walks chars, consuming exactly two hex digits per channel via
to_digit(16) and requiring the exact total length - byte offsets no
longer exist in the function. Covers all five colour fields and set_hex.
A regression test covers multi-byte inputs.
2026-09-30 19:15:49 +01:00
Karim Jerbi d175861fa4 fix(table): ignore malformed formula delimiters instead of slicing reversed
Bug:
evaluate_table_formula located "(" with find (forward) and ")" with
rfind (backward), then sliced body[open+1..close]. For malformed input
like =)( the delimiters come out reversed and the slice panics ("slice
index starts at 2 but ends at 0") during tessellation - on the render
path, from a single pasted cell value.

Reproduce:
1. Insert a table and double-click a cell.
2. Type =)( (or =a)b(c ) and press Enter.
3. Before: the next repaint panics.
4. After: the formula does not evaluate; the literal text renders.

Fix:
body.get(open + 1..close)? - reversed delimiters yield None and the
caller's existing fallback shows the literal text. Well-formed formulas
are untouched: regression tests keep =SUM(A1:B2), =AVERAGE(A1:B2) and
=A1 evaluating, and reject =)(, =a)b(c and =).
2026-09-30 19:15:49 +01:00
Karim Jerbi ee188a6e3c fix(control): reject unknown entities_transform actions instead of panicking
Bug:
control_entities_transform validated the action field only inside the
transform closure - and invoked that closure from three of the match's
arms. Any other action fell into unreachable!("validated by
transform()") and aborted the process. The claim in the panic message
was false: for unknown actions the validating code never ran. The handler
is reachable from every remote surface (REST, MCP, --serve) because they
share this dispatch. Two extra triggers: "move " (trailing space, no
trim) and an all-whitespace action.

Reproduce:
1. Start OpenCADStudio with --http 8765.
2. POST /api/v1/entities (a LINE), take handles[0].
3. POST /api/v1/entities/transform with
   {"handles":["<handle>"],"action":"explode"}.
4. Before: "internal error: entered unreachable code" and the process
   exits.
5. After: {"ok":false,"code":"invalid_action",...} and the server keeps
   serving. "action":"move " is now accepted (trimmed); "   " is
   rejected.

Fix:
The action is trimmed, lowercased and checked against the six-value
whitelist after require_existing (error precedence unchanged: a bad
handle still reports first) but before push_undo_snapshot, so an invalid
request no longer dirties the undo stack. The match's fallback arm
returns the same invalid_action error instead of unreachable!, so a
future mismatch between the whitelist and the match fails closed.
2026-09-30 19:15:49 +01:00
Karim Jerbi be8a7b95c4 fix(io): cap line reads on the serve and MCP transports
Bug:
The --serve automation loop (stdin/socket) and the MCP stdio loop read
input with unbounded read_line/lines. A single line over 64 MiB - a
huge paste or a hostile client - grows the buffer until the allocator
aborts, the same class of failure as the HTTP path, on the non-HTTP
transports.

Reproduce:
1. Start --serve on a socket (or the MCP server on stdio).
2. Send one 65 MiB line with no terminating newline anywhere in it.
3. Before: RSS grows to the full line, then "memory allocation failed"
   (or a multi-second stall while the line is copied).
4. After: the read fails at the cap and the existing EOF arm closes the
   connection/stdio promptly.

Fix:
New shared src/io/line_read.rs module: lines_capped(reader, 64 MiB)
yields Err for an over-cap line, which the serve and MCP loops already
treat as EOF/close. rest.rs was refactored onto the same helpers so the
cap logic lives in one place. A regression test streams a 65 MiB line
at the serve socket and asserts the connection ends without a response.
2026-09-30 19:15:49 +01:00
Karim Jerbi 6c3c266475 fix(rest): cap HTTP request line and headers, answer 431
Bug:
read_request used bare BufRead::read_line for the request line and every
header. A client that connects and streams bytes without ever sending
CRLF grows a single String until the allocator fails and the process
aborts. The 15-second idle timeout never fires while bytes keep
arriving, so one socket is enough.

Reproduce:
1. Start OpenCADStudio with --http 8765.
2. Connect (e.g. nc 127.0.0.1 8765) and write "GET /" followed by
   gigabytes with no newline.
3. Before: memory climbs until "memory allocation failed" aborts the
   process.
4. After: the line hits the 8 KiB cap, a 431 response is written and
   the connection closes; the server keeps serving.

Fix:
The request line and headers are read through capped helpers - 8 KiB per
line, 64 KiB for the whole header block. An over-limit request gets the
standard 431 (Request Header Fields Too Large) written inside
read_request before anything else parses it. The GUI bridge path shares
read_request, so it is covered too. Regression tests stream an oversize
request line and an oversize header block and assert the 431.
2026-09-30 19:15:49 +01:00
Karim Jerbi 3345b6453e fix(background): keep the app alive when a background worker panics
Bug:
background_task joined the worker thread with .expect(...). Any panic
inside a background closure (decoding a malformed file, an unexpected
None) was re-raised on the main thread and took the whole application
down, even though the worker's failure was already contained.

Reproduce:
1. Run an operation that executes in a background worker, e.g. loading
   or converting a deliberately corrupted file whose decoder panics.
2. Before: the process dies with the worker's panic message.
3. After: the command line shows "background task failed: <label> -
   <panic>" and the application keeps running.

Fix:
The worker closure is wrapped in catch_unwind; a panic is converted to
Message::BackgroundTaskFailed(context, message) delivered back to the
update loop and surfaced as a command-line error. Each call site passes
a context label naming the operation. A failed thread join (thread
aborted) is handled the same way instead of expect().
2026-09-30 19:15:49 +01:00
Karim Jerbi 2d6f4fcfdf fix(tabs): close by stable document id instead of vector index
Bug:
Message::TabClose carried a vector index captured when the view message
was built. Closing several tabs in one batch (Close All / Close Others
with unsaved-changes dialogs, or a queue of continuation updates) let an
earlier close shift the vector before a later TabClose(idx) was handled:
self.tabs.remove(idx) either panicked (index past the end) or closed a
different tab than the one the user confirmed.

Reproduce:
1. Open several drawings and modify each so they all prompt on close.
2. Use Close All, or close two tabs in quick succession so both messages
   are handled in one batch.
3. Before: panic in remove(idx), or the wrong tab closes while the tab
   the user chose stays open (or its work is lost).
4. After: the chosen tab closes; an id already closed by an earlier
   message is a no-op.

Fix:
TabClose now carries the tab's stable u64 id, resolved to a vector
position when the message is handled, not when it is built;
PendingClose::Tab stores ids the same way. All construction sites pass
tab.id (a raw usize index no longer type-checks), and
DocTabCloseAll/DocTabCloseOthers build their queues from live ids at
handling time. A missing id means the tab is already gone, so the
handler returns Task::none().
2026-09-30 19:15:49 +01:00
Hakan SevenandClaude Opus 5.5 9c4aa5f896 chore: bump codec fe3984f, kernel 9300a30, graph 5260f33 (condominium solids draw every face)
The #1538 drawing's solids now draw every face: extrusion, sum and
offset surfaces and summary-only intcurves are read, vertex blends are
filled over their boundary, and the mesher closes boundaries fitted to a
few thousandths, notched cylinders and torus bands.

Fixes #1538

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 18:59:27 +03:00
Karim Jerbi 6951cc2a7a feat(mcp): declarative op table and per-variant request validation
- Define Ty, Param, Rule, and OpDef in src/mcp_ops.rs for all 39 mutations
- Generate Draft 2020-12 schema using per-op anyOf variants with op enum discriminators
- Disambiguate colliding properties (replace, center, name) across mutation variants
- Add pre-dispatch validator enforcing required fields, rules, and parameter types
- Emit unknown parameter warnings into response warnings
- Introduce target_document_id for entities_copy_to with deprecated document_id fallback
- Update unit tests and automation smoke assertions
2026-09-30 16:54:45 +01:00