diff --git a/.cargo/config.toml b/.cargo/config.toml index 56bb92897..b3e368cb6 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -30,6 +30,8 @@ rustflags = [ "-Clink-arg=/STACK:16777216", ] +# Also sizes rustc's own threads: rustc 1.99 overflows the default 8 MB +# compiling this crate in release on Windows/macOS. [env] -RUST_MIN_STACK = "8388608" +RUST_MIN_STACK = "67108864" diff --git a/.github/workflows/mcp-conformance.yml b/.github/workflows/mcp-conformance.yml new file mode 100644 index 000000000..5b15e6ecf --- /dev/null +++ b/.github/workflows/mcp-conformance.yml @@ -0,0 +1,89 @@ +name: MCP conformance + +# Strict third-party proof for the MCP bridge (src/mcp.rs): run the official +# MCP Inspector CLI against a fresh release build and assert the stable +# surface. Headless-safe methods only — anything touching the drawing needs +# a live desktop GUI and stays in unit tests plus the pinned schema digest. +# +# Two deliberate limits, both documented: +# - The server is addressed through a --config file (array-form command). +# Passing the command positionally breaks on Windows shells and can launch +# the GUI instead of the --mcp bridge. +# - Assertions cover fields the Inspector preserves (names, titles, +# templates, handshake purity). Freshness hints (ttlMs/cacheScope) are +# asserted at unit level (legacy_list_results_carry_sep2549_ttl) and by +# direct stdio probes: this CLI normalizes result envelopes on output. + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: mcp-conformance-${{ github.ref }} + cancel-in-progress: true + +jobs: + inspector: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + + # Same libraries the Linux release build needs to start. + - name: System libraries + run: | + sudo apt-get update + sudo apt-get install -y \ + libgl1-mesa-dev libx11-dev libxcursor-dev libxi-dev libxrandr-dev \ + libxkbcommon-dev libwayland-dev libfontconfig1-dev libfreetype6-dev + + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Release build + run: cargo build --release --locked + + - name: Inspector config + run: | + printf '{"mcpServers":{"ocs":{"command":"%s/target/release/OpenCADStudio","args":["--mcp"]}}}' \ + "${{ github.workspace }}" > "${{ runner.temp }}/mcp-inspector.json" + + - name: initialize (pure serverInfo, experimental build) + run: | + npx -y @modelcontextprotocol/inspector --cli \ + --config "${{ runner.temp }}/mcp-inspector.json" --server ocs \ + --method initialize --format json 2>/dev/null | jq -e ' + .result.serverInfo.name == "OpenCADStudio" + and ((.result.serverInfo | keys_unsorted | sort) == ["name","title","version"]) + and (.result.capabilities.experimental["opencadstudio.build"].build_rev | type == "string")' + + - name: tools/list (four tools with titles) + run: | + npx -y @modelcontextprotocol/inspector --cli \ + --config "${{ runner.temp }}/mcp-inspector.json" --server ocs \ + --method tools/list --format json 2>/dev/null | jq -e ' + ([.result.tools[].name] | sort) == ["ocs_capture","ocs_execute","ocs_read","ocs_sessions"] + and ([.result.tools[].title] | length == 4)' + + - name: resources/list (reachable headless) + run: | + npx -y @modelcontextprotocol/inspector --cli \ + --config "${{ runner.temp }}/mcp-inspector.json" --server ocs \ + --method resources/list --format json 2>/dev/null | jq -e ' + (.result.resources | type == "array")' + + - name: resources/templates/list (tile and snapshot templates) + run: | + npx -y @modelcontextprotocol/inspector --cli \ + --config "${{ runner.temp }}/mcp-inspector.json" --server ocs \ + --method resources/templates/list --format json 2>/dev/null | jq -e ' + ([.result.resourceTemplates[].uriTemplate] | sort) == [ + "cad://session/{session_id}/snapshot/{hash}.png", + "cad://session/{session_id}/tile/{level}/{x}/{y}.png" + ]' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9769a3d7c..cbb3d03f0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,9 +24,21 @@ on: options: - all - snap + # Rebuild an already-published release: tag plus the commit it points at. + tag: + description: Existing release tag to rebuild (optional) + default: '' + type: string + commit: + description: Commit the tag points at (required with tag) + default: '' + type: string env: RELEASE_TAG: ${{ inputs.tag || github.event.release.tag_name }} + # Mirrors .cargo/config.toml so rebuilding a commit that predates the bump + # still gives rustc 1.99 enough stack. + RUST_MIN_STACK: "67108864" jobs: build-appimage: @@ -238,6 +250,7 @@ jobs: - name: Build env: OCS_PATREON_TOKEN: ${{ secrets.OCS_PATREON_TOKEN }} + shell: bash run: | cargo build --locked --release cargo build --locked --release -p dwg-thumbnailer-win @@ -479,10 +492,16 @@ jobs: cp AppIcon.icns "$APP/Contents/Resources/AppIcon.icns" cp DWG.icns DXF.icns "$APP/Contents/Resources/" # First-party Python scripting plugin. It remains out of process and - # uses the same API/toolchain as this host build. - bash plugins/opencad-python/tools/stage-bundled.sh \ - "$APP/Contents/Resources/plugins/opencad.python" \ - --target aarch64-apple-darwin + # uses the same API/toolchain as this host build. A dispatched + # rebuild of a tag cut before the plugin existed ships without it. + STAGE=plugins/opencad-python/tools/stage-bundled.sh + if [ -f "$STAGE" ] || [ "${{ github.event_name }}" != workflow_dispatch ]; then + bash "$STAGE" \ + "$APP/Contents/Resources/plugins/opencad.python" \ + --target aarch64-apple-darwin + else + echo "::warning::$STAGE missing at this commit; Python plugin not bundled" + fi # QuickLook thumbnail extension. cp -R DWGThumbnail.appex "$APP/Contents/PlugIns/" # Substitute version into Info.plist. diff --git a/Cargo.lock b/Cargo.lock index cb79660da..0f58675e1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,7 +4,7 @@ version = 4 [[package]] name = "OpenCADStudio" -version = "2026.39.0" +version = "2026.40.0" dependencies = [ "allsorts", "ashpd", @@ -4206,7 +4206,7 @@ dependencies = [ [[package]] name = "opencadcodec" version = "0.5.5" -source = "git+https://github.com/ramox81/cadcodec.git?rev=94095f7#94095f76fda8cd2596174b2ee9845d568e5da3fb" +source = "git+https://github.com/ramox81/cadcodec.git?rev=6d9c604#6d9c604dca78ed2f631e0f5b703415ea1f13b0db" dependencies = [ "ahash 0.8.12", "anyhow", @@ -4229,7 +4229,7 @@ dependencies = [ [[package]] name = "opencadgraph" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/opencadgraph.git?rev=10d0858#10d0858a7252f85df07be4dfa00946658b6979e6" +source = "git+https://github.com/HakanSeven12/opencadgraph.git?rev=892f942#892f942dff7b3082c195df82e560ac9ff44cec3b" dependencies = [ "opencadkernel", "serde_json", @@ -4238,7 +4238,7 @@ dependencies = [ [[package]] name = "opencadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=9cdd36d#9cdd36dc527216ba7929d8cfcdc1b7712a2d4cb8" +source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=ae28f66#ae28f669f8a5673585c2c5c5b3617876c8b7bf98" dependencies = [ "cavalier_contours", "opencadcodec", @@ -4249,7 +4249,7 @@ dependencies = [ [[package]] name = "opencadkernel-constraints" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=9cdd36d#9cdd36dc527216ba7929d8cfcdc1b7712a2d4cb8" +source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=ae28f66#ae28f669f8a5673585c2c5c5b3617876c8b7bf98" dependencies = [ "nalgebra", ] diff --git a/Cargo.toml b/Cargo.toml index f805f10db..361a526a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "OpenCADStudio" -version = "2026.39.0" +version = "2026.40.0" edition = "2021" build = "build.rs" default-run = "OpenCADStudio" @@ -34,10 +34,10 @@ rfd = "0.17" clap = { version = "4", features = ["derive", "string"] } env_logger = "0.11" log = "0.4" -codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "d96e3fa", features = ["serde"] } -kernel = { package = "opencadkernel", git = "https://github.com/HakanSeven12/opencadkernel.git", rev = "9cdd36d", features = ["acis", "offset"] } -kernel-constraints = { package = "opencadkernel-constraints", git = "https://github.com/HakanSeven12/opencadkernel.git", rev = "9cdd36d" } -graph = { package = "opencadgraph", git = "https://github.com/HakanSeven12/opencadgraph.git", rev = "10d0858" } +codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "fe69506", features = ["serde"] } +kernel = { package = "opencadkernel", git = "https://github.com/HakanSeven12/opencadkernel.git", rev = "ae28f66", features = ["acis", "offset"] } +kernel-constraints = { package = "opencadkernel-constraints", git = "https://github.com/HakanSeven12/opencadkernel.git", rev = "ae28f66" } +graph = { package = "opencadgraph", git = "https://github.com/HakanSeven12/opencadgraph.git", rev = "892f942" } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } diff --git a/crates/ocs_plugin_api/Cargo.toml b/crates/ocs_plugin_api/Cargo.toml index 8ac76adc1..f9bcba781 100644 --- a/crates/ocs_plugin_api/Cargo.toml +++ b/crates/ocs_plugin_api/Cargo.toml @@ -15,7 +15,7 @@ serde_json = { version = "1", optional = true } # Pulled in only by the `host` feature, which adds the `opencadcodec`-typed # `HostApi` runtime surface. The default crate stays dependency-free so engine # crates and external tooling can depend on the manifest/ribbon contract cheaply. -codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "d96e3fa", optional = true, features = ["serde"] } +codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "fe69506", optional = true, features = ["serde"] } # Runtime IPC and serialization (host feature only). interprocess = { version = "2", optional = true } @@ -39,7 +39,7 @@ serde_json = "1" serde = { version = "1", features = ["derive"] } cargo-lock = "11" # opencadcodec is scanned at build time to generate the embedded type registry. -codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "d96e3fa", features = ["serde"] } +codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "fe69506", features = ["serde"] } [dev-dependencies] serde_json = "1" diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index 382754826..dda5ba997 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -162,6 +162,10 @@ fn generate_type_registry(out_dir: &Path) { "AssocConstraintNodeData", trace_simple::, ), + ( + "AssocCurveValue", + trace_simple::, + ), ( "AssocEvalValue", trace_simple::, diff --git a/crates/ocs_web_worker/Cargo.toml b/crates/ocs_web_worker/Cargo.toml index 91ed6035f..138128ec0 100644 --- a/crates/ocs_web_worker/Cargo.toml +++ b/crates/ocs_web_worker/Cargo.toml @@ -8,7 +8,7 @@ publish = false crate-type = ["cdylib"] [dependencies] -codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "d96e3fa", features = ["serde"] } +codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "fe69506", features = ["serde"] } bincode = "1.3" serde = { version = "1", features = ["derive"] } console_error_panic_hook = "0.1" diff --git a/docs/automation/DEVELOPING.md b/docs/automation/DEVELOPING.md new file mode 100644 index 000000000..dae5f5da8 --- /dev/null +++ b/docs/automation/DEVELOPING.md @@ -0,0 +1,98 @@ +# Developing the MCP bridge + +Operator-facing setup lives in [README.md](README.md). This note is for +contributors iterating on the bridge itself: the stdio JSON-RPC layer in +`src/mcp.rs` and the operation validators in `src/mcp_ops.rs`. + +## Fast iteration: no version bumps needed + +- Run the bridge straight from a local build: `target/debug/OpenCADStudio --mcp` + for speed, `target/release/OpenCADStudio --mcp` when close to shipping. + Both report the same handshake; only the `build_profile` field differs. +- Tool-surface changes need no version-number change. `serverInfo` carries a + content `tool_schema` digest plus the `build_rev` revision hash, so staleness + is observable without a release. +- If you change any tool schema, the `tool_schema_digest_is_pinned` test + fails: review the diff, then update `TOOL_SCHEMA_DIGEST` deliberately + (never blindly). + +## Sessions are per client connection + +Clients bind the tool surface once when they connect, so after every +rebuild, restart the client session (or reconnect its MCP servers) to pick +up the new schema. The bridge helps: it watches its own executable and, once +it detects a rebuild, finishes the in-flight request and exits, so the next +call spawns a fresh bridge. No in-flight call ever fails because of this; +at worst the following call pays one respawn. + +## Session discovery + +`ocs_sessions` reads GUI descriptor files and verifies each one is alive +before probing it: process-liveness checks come first (cached snapshots +where the OS has no process table to read), dead descriptors are deleted, +and every remaining TCP probe uses a bounded timeout. Any step that cannot +run fails open toward treating the session as alive, so a broken check can +slow discovery but never wedge it. + +Concurrent launches are serialized with `automation/starting.lock` +(`{"pid":..,"started":unix_secs}`, 60s TTL): the first `launch_if_none: true` +caller claims it and spawns the GUI, later callers with a fresh live claim +wait for the same descriptor instead of opening another window. Stale claims +and claims from dead pids are reclaimed; an unusable directory fails open so +a broken lock can never wedge launching. Covered by +`startup_lock_serializes_concurrent_launches` (claim, wait, stale-reclaim, +dead-pid-reclaim, release). + +## Cancellation + +`run()` reads stdin on a feeder thread into a channel (`CancelPump`); the +main loop stays the only stdout writer. Wait loops harvest the channel in +short quanta instead of sleeping blind. A `notifications/cancelled` naming +the in-flight request dismisses the GUI prompt with a second `{"op":"cancel"}` +exchange, drops the late result, and sends nothing back. Cancels arriving +between polls resolve through a pending-operation map; re-polls for dismissed +operations answer `cancelled` without touching the GUI. Interactive picks +(`user_select`, `getpoint`) have a ten-minute single-call ceiling ending in +an explicit timeout error; everything else keeps the one-minute clamp. + +## Error routing + +Tool-call failures are classified at the source (`CallError`): tool-domain +failures (bad arguments, failed operations) carry model-actionable guidance +and stay `isError` results; bridge and GUI infrastructure failures (no live +session, dead transport) become `-32603`; cancelled requests get no response. +Resource-not-found follows the negotiated era (`-32602` modern, `-32002` +legacy). Error results omit `structuredContent`: strict clients validate it +against `outputSchema`, which only describes success. + +## Safety model + +Never assume the client prompts before destructive calls. `ocs_execute` is +marked `destructiveHint: true`, but confirmation behavior is client-defined +and unobservable from here — server-side validation is the only safety net. +Validate every argument before touching the drawing, keep destructive and +read-only paths in separate ops, and prefer reversible effects. + +## Rebuilding while the bridge runs + +If the linker refuses to replace the binary while a bridge process runs +from it, move the running binary aside under a new name and rebuild. The +live bridge keeps serving from the moved file; restart the client +afterwards so it respawns from the fresh binary, and delete the moved file +once nothing runs from it anymore. + +## Conformance gates + +These tests hold the spec line; keep them green and extend them with every +protocol change: `tool_schema_digest_is_pinned`, +`initialize_is_spec_pure_with_experimental_build`, +`malformed_requests_get_jsonrpc_errors`, +`legacy_list_results_carry_sep2549_ttl`, +`resource_not_found_code_follows_era`, +`non_object_input_is_invalid_request`, +`unknown_tool_is_a_protocol_error_with_names`, +`cancel_matching_and_wait_deadlines`. +For end-to-end proof, run the official MCP Inspector CLI against a local +build (headless-safe methods only: `initialize`, `tools/list`, +`resources/list`, `resources/read` — anything touching the drawing needs a +live desktop session and stays in unit tests). diff --git a/docs/automation/README.md b/docs/automation/README.md index 183a8973f..8333fc9eb 100644 --- a/docs/automation/README.md +++ b/docs/automation/README.md @@ -20,6 +20,15 @@ The server provides four tools: - `ocs_execute` performs one operation, an atomic record update, or a sequential batch against the real editor. - `ocs_capture` returns a bounded PNG of the drawing viewport or complete window. +### Stale sessions clean themselves up + +Dead editor sessions used to leave descriptor files behind that slowed down +session discovery. Discovery now skips processes that are gone (and removes +their leftover files), and a bridge started from an older build exits on its +own once it detects a rebuild so the client starts a fresh one. No action is +needed on your side; if a client reports version errors after an upgrade, +reconnecting its MCP servers picks up the new build. + Run `python docs/automation/mcp_acceptance.py target/debug/OpenCADStudio.exe` for a repeatable end-to-end acceptance. It creates visible geometry through MCP, audits and verified-saves DWG 2000/2013/2018 plus DXF 2000, and records a diff --git a/docs/screenshots/offset-drag/no-selection-distance-first.png b/docs/screenshots/offset-drag/no-selection-distance-first.png new file mode 100644 index 000000000..32835108c Binary files /dev/null and b/docs/screenshots/offset-drag/no-selection-distance-first.png differ diff --git a/docs/screenshots/offset-drag/preselected-live-preview.png b/docs/screenshots/offset-drag/preselected-live-preview.png new file mode 100644 index 000000000..d7eda8df4 Binary files /dev/null and b/docs/screenshots/offset-drag/preselected-live-preview.png differ diff --git a/docs/screenshots/offset-drag/typed-minus-10-lands-outside.png b/docs/screenshots/offset-drag/typed-minus-10-lands-outside.png new file mode 100644 index 000000000..53bb21750 Binary files /dev/null and b/docs/screenshots/offset-drag/typed-minus-10-lands-outside.png differ diff --git a/docs/screenshots/ribbon-highlight/after-dimbreak.png b/docs/screenshots/ribbon-highlight/after-dimbreak.png new file mode 100644 index 000000000..a6538aa96 Binary files /dev/null and b/docs/screenshots/ribbon-highlight/after-dimbreak.png differ diff --git a/docs/screenshots/ribbon-highlight/after-offset.png b/docs/screenshots/ribbon-highlight/after-offset.png new file mode 100644 index 000000000..d7c79c1f7 Binary files /dev/null and b/docs/screenshots/ribbon-highlight/after-offset.png differ diff --git a/docs/screenshots/ribbon-highlight/after-xline.png b/docs/screenshots/ribbon-highlight/after-xline.png new file mode 100644 index 000000000..e0edc2e7a Binary files /dev/null and b/docs/screenshots/ribbon-highlight/after-xline.png differ diff --git a/docs/screenshots/ribbon-highlight/before-dimbreak.png b/docs/screenshots/ribbon-highlight/before-dimbreak.png new file mode 100644 index 000000000..195fa916b Binary files /dev/null and b/docs/screenshots/ribbon-highlight/before-dimbreak.png differ diff --git a/docs/screenshots/ribbon-highlight/before-offset.png b/docs/screenshots/ribbon-highlight/before-offset.png new file mode 100644 index 000000000..195fa916b Binary files /dev/null and b/docs/screenshots/ribbon-highlight/before-offset.png differ diff --git a/docs/screenshots/ribbon-highlight/before-xline.png b/docs/screenshots/ribbon-highlight/before-xline.png new file mode 100644 index 000000000..195fa916b Binary files /dev/null and b/docs/screenshots/ribbon-highlight/before-xline.png differ diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 1a8715e9d..d8f9892f5 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -3035,6 +3035,8 @@ modify = .offset-n-click-through-point-or-multiple = OFFSET__ocs_arg_n__ انقر نقطة العبور أو [متعدد]: .offset-n-multiple-click-next-side-distance-d = OFFSET__ocs_arg_n__ متعدد انقر الجانب التالي [المسافة __ocs_arg_d__]: .offset-n-multiple-click-next-through-point = OFFSET__ocs_arg_n__ متعدد انقر نقطة العبور التالية: + .offset-n-click-through-point-or-type-distance = OFFSET__ocs_arg_n__ انقر نقطة العبور أو اكتب المسافة، والقيمة السالبة للجهة الأخرى [Multiple/Erase/Layer] <__ocs_arg_d__>: + .offset-n-multiple-click-through-point-or-type-distance = OFFSET__ocs_arg_n__ متعدد انقر نقطة العبور أو اكتب المسافة، والقيمة السالبة للجهة الأخرى [Erase/Layer] <__ocs_arg_d__>: .offset-dimexo = إزاحة (DIMEXO) .overall-scale-dimscale = المقياس الإجمالي (DIMSCALE) .pedit-enter-option = PEDIT أدخل الخيار: @@ -4105,6 +4107,12 @@ insert = .field-select-object = Select object .field-expression = Field expression .field-help = Inserts a field: text that updates from the drawing, the date or another source. + .field-average = Average + .field-sum = Sum + .field-object-name = Object name + .field-block-placeholder = Block placeholder + .field-block-editor-only = Only accessible in the block editor + .field-block-reference-property = Block reference property .field-cat-linked = Linked .field-current-precision = Current precision .field-evaluate = Evaluate diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index 814d0f4d4..4453f5bf9 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -3022,6 +3022,8 @@ modify = .offset-n-click-through-point-or-multiple = OFFSET__ocs_arg_n__ Кликни точка на преминаване или [Многократно]: .offset-n-multiple-click-next-side-distance-d = OFFSET__ocs_arg_n__ Многократно Кликни следваща страна [разстояние __ocs_arg_d__]: .offset-n-multiple-click-next-through-point = OFFSET__ocs_arg_n__ Многократно Кликни следваща точка на преминаване: + .offset-n-click-through-point-or-type-distance = OFFSET__ocs_arg_n__ Кликни точка на преминаване или въведи разстояние, отрицателно за другата страна [Multiple/Erase/Layer] <__ocs_arg_d__>: + .offset-n-multiple-click-through-point-or-type-distance = OFFSET__ocs_arg_n__ Многократно Кликни точка на преминаване или въведи разстояние, отрицателно за другата страна [Erase/Layer] <__ocs_arg_d__>: .offset-dimexo = Отместване (DIMEXO) .overall-scale-dimscale = Общ мащаб (DIMSCALE) .pedit-enter-option = PEDIT Въведи опция: @@ -4092,6 +4094,12 @@ insert = .field-select-object = Select object .field-expression = Field expression .field-help = Inserts a field: text that updates from the drawing, the date or another source. + .field-average = Average + .field-sum = Sum + .field-object-name = Object name + .field-block-placeholder = Block placeholder + .field-block-editor-only = Only accessible in the block editor + .field-block-reference-property = Block reference property .field-cat-linked = Linked .field-current-precision = Current precision .field-evaluate = Evaluate diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 8613591b1..d0a43de2c 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -3022,6 +3022,8 @@ modify = .offset-n-click-through-point-or-multiple = OFFSET__ocs_arg_n__ Bod prokliku nebo [Multiple]: .offset-n-multiple-click-next-side-distance-d = OFFSET__ocs_arg_n__ Vícenásobné kliknutí na další stranu [vzdálenost __ocs_arg_d__]: .offset-n-multiple-click-next-through-point = OFFSET__ocs_arg_n__ Vícenásobné kliknutí dalším průchozím bodem: + .offset-n-click-through-point-or-type-distance = OFFSET__ocs_arg_n__ Klikněte na průchozí bod nebo zadejte vzdálenost, zápornou pro druhou stranu [Multiple/Erase/Layer] <__ocs_arg_d__>: + .offset-n-multiple-click-through-point-or-type-distance = OFFSET__ocs_arg_n__ Vícenásobně Klikněte na průchozí bod nebo zadejte vzdálenost, zápornou pro druhou stranu [Erase/Layer] <__ocs_arg_d__>: .offset-dimexo = Odsazení (DIMEXO) .overall-scale-dimscale = Celková stupnice (DIMSCALE) .pedit-enter-option = Možnost PEDIT Enter: @@ -4090,6 +4092,12 @@ insert = .field-select-object = Select object .field-expression = Field expression .field-help = Inserts a field: text that updates from the drawing, the date or another source. + .field-average = Average + .field-sum = Sum + .field-object-name = Object name + .field-block-placeholder = Block placeholder + .field-block-editor-only = Only accessible in the block editor + .field-block-reference-property = Block reference property .field-cat-linked = Linked .field-current-precision = Current precision .field-evaluate = Evaluate diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index c05f97f7e..96d321278 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -3023,6 +3023,8 @@ modify = .offset-n-click-through-point-or-multiple = OFFSET__ocs_arg_n__ Klicken Sie durch Punkt oder [Multiple]: .offset-n-multiple-click-next-side-distance-d = OFFSET__ocs_arg_n__ Mehrfachklicken Sie auf die nächste Seite [Abstand __ocs_arg_d__]: .offset-n-multiple-click-next-through-point = OFFSET__ocs_arg_n__ Mehrfachklicken Sie als nächstes durch den Punkt: + .offset-n-click-through-point-or-type-distance = OFFSET__ocs_arg_n__ Durch Punkt klicken oder Abstand eingeben, negativ für die andere Seite [Multiple/Erase/Layer] <__ocs_arg_d__>: + .offset-n-multiple-click-through-point-or-type-distance = OFFSET__ocs_arg_n__ Mehrfach Durch Punkt klicken oder Abstand eingeben, negativ für die andere Seite [Erase/Layer] <__ocs_arg_d__>: .offset-dimexo = Abstand der Maßhilfslinie (DIMEXO) .overall-scale-dimscale = Gesamtmaßstab (DIMSCALE) .pedit-enter-option = PEDIT Option eingeben: @@ -4091,6 +4093,12 @@ insert = .field-select-object = Select object .field-expression = Field expression .field-help = Inserts a field: text that updates from the drawing, the date or another source. + .field-average = Average + .field-sum = Sum + .field-object-name = Object name + .field-block-placeholder = Block placeholder + .field-block-editor-only = Only accessible in the block editor + .field-block-reference-property = Block reference property .field-cat-linked = Linked .field-current-precision = Current precision .field-evaluate = Evaluate diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index d39f35443..db3cab564 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -3023,6 +3023,8 @@ modify = .offset-n-click-through-point-or-multiple = OFFSET__ocs_arg_n__ Κάντε κλικ σε σημείο διέλευσης ή [Πολλαπλά]: .offset-n-multiple-click-next-side-distance-d = OFFSET__ocs_arg_n__ Πολλαπλά Κάντε κλικ στην επόμενη πλευρά [απόσταση __ocs_arg_d__]: .offset-n-multiple-click-next-through-point = OFFSET__ocs_arg_n__ Πολλαπλά Κάντε κλικ στο επόμενο σημείο διέλευσης: + .offset-n-click-through-point-or-type-distance = OFFSET__ocs_arg_n__ Κάντε κλικ σε σημείο διέλευσης ή πληκτρολογήστε απόσταση, αρνητική για την άλλη πλευρά [Multiple/Erase/Layer] <__ocs_arg_d__>: + .offset-n-multiple-click-through-point-or-type-distance = OFFSET__ocs_arg_n__ Πολλαπλά Κάντε κλικ σε σημείο διέλευσης ή πληκτρολογήστε απόσταση, αρνητική για την άλλη πλευρά [Erase/Layer] <__ocs_arg_d__>: .offset-dimexo = Μετατόπιση (DIMEXO) .overall-scale-dimscale = Συνολική κλίμακα (DIMSCALE) .pedit-enter-option = PEDIT Δώστε επιλογή: @@ -4093,6 +4095,12 @@ insert = .field-select-object = Select object .field-expression = Field expression .field-help = Inserts a field: text that updates from the drawing, the date or another source. + .field-average = Average + .field-sum = Sum + .field-object-name = Object name + .field-block-placeholder = Block placeholder + .field-block-editor-only = Only accessible in the block editor + .field-block-reference-property = Block reference property .field-cat-linked = Linked .field-current-precision = Current precision .field-evaluate = Evaluate diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index d6ba4c9c1..51b717a8d 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -3024,6 +3024,8 @@ modify = .offset-n-click-through-point-or-multiple = OFFSET__ocs_arg_n__ Click through point or [Multiple]: .offset-n-multiple-click-next-side-distance-d = OFFSET__ocs_arg_n__ Multiple Click next side [distance __ocs_arg_d__]: .offset-n-multiple-click-next-through-point = OFFSET__ocs_arg_n__ Multiple Click next through point: + .offset-n-click-through-point-or-type-distance = OFFSET__ocs_arg_n__ Click through point or type distance, negative for the other side [Multiple/Erase/Layer] <__ocs_arg_d__>: + .offset-n-multiple-click-through-point-or-type-distance = OFFSET__ocs_arg_n__ Multiple Click through point or type distance, negative for the other side [Erase/Layer] <__ocs_arg_d__>: .offset-dimexo = Offset (DIMEXO) .overall-scale-dimscale = Overall scale (DIMSCALE) .pedit-enter-option = PEDIT Enter option: @@ -4094,6 +4096,12 @@ insert = .field-select-object = Select object .field-expression = Field expression .field-help = Inserts a field: text that updates from the drawing, the date or another source. + .field-average = Average + .field-sum = Sum + .field-object-name = Object name + .field-block-placeholder = Block placeholder + .field-block-editor-only = Only accessible in the block editor + .field-block-reference-property = Block reference property .field-cat-linked = Linked .field-current-precision = Current precision .field-evaluate = Evaluate diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 0ad5c4630..3e7750281 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -3023,6 +3023,8 @@ modify = .offset-n-click-through-point-or-multiple = OFFSET__ocs_arg_n__ Pulse el punto de paso o [Múltiple]: .offset-n-multiple-click-next-side-distance-d = OFFSET__ocs_arg_n__ Múltiple Pulse el siguiente lado [distancia __ocs_arg_d__]: .offset-n-multiple-click-next-through-point = OFFSET__ocs_arg_n__ Múltiple Pulse el siguiente punto de paso: + .offset-n-click-through-point-or-type-distance = OFFSET__ocs_arg_n__ Pulse el punto de paso o escriba la distancia, negativa para el otro lado [Multiple/Erase/Layer] <__ocs_arg_d__>: + .offset-n-multiple-click-through-point-or-type-distance = OFFSET__ocs_arg_n__ Múltiple Pulse el punto de paso o escriba la distancia, negativa para el otro lado [Erase/Layer] <__ocs_arg_d__>: .offset-dimexo = Desfase (DIMEXO) .overall-scale-dimscale = Escala general (DIMSCALE) .pedit-enter-option = PEDIT Indique una opción: @@ -4093,6 +4095,12 @@ insert = .field-select-object = Select object .field-expression = Field expression .field-help = Inserts a field: text that updates from the drawing, the date or another source. + .field-average = Average + .field-sum = Sum + .field-object-name = Object name + .field-block-placeholder = Block placeholder + .field-block-editor-only = Only accessible in the block editor + .field-block-reference-property = Block reference property .field-cat-linked = Linked .field-current-precision = Current precision .field-evaluate = Evaluate diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 7f43aa3fc..a1d1ab170 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -3022,6 +3022,8 @@ modify = .offset-n-click-through-point-or-multiple = OFFSET__ocs_arg_n__ Napsautuskohta tai [Multiple]: .offset-n-multiple-click-next-side-distance-d = OFFSET__ocs_arg_n__ Useita Napsauta seuraavaa reunaa [etäisyys __ocs_arg_d__]: .offset-n-multiple-click-next-through-point = OFFSET__ocs_arg_n__ Useita napsauttamalla seuraava kohta: + .offset-n-click-through-point-or-type-distance = OFFSET__ocs_arg_n__ Napsauta läpikulkupistettä tai kirjoita etäisyys, negatiivinen toiselle puolelle [Multiple/Erase/Layer] <__ocs_arg_d__>: + .offset-n-multiple-click-through-point-or-type-distance = OFFSET__ocs_arg_n__ Useita Napsauta läpikulkupistettä tai kirjoita etäisyys, negatiivinen toiselle puolelle [Erase/Layer] <__ocs_arg_d__>: .offset-dimexo = Siirtymä (DIMEXO) .overall-scale-dimscale = Kokonaismittakaava (DIMSCALE) .pedit-enter-option = PEDIT Enter vaihtoehto: @@ -4092,6 +4094,12 @@ insert = .field-select-object = Select object .field-expression = Field expression .field-help = Inserts a field: text that updates from the drawing, the date or another source. + .field-average = Average + .field-sum = Sum + .field-object-name = Object name + .field-block-placeholder = Block placeholder + .field-block-editor-only = Only accessible in the block editor + .field-block-reference-property = Block reference property .field-cat-linked = Linked .field-current-precision = Current precision .field-evaluate = Evaluate diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index d634008d5..65bfde6e0 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -3023,6 +3023,8 @@ modify = .offset-n-click-through-point-or-multiple = OFFSET__ocs_arg_n__ Cliquez sur le point ou [multiple]: .offset-n-multiple-click-next-side-distance-d = OFFSET__ocs_arg_n__ Cliquer sur le côté suivant [distance __ocs_arg_d__]: .offset-n-multiple-click-next-through-point = OFFSET__ocs_arg_n__ Cliquez sur plusieurs ci-dessous pour accéder au point suivant: + .offset-n-click-through-point-or-type-distance = OFFSET__ocs_arg_n__ Cliquez sur le point de passage ou saisissez la distance, négative pour l'autre côté [Multiple/Erase/Layer] <__ocs_arg_d__>: + .offset-n-multiple-click-through-point-or-type-distance = OFFSET__ocs_arg_n__ Multiple Cliquez sur le point de passage ou saisissez la distance, négative pour l'autre côté [Erase/Layer] <__ocs_arg_d__>: .offset-dimexo = Décomposition (DIMEXO) .overall-scale-dimscale = Échelle globale (DIMSCALE) .pedit-enter-option = PEDIT Saisissez l'option : @@ -4093,6 +4095,12 @@ insert = .field-select-object = Select object .field-expression = Field expression .field-help = Inserts a field: text that updates from the drawing, the date or another source. + .field-average = Average + .field-sum = Sum + .field-object-name = Object name + .field-block-placeholder = Block placeholder + .field-block-editor-only = Only accessible in the block editor + .field-block-reference-property = Block reference property .field-cat-linked = Linked .field-current-precision = Current precision .field-evaluate = Evaluate diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index e0f26ae1e..21035e9e7 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -3011,6 +3011,8 @@ modify = .offset-n-click-through-point-or-multiple = OFFSET__ocs_arg_n__ बिंदु के माध्यम से क्लिक करें या [एकाधिक]: .offset-n-multiple-click-next-side-distance-d = OFFSET__ocs_arg_n__ अगली दिशा की ओर क्लिक करें [दूरी __ocs_arg_d__]: .offset-n-multiple-click-next-through-point = OFFSET__ocs_arg_n__ एकाधिक बिंदु के माध्यम से आगे क्लिक करें: + .offset-n-click-through-point-or-type-distance = OFFSET__ocs_arg_n__ पारगमन बिंदु पर क्लिक करें या दूरी टाइप करें, दूसरी ओर के लिए ऋणात्मक [Multiple/Erase/Layer] <__ocs_arg_d__>: + .offset-n-multiple-click-through-point-or-type-distance = OFFSET__ocs_arg_n__ एकाधिक पारगमन बिंदु पर क्लिक करें या दूरी टाइप करें, दूसरी ओर के लिए ऋणात्मक [Erase/Layer] <__ocs_arg_d__>: .offset-dimexo = ऑफसेट (DIMEXO) .overall-scale-dimscale = कुल मिलाकर स्केल (DIMSCALE) .pedit-enter-option = PEDIT विकल्प दर्ज करें: @@ -4081,6 +4083,12 @@ insert = .field-select-object = Select object .field-expression = Field expression .field-help = Inserts a field: text that updates from the drawing, the date or another source. + .field-average = Average + .field-sum = Sum + .field-object-name = Object name + .field-block-placeholder = Block placeholder + .field-block-editor-only = Only accessible in the block editor + .field-block-reference-property = Block reference property .field-cat-linked = Linked .field-current-precision = Current precision .field-evaluate = Evaluate diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index 9df1e87b6..6906cabd4 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -3022,6 +3022,8 @@ modify = .offset-n-click-through-point-or-multiple = OFFSET__ocs_arg_n__ Átkattintási pont vagy [Multiple]: .offset-n-multiple-click-next-side-distance-d = OFFSET__ocs_arg_n__ Több kattintás a következő oldalon [távolság __ocs_arg_d__]: .offset-n-multiple-click-next-through-point = OFFSET__ocs_arg_n__ Több kattintás a következő ponton: + .offset-n-click-through-point-or-type-distance = OFFSET__ocs_arg_n__ Kattintson az átmenő pontra, vagy írja be a távolságot, negatívat a másik oldalhoz [Multiple/Erase/Layer] <__ocs_arg_d__>: + .offset-n-multiple-click-through-point-or-type-distance = OFFSET__ocs_arg_n__ Többszörös Kattintson az átmenő pontra, vagy írja be a távolságot, negatívat a másik oldalhoz [Erase/Layer] <__ocs_arg_d__>: .offset-dimexo = Eltolás (DIMEXO) .overall-scale-dimscale = Teljes skála (DIMSCALE) .pedit-enter-option = PEDIT Enter opció: @@ -4092,6 +4094,12 @@ insert = .field-select-object = Select object .field-expression = Field expression .field-help = Inserts a field: text that updates from the drawing, the date or another source. + .field-average = Average + .field-sum = Sum + .field-object-name = Object name + .field-block-placeholder = Block placeholder + .field-block-editor-only = Only accessible in the block editor + .field-block-reference-property = Block reference property .field-cat-linked = Linked .field-current-precision = Current precision .field-evaluate = Evaluate diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index 01c52fbb7..f652cc7d6 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -33,7 +33,7 @@ ribbon-tab = .annotate = Annotazione .insert = Inserisci .model = Modello - .layout = Disposizione + .layout = Impagina .manage = Gestisci .view = Vista @@ -128,7 +128,7 @@ modal = .save-warning = Avviso di salvataggio .unable-save = Impossibile salvare il disegno .drawing-changed = Il disegno è stato modificato sul disco - .delete-layer = Elimina layer + .delete-layer = Elimina livelli .unsaved-changes = Modifiche non salvate .unsaved-save-prompt = Salvare le modifiche? .point-style = Stile punto @@ -138,7 +138,7 @@ modal = .recovery-prompt = Recupero del disegno .file-name-could-not-be-saved = Impossibile salvare "__ocs_arg_file_name__". .file-name-was-changed-by-another-application = "__ocs_arg_file_name__" è stato modificato da un'altra applicazione. - .count-selected-layers = __ocs_arg_count__ layer selezionati + .count-selected-layers = __ocs_arg_count__ livelli selezionati .subject-still-has-count-obj-deleting-will-also = __ocs_arg_subject__ __ocs_arg_has__ ancora __ocs_arg_count__ __ocs_arg_obj__. L'eliminazione rimuoverà dal disegno anche __ocs_arg_those__. Continuare? @@ -274,7 +274,7 @@ common = .none-use-shortcuts-set-key-command =(nessuno — utilizzare: SHORTCUTS SET ) .alignment-indented-label-title-case = Allineamento: .background-fill-enabled = Riempimento dello sfondo abilitato - .borders-type-weight-color-spacing-hidden = Bordi (tipo/peso/colore/spaziatura/nascosto) + .borders-type-weight-color-spacing-hidden = Bordi (tipo/spessore/colore/spaziatura/nascosto) .data-type = Tipo di dati: .fill-color-indented-label-sentence-case = Colore di riempimento: .format-string = Stringa di formato: @@ -364,12 +364,12 @@ common = .bottomleft = In basso a sinistra .bottomright = In basso a destra .box = Scatola - .break = Pausa + .break = Spezza .brightness = Luminosità - .by-layer = Per strato - .byblock = PerBlocco - .bycolor = PerColore - .bylayer = PerLayer + .by-layer = DaLayer + .byblock = DaBlocco + .bycolor = DaColore + .bylayer = DaLayer .convtosurface-converted-n-solid-s-to-surface-s = CONVTOSURFACE: convertito(i) solido(i) __ocs_fmt_0__ in superficie(i). .convtosurface-select-a-solid-created-this-sess = CONVTOSURFACE: seleziona un solido creato in questa sessione. .coords-mode-label = COORDS = __ocs_fmt_0__ (__ocs_fmt_1__) @@ -383,7 +383,7 @@ common = .cannot-rename-the-standard-style = Impossibile rinominare lo stile Standard. .catchment-tagged-successfully = Bacino taggato correttamente. .center = Centro - .center-mark-dimcen = Contrassegno centrale (DIMCEN) + .center-mark-dimcen = Contrassegno del centro (DIMCEN) .centered = Centrato .centimeters = Centimetri .chamfer = Smusso @@ -493,14 +493,14 @@ common = .file-locations-are-unavailable-in-the-web-appl = I percorsi dei file non sono disponibili nell'applicazione Web. .fill-color-sentence-case = Colore di riempimento .fill-color-aci-dimtfillclr = Colore riempimento ACI (DIMTFILLCLR) - .fillet = Filetto + .fillet = Raccordo .finalize-command-repeat-last = Finalizza comando/Ripeti per ultimo .finalizing = Finalizzazione… .find-and-replace = Trova e sostituisci .first-seg-angle = Primo seg. angolo: .fit = Adatta - .fit-dimatfit = Vestibilità (DIMATFIT) - .fit-legacy-dimfit = Vestibilità (precedente DIMFIT) + .fit-dimatfit = Disposizione (DIMATFIT) + .fit-legacy-dimfit = Disposizione (precedente DIMFIT) .fixed-height = Altezza fissa: .fixed-length-dimfxl = Lunghezza fissa (DIMFXL) .fixed-length-ext-lines-dimfxlon = Linee esterne a lunghezza fissa (DIMFXLON) @@ -527,10 +527,10 @@ common = .groups = Gruppi .h-margin = H Margine: .hatchedit-hatch-entity-not-found = HATCHEDIT: entità tratteggio non trovata. - .hatchedit-hatch-updated = HATCHEDIT: portello aggiornato. + .hatchedit-hatch-updated = HATCHEDIT: tratteggio aggiornato. .hatchedit-not-a-hatch-entity = HATCHEDIT: non un'entità di tratteggio. .handle = Maniglia - .hatch = Portello + .hatch = Tratteggio .header-row = Riga di intestazione: .header-row-suppressed = Riga di intestazione soppressa .height = Altezza @@ -565,7 +565,7 @@ common = .jis = JIS .join-count-in-object-s-joined-into-count-out = JOIN: oggetto/i __ocs_fmt_0__ uniti in __ocs_fmt_1__. .jog-angle-dimjogang = Angolo di jog ° (DIMJOGANG) - .join = Partecipa + .join = Unisci .keep-0-ft-drop-0-in = Mantieni 0 piedi, lascia cadere 0 dentro .keep-recent-files = Conserva i file recenti .kilometers = Chilometri @@ -585,12 +585,12 @@ common = .landscape = Paesaggio .language-title-case = Lingua .language-label-title-case = Lingua: - .layer = Strato + .layer = Livello .layer-matched-to-layer = Livello corrispondente a "__ocs_fmt_0__". .layer-state-name-already-exists =Lo stato del livello "__ocs_fmt_0__" esiste già. .layer-state-name-was-not-found = Lo stato del livello "__ocs_fmt_0__" non è stato trovato. .layer-state-name-cannot-be-empty = Il nome dello stato del livello non può essere vuoto. - .layers = Strati + .layers = Livelli .layout = Disposizione .layout-name-silindi = Layout "__ocs_fmt_0__" eliminato .layout-new-name-created-use-mview-to-add-a-vie = Layout "__ocs_fmt_0__" creato: utilizza MVIEW per aggiungere una finestra @@ -600,13 +600,13 @@ common = .layout-manager = Responsabile del layout .layout-name-cannot-be-empty = Il nome del layout non può essere vuoto. .layout-renamed-old-name-new-name = Layout rinominato: '__ocs_fmt_0__' → '__ocs_fmt_1__' - .leader = Capo + .leader = Leader .leader-format = Formato leader .leader-structure = Struttura del leader .leaders = Leader .leading-trailing = Principale e finale .left = A sinistra - .left-attachment = Allegato sinistro: + .left-attachment = Aggancio a sinistra: .left-to-right-dimtxtdirection = Da sinistra a destra (DIMTXTDIRECTION) .length = Lunghezza .light = Luce @@ -651,7 +651,7 @@ common = .millimeters = Millimetri .model-title-case = Modello .modify = Modifica - .move = Muoviti + .move = Sposta .multiple = Molteplici .multiplier-dimaltf = Moltiplicatore (DIMALTF) .name-title-case = Nome @@ -676,7 +676,7 @@ common = .oblique = Obliquo (°): .oblique-angle = Angolo obliquo .off = Spento - .offset = Compensazione + .offset = Parallela .on = Su .open = Aperto .open-dwg-dxf-not-yet-implemented = Aperto DWG/DXF: non ancora implementato. @@ -720,13 +720,13 @@ common = .pick-an-object = Scegli un oggetto .pinned = Appuntato .place-text-manually-dimupt = Posiziona il testo manualmente (DIMUPT) - .plot = Trama - .plot-style = Stile di trama + .plot = Stampa + .plot-style = Stile di stampa .plot-style-loaded-color-entries = Stile di stampa '__ocs_fmt_0__' caricato (voci colore __ocs_fmt_1__). .plot-style-table-cleared = Tabella stili di stampa cancellata. .plot-style-table-saved-to = Tabella stili di stampa salvata in "__ocs_fmt_0__". - .plot-window-x0-2-y0-2-to-x1-2-y1-2 = Finestra di tracciamento: da __ocs_fmt_0__,__ocs_fmt_1__ a __ocs_fmt_2__,__ocs_fmt_3__ - .plot-window-x1-2-y1-2-to-x2-2-y2-2 = Finestra di tracciamento: da __ocs_fmt_0__,__ocs_fmt_1__ a __ocs_fmt_2__,__ocs_fmt_3__ + .plot-window-x0-2-y0-2-to-x1-2-y1-2 = Finestra di stampa: da __ocs_fmt_0__,__ocs_fmt_1__ a __ocs_fmt_2__,__ocs_fmt_3__ + .plot-window-x1-2-y1-2-to-x2-2-y2-2 = Finestra di stampa: da __ocs_fmt_0__,__ocs_fmt_1__ a __ocs_fmt_2__,__ocs_fmt_3__ .plugin-id-failed-to-load-e = Impossibile caricare il plug-in "__ocs_fmt_0__": __ocs_fmt_1__ .plugin-manager = Gestore dei plugin .point = Punto @@ -776,8 +776,8 @@ common = .restore = Ripristina .retry = Riprova .reverse = Inverso - .right = Giusto - .right-attachment = Allegato destro: + .right = Destra + .right-attachment = Aggancio a destra: .rotate = Ruota .rotation = Rotazione .round-off-dimaltrnd = Arrotondare (DIMALTRND) @@ -794,17 +794,17 @@ common = .slice-select-exactly-one-solid-created-this-se = SLICE: seleziona esattamente un solido creato in questa sessione. .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: il piano non attraversa il solido dal lato mantenuto. .both = Both - .slice-select-at-least-one-target = SLICE: select at least one solid or surface. - .slice-invalid-plane = SLICE: the cutting plane is invalid. - .slice-side-point-on-plane = SLICE: choose a point away from the cutting plane. - .slice-plane-misses-target = SLICE: the cutting plane does not cross every selected object; no object was changed. - .slice-unsupported-intersection = SLICE: the selected geometry has an unsupported surface intersection; no object was changed. - .slice-coincident-plane = SLICE: the cutting plane is coincident with source geometry; no object was changed. - .slice-topology-refused = SLICE: the selected topology could not be split safely; no object was changed. - .slice-result-encode-failed = SLICE: a result could not be encoded; no object was changed. - .slice-result-display-failed = SLICE: a result could not be displayed completely; no object was changed. - .slice-result-commit-failed = SLICE: a result could not be committed; all source objects were retained. - .slice-retained-update-failed = SLICE: a retained result could not be updated. + .slice-select-at-least-one-target = SLICE: seleziona almeno un solido o una superficie. + .slice-invalid-plane = SLICE: il piano di taglio non è valido. + .slice-side-point-on-plane = SLICE: scegli un punto esterno al piano di taglio. + .slice-plane-misses-target = SLICE: il piano di taglio non interseca tutti gli oggetti selezionati; nessuna modifica apportata. + .slice-unsupported-intersection = SLICE: la geometria selezionata ha una intersezione fra superfici non supportata; nessuna modifica apportata. + .slice-coincident-plane = SLICE: il piano di taglio combacia con la geometria selezionata; nessuna modifica apportata. + .slice-topology-refused = SLICE: la topologia selezionata non può essere modificata in modo affidabile; nessuna modifica apportata. + .slice-result-encode-failed = SLICE: il risultato non si riesce a codificare; nessuna modifica apportata. + .slice-result-display-failed = SLICE: il risultato non si riesce a visualizzare; nessuna modifica apportata. + .slice-result-commit-failed = SLICE: impossibile ottenere il risultato; gli oggetti originali sono rimasti come prima. + .slice-retained-update-failed = SLICE: non riesco ad aggiornare il risultato ottenuto. .slice-created-results = SLICE: created __ocs_fmt_0__ result object(s). .splinefit-fit-a-spline-through-n-points = SPLINEFIT: adatta una spline attraverso i punti __ocs_fmt_0__. .splinefit-need-at-least-3-points =SPLINEFIT: servono almeno 3 punti. @@ -842,8 +842,8 @@ common = .send-feedback = Invia feedback .sent-to-printer-printer = Inviato alla stampante: __ocs_fmt_0__ .set-current = Imposta corrente - .shade-plot = Trama dell'ombra - .shadow-flags = Bandiere d'ombra + .shade-plot = Stampa ombreggiatura + .shadow-flags = Opzioni di ombreggiatura .single = Singolo .size = Dimensioni .snap-override-label-next-pick-only = Sostituzione dello snap: __ocs_fmt_0__ (solo scelta successiva). @@ -858,7 +858,7 @@ common = .start =Inizia .start-angle = Angolo iniziale .status = Stato - .stretch = Allungare + .stretch = Stira .style-name-created = Stile "__ocs_fmt_0__" creato. .style-name-deleted = Stile "__ocs_fmt_0__" eliminato. .style-new-already-exists = Lo stile "__ocs_fmt_0__" esiste già. @@ -908,7 +908,7 @@ common = .toggle-object-snap-tracking = Attiva/disattiva il tracciamento dello snap ad oggetto .toggle-ortho = Attiva/disattiva orto .toggle-polar-tracking = Attiva/disattiva il tracciamento polare - .tol-decimals-dimalttd = Tol decimali (DIMALTTD) + .tol-decimals-dimalttd = Tol. decimali (DIMALTTD) .tol-zero-supp-dimalttz = Soppressione zeri della tolleranza (DIMALTTZ) .tol-decimals-dimtdec = Tol. decimali (DIMTDEC) .tol-vert-just-dimtolj =Tol. verde solo (DIMTOLJ) @@ -968,13 +968,13 @@ common = .view-value = Visualizza: __ocs_fmt_0__ .viewport = Visualizzazione .viewport-visual-style-label = Stile di visualizzazione della finestra: __ocs_fmt_0__ - .visual-style = Stile visivo - .visual-style-label = Stile visivo: __ocs_fmt_0__ + .visual-style = Stile visuale + .visual-style-label = Stile visuale: __ocs_fmt_0__ .wblock-saved-block-name = WBLOCK Salvato "__ocs_fmt_0__" → "__ocs_fmt_1__" .wblock-save-failed-error = WBLOCK salvataggio non riuscito: __ocs_fmt_0__ .web = Rete .wedge =Cuneo - .weight = Peso + .weight = Spessore .welcome = Benvenuto .white = Bianco .width = Larghezza @@ -982,7 +982,7 @@ common = .width-factor-label-title-case = Fattore di larghezza: .width-factor-sentence-case = Fattore di larghezza .window = Finestra - .wipeout = Eliminazione + .wipeout = Entità coprente .wireframe = Wireframe .with-text = Con testo .world = Mondo @@ -1012,8 +1012,8 @@ common = .acquired = __ocs_fmt_0__ acquisito. .alias-es-applied = Alias __ocs_fmt_0__ applicati. .group-s-dissolved = Gruppo(i) __ocs_fmt_0__ disciolto. - .layer-s-turned = Strati __ocs_fmt_0__ trasformati in __ocs_fmt_1__ - .layer-s = __ocs_fmt_0__ strato(i) __ocs_fmt_1__ + .layer-s-turned = Livelli __ocs_fmt_0__ trasformati in __ocs_fmt_1__ + .layer-s = __ocs_fmt_0__ livello(i) __ocs_fmt_1__ .mod-shortcuts = ── __ocs_arg_MOD__ Scorciatoie ────────────────────────────────────── .custom-overrides-shortcuts-set = ── Sostituzioni personalizzate (SHORTCUTS SET) ────────────────── .function-keys = ── Tasti funzione ────────────────────────────────────── @@ -1156,7 +1156,7 @@ common = .step-files = { common.files } (STEP) .import-obj-mesh = { common.import }: OBJ · { properties.mesh } .wavefront-obj = Wavefront OBJ - .layer-s-set-to = __ocs_fmt_0__ strato(i) __ocs_fmt_1__ + .layer-s-set-to = __ocs_fmt_0__ livello(i) __ocs_fmt_1__ .isometric-plane = Piano isometrico: __ocs_fmt_0__. .drafting-rotation-reset-to-world-at-0 = Rotazione di disegno reimpostata a 0° nel sistema globale. .length-format-is-now = Il formato della lunghezza ora è __ocs_fmt_0__. @@ -1344,8 +1344,8 @@ common = .reset = Reimposta .lineweight = { common.lineweight-title-case }: .layers-using-aci = { common.layers } (ACI __ocs_fmt_0__) - .layer-dac1d7 = Strato - .layers-e3b816 = Strati + .layer-dac1d7 = Livello + .layers-e3b816 = Livelli .no-layers-use-this-aci-in-the-current-drawing = Nessun layer nel disegno corrente usa questo ACI. .block-palette = Tavolozza blocchi .search-blocks = Cerca blocchi… @@ -1377,7 +1377,7 @@ common = .no-standard-loaded = Nessuno standard caricato. .map = Associa .map-same = Associa nomi uguali - .force-objects-to-bylayer = Forza gli oggetti a ByLayer + .force-objects-to-bylayer = Forza gli oggetti a DaLayer .write-translation-log = Scrivi registro di conversione dei layer .save-mappings = { common.save }: { common.mappings }… .load-mappings = { common.load }: { common.mappings }… @@ -1883,32 +1883,32 @@ common = .dimensional = Dimensionali .show-hide = Mostra/Nascondi - .enter-a-valid-cell-range-such-as-a1-d10 = Enter a valid cell range such as A1:D10. - .named-ranges-require-a-spreadsheet-file = Named ranges require a spreadsheet file. - .select-a-named-range = Select a named range. - .the-named-range-was-not-found = The named range was not found. - .the-named-range-does-not-refer-to-a-worksheet-cell-range = The named range does not refer to a worksheet cell range. - .the-spreadsheet-contains-no-worksheets = The spreadsheet contains no worksheets. - .the-selected-data-source-is-empty = The selected data source is empty. - .the-data-link-no-longer-exists = The data link no longer exists. - .writing-to-this-data-link-is-disabled = Writing to this data link is disabled. - .writing-linked-data-is-supported-for-csv-and-text-sources = Writing linked data is supported for CSV and text sources. + .enter-a-valid-cell-range-such-as-a1-d10 = Indica un intervallo valido, come A1:D10. + .named-ranges-require-a-spreadsheet-file = Intervalli denominati richiedono un foglio elettronico. + .select-a-named-range = Seleziona un intervallo denominato. + .the-named-range-was-not-found = L'intervallo denominato non è stato trovato. + .the-named-range-does-not-refer-to-a-worksheet-cell-range = Il nome indicato non si riferisce a un intervallo sul foglio elettronico. + .the-spreadsheet-contains-no-worksheets = Il foglio elettronico non contiene fogli di lavoro. + .the-selected-data-source-is-empty = La sorgente dati selezionata è vuota. + .the-data-link-no-longer-exists = Il link ai dati non esiste più. + .writing-to-this-data-link-is-disabled = La scrittura sui dati collegati è disattivata. + .writing-linked-data-is-supported-for-csv-and-text-sources = Scrivere sui dati collegati è supportato per i CSV e le sorgenti di testo. .data-source-not-found = Data source not found: __ocs_arg_path__ - .select-at-least-one-property = Select at least one property. + .select-at-least-one-property = Seleziona almeno una proprietà. .select-a-data-link = Select a data link. - .the-selected-data-link-no-longer-exists = The selected data link no longer exists. - .columns-rows-width-and-height-must-be-positive-values = Columns, rows, width, and height must be positive values. - .choose-a-spreadsheet-file = Choose a Spreadsheet File - .spreadsheet-files = Spreadsheet Files - .enter-a-data-link-name = Enter a data link name. - .a-data-link-with-that-name-already-exists = A data link with that name already exists. - .data-link-deleted-existing-table-values-were-retained = Data link deleted; existing table values were retained. - .no-objects-were-selected-when-the-wizard-opened = No objects were selected when the wizard opened. - .add-a-drawing-or-include-the-current-drawing = Add a drawing or include the current drawing. - .no-extractable-objects-were-found-in-the-selected-source = No extractable objects were found in the selected source. - .select-at-least-one-object-type = Select at least one object type. - .choose-at-least-one-output = Choose at least one output. - .data-extraction-settings = Data Extraction Settings + .the-selected-data-link-no-longer-exists = Il link ai dati selezionato non esiste più. + .columns-rows-width-and-height-must-be-positive-values = Colonne, Righe, Altezza e Larghezza devono essere numeri positivi. + .choose-a-spreadsheet-file = Scegli un file di foglio elettronico + .spreadsheet-files = Fogli elettronici + .enter-a-data-link-name = Indica un nome per il link ai dati. + .a-data-link-with-that-name-already-exists = Un link ai dati con questo nome esiste già. + .data-link-deleted-existing-table-values-were-retained = Link ai dati eliminato; i valori nelle tabelle esistenti sono stati mantenuti. + .no-objects-were-selected-when-the-wizard-opened = Nessun oggetto selezionato quando l'autocomposizione è stata attivata. + .add-a-drawing-or-include-the-current-drawing = Aggiungi un disegno o includi il disegno corrente. + .no-extractable-objects-were-found-in-the-selected-source = Nessun oggetto estraibile trovato nella sorgente selezionata. + .select-at-least-one-object-type = Seleziona almeno un tipo di oggetto. + .choose-at-least-one-output = Scegli almeno un output. + .data-extraction-settings = Impostazioni di estrazione dati .add-drawings = Add Drawings .select-objects-or-settings-selected-enter-to-apply = %__ocs_fmt_0__ Select objects or [Settings] (%__ocs_fmt_1__ selected, Enter to apply): .select-objects-or-settings = %__ocs_fmt_0__ Select objects or [Settings]: @@ -1917,121 +1917,121 @@ common = .object-snap-on-f4-3d = 3D Object Snap On (F4) .object-snap-modes-3d = 3D Object Snap modes .a-numeric-shell-offset-distance-is-required = A numeric shell offset distance is required. - .a-selected-edge-no-longer-belongs-to-the-body = A selected edge no longer belongs to the body. - .a-selected-face-could-not-be-resolved = A selected face could not be resolved. - .a-selected-face-does-not-belong-to-the-solid = A selected face does not belong to the solid. - .align-scale-objects-based-on-alignment-points-yes-no-no = ALIGN Scale objects based on alignment points? [Yes / No] : - .annoreset-no-selected-object-has-alternate-scale-representations = ANNORESET: no selected object has alternate scale representations. - .annoreset-synchronized-object-s-from-the-current-scale = ANNORESET: synchronized __ocs_fmt_0__ object(s) from the current scale. - .adaptive-grid = Adaptive grid - .add-drawing-folder = Add Drawing Folder - .add-drawings-2 = Add drawings… - .add-folder = Add folder… - .add-parameter-2 = Add parameter - .all-other-rows = All other rows - .allow-selection-cycling = Allow selection cycling - .allow-writing-to-source-file = Allow writing to source file - .break-specify-second-break-point-or-first-point = BREAK Specify second break point or [First point]: + .a-selected-edge-no-longer-belongs-to-the-body = Uno spigolo selezionato non appartiene più al corpo. + .a-selected-face-could-not-be-resolved = Una delle geometrie delle facce non può essere risolta. + .a-selected-face-does-not-belong-to-the-solid = Una delle facce selezionate non appartiene al solido. + .align-scale-objects-based-on-alignment-points-yes-no-no = ALIGN Scalare gli oggetti in base ai punti di allineamento? [Yes / No] : + .annoreset-no-selected-object-has-alternate-scale-representations = ANNORESET: nessuno degli oggetti selezionati ha scale multiple di rappresentazione. + .annoreset-synchronized-object-s-from-the-current-scale = ANNORESET: sincronizzati __ocs_fmt_0__ oggetto/i dalla scala corrente. + .adaptive-grid = Griglia adattiva + .add-drawing-folder = Aggiungi cartella con disegni + .add-drawings-2 = Aggiungi disegni… + .add-folder = Aggiungi cartella… + .add-parameter-2 = Aggiungi parametro + .all-other-rows = Tutte le altre righe + .allow-selection-cycling = Permetti selezione ciclica + .allow-writing-to-source-file = Attiva scrittura sul file di origine + .break-specify-second-break-point-or-first-point = BREAK Specifica il secondo punto per spezzare o [First point]: .begin = Begin - .chamferedge-select-a-base-face-edge = CHAMFEREDGE: select a base face edge. - .cannot-delete-beyond-this = Cannot delete beyond this. - .cell-range = Cell range - .center-of-face = Center of face + .chamferedge-select-a-base-face-edge = CHAMFEREDGE: seleziona uno spigolo sulla faccia di base. + .cannot-delete-beyond-this = Impossibile eliminare oltre. + .cell-range = Intervallo celle + .center-of-face = Centro della faccia .choose-output = Choose output .clipboard-access-is-unavailable-press-ctrl-c-or-command-c-to-copy-the-selected-history = Clipboard access is unavailable. Press Ctrl+C or Command+C to copy the selected history. - .column-row-settings = Column & row settings - .combine-identical-rows = Combine identical rows - .constraint-settings = Constraint Settings - .constraint-type = Constraint Type - .copied = Copied - .create-a-new-data-link = Create a New Data Link - .create-a-new-data-extraction = Create a new data extraction - .current-drawing = Current drawing + .column-row-settings = Impostazioni colonna & riga + .combine-identical-rows = Combina righe identiche + .constraint-settings = Impostazioni vincoli + .constraint-type = Tipo di vincolo + .copied = Copiato + .create-a-new-data-link = Crea un nuovo link ai dati + .create-a-new-data-extraction = Crea una nuova estrazione dati + .current-drawing = Disegno corrente .cursor-dependent-position = Cursor-dependent position - .dataextraction-specify-insertion-point = DATAEXTRACTION Specify insertion point: - .dataextraction-external-file-created = DATAEXTRACTION: external file created. - .dimbreak-select-dimension-to-add-remove-break-or-multiple = DIMBREAK Select dimension to add/remove break or [Multiple]: - .dimbreak-select-dimensions-selected-press-enter-when-done = DIMBREAK Select dimensions (__ocs_fmt_0__ selected, press Enter when done): - .dimbreak-select-object-to-break-dimension-or-auto-manual-remove-auto = DIMBREAK Select object to break dimension or [Auto/Manual/Remove] : - .dimbreak-specify-first-break-point = DIMBREAK Specify first break point: - .dimbreak-specify-second-break-point = DIMBREAK Specify second break point: - .dimbreak-no-editable-dimensions-were-selected = DIMBREAK: no editable dimensions were selected. - .dimbreak-no-intersections-were-found = DIMBREAK: no intersections were found. - .dimbreak-removed-breaks-from-dimension-s = DIMBREAK: removed breaks from __ocs_fmt_0__ dimension(s). - .dimbreak-select-a-dimension = DIMBREAK: select a dimension. - .dimbreak-updated-dimension-s = DIMBREAK: updated __ocs_fmt_0__ dimension(s). - .dimedit-enter-new-dimension-text-measured-value = DIMEDIT Enter new dimension text : - .dimedit-enter-obliquing-angle-press-enter-for-none = DIMEDIT Enter obliquing angle (press Enter for none): - .dimedit-enter-type-of-dimension-editing-home-new-rotate-oblique-home = DIMEDIT Enter type of dimension editing [Home/New/Rotate/Oblique] : + .dataextraction-specify-insertion-point = DATAEXTRACTION Specifica punto di inserimento: + .dataextraction-external-file-created = DATAEXTRACTION: file esterno creato. + .dimbreak-select-dimension-to-add-remove-break-or-multiple = DIMBREAK Selezionare quota per aggiunta/rimozione interruzione o [Multipla]: + .dimbreak-select-dimensions-selected-press-enter-when-done = DIMBREAK Seleziona quote (__ocs_fmt_0__ selezionate, premi Enter al termine): + .dimbreak-select-object-to-break-dimension-or-auto-manual-remove-auto = DIMBREAK Seleziona oggetti per interrompere le quote o [Auto/Manual/Remove] : + .dimbreak-specify-first-break-point = DIMBREAK Specifica primo punto di interruzione: + .dimbreak-specify-second-break-point = DIMBREAK Specifica secondo punto di interruzione: + .dimbreak-no-editable-dimensions-were-selected = DIMBREAK: nessuna quota modificabile selezionata. + .dimbreak-no-intersections-were-found = DIMBREAK: nessuna intersezione trovata. + .dimbreak-removed-breaks-from-dimension-s = DIMBREAK: rimosse interruzioni da __ocs_fmt_0__ quota(e). + .dimbreak-select-a-dimension = DIMBREAK: seleziona una quota. + .dimbreak-updated-dimension-s = DIMBREAK: aggiornate __ocs_fmt_0__ quota(e). + .dimedit-enter-new-dimension-text-measured-value = DIMEDIT Nuovo testo quota : + .dimedit-enter-obliquing-angle-press-enter-for-none = DIMEDIT Angolo obliquo (Enter per nessuno): + .dimedit-enter-type-of-dimension-editing-home-new-rotate-oblique-home = DIMEDIT Scegli tipo di modifica quota [Home/New/Rotate/Oblique] : .dimedit-select-objects = DIMEDIT Select objects: - .dimedit-specify-angle-for-dimension-text = DIMEDIT Specify angle for dimension text: - .dimedit-no-editable-dimensions-were-selected = DIMEDIT: no editable dimensions were selected. + .dimedit-specify-angle-for-dimension-text = DIMEDIT Selezionare l'angolo del testo di quota: + .dimedit-no-editable-dimensions-were-selected = DIMEDIT: nessuna quota modificabile selezionata. .dimedit-updated-dimension-s = DIMEDIT: updated __ocs_fmt_0__ dimension(s). - .dimjogline-select-dimension-to-add-jog-or-remove = DIMJOGLINE Select dimension to add jog or [Remove]: - .dimjogline-select-dimension-to-remove-jog = DIMJOGLINE Select dimension to remove jog: - .dimjogline-specify-jog-location-or-press-enter = DIMJOGLINE Specify jog location (or press Enter): + .dimjogline-select-dimension-to-add-jog-or-remove = DIMJOGLINE Scegli la quota a cui aggiungere la riduzione o [Remove]: + .dimjogline-select-dimension-to-remove-jog = DIMJOGLINE Scegli la quota a cui rimuovere la riduzione: + .dimjogline-specify-jog-location-or-press-enter = DIMJOGLINE Specifica la posizione della riduzione (o premi Enter): .dimjogline-jog-added = DIMJOGLINE: jog added. .dimjogline-jog-removed = DIMJOGLINE: jog removed. - .dimjogline-select-a-linear-or-aligned-dimension = DIMJOGLINE: select a linear or aligned dimension. - .dimspace-specify-distance-between-dimensions-or-auto-auto = DIMSPACE Specify distance between dimensions or [Auto] : - .dimspace-enter-a-non-negative-spacing-value-or-auto = DIMSPACE: enter a non-negative spacing value or Auto. - .dimspace-select-a-linear-or-aligned-dimension = DIMSPACE: select a linear or aligned dimension. + .dimjogline-select-a-linear-or-aligned-dimension = DIMJOGLINE: selezionare una quota lineare o allineata. + .dimspace-specify-distance-between-dimensions-or-auto-auto = DIMSPACE Specificare la distanza fra le quote o [Auto] : + .dimspace-enter-a-non-negative-spacing-value-or-auto = DIMSPACE: inserisci una spaziatura non negativa o Auto. + .dimspace-select-a-linear-or-aligned-dimension = DIMSPACE: selezionare una quota lineare o allineata. .dimspace-select-one-or-more-linear-or-aligned-dimensions = DIMSPACE: select one or more linear or aligned dimensions. - .dimspace-spacing-value-cannot-be-negative = DIMSPACE: spacing value cannot be negative. - .dimtedit-specify-angle-for-dimension-text = DIMTEDIT Specify angle for dimension text: - .dimtedit-specify-new-location-for-dimension-text-or-left-right-center-home-angle = DIMTEDIT Specify new location for dimension text or [Left/Right/Center/Home/Angle]: - .dimtedit-select-a-dimension = DIMTEDIT: select a dimension. + .dimspace-spacing-value-cannot-be-negative = DIMSPACE: la spaziatura non può essere negativa. + .dimtedit-specify-angle-for-dimension-text = DIMTEDIT Specifica l'angolo per il testo: + .dimtedit-specify-new-location-for-dimension-text-or-left-right-center-home-angle = DIMTEDIT Specifica una nuova posizione per il testo o [Left/Right/Center/Home/Angle]: + .dimtedit-select-a-dimension = DIMTEDIT: seleziona una quota. .divide-marker-s-placed = DIVIDE: __ocs_fmt_0__ marker(s) placed. .dof = DOF: __ocs_fmt_0__ .draworder-selection-reordered-relative-to-reference-objects = DRAWORDER: selection reordered relative to reference objects. - .data-extraction = Data Extraction - .data-extraction-wizard = Data Extraction Wizard - .data-link-manager = Data Link Manager - .data-extraction-2 = Data extraction - .data-link = Data link - .data-link-name = Data link name - .data-rows = Data rows - .define-data-source = Define data source - .display-quick-properties-palette-on-selection = Display Quick Properties palette on selection - .display-selection-cycling-list-box = Display Selection Cycling List Box - .display-coordinate-input-near-crosshairs = Display coordinate input near crosshairs - .display-grid-beyond-limits = Display grid beyond Limits + .data-extraction = Estrazione dati + .data-extraction-wizard = Autocomposizione per l'estrazione dati + .data-link-manager = Gestione link ai dati + .data-extraction-2 = Estrazione dati + .data-link = Link ai dati + .data-link-name = Nome del link ai dati + .data-rows = Righe dei dati + .define-data-source = Definizione della sorgente dati + .display-quick-properties-palette-on-selection = Mostra proprietà rapide alla selezione + .display-selection-cycling-list-box = Mostra box elenco per la selezione ciclica + .display-coordinate-input-near-crosshairs = Mostra le coordinate vicino al mirino + .display-grid-beyond-limits = Mostra griglia oltre ai limiti .display-selection-candidate-list-box-on-click = Display selection candidate list box on click - .distance-and-angle-tolerances-must-be-non-negative-numbers = Distance and angle tolerances must be non-negative numbers. + .distance-and-angle-tolerances-must-be-non-negative-numbers = Distanze e tolleranze degli angoli non possono essere numeri negativi. .distance1-distance2 = Distance1 = __ocs_fmt_0__, Distance2 = __ocs_fmt_1__ .draw-section = Draw section .drawing-table = Drawing table .drawings-folders = Drawings / folders .dynamic-prompts = Dynamic Prompts - .edit-an-existing-data-extraction = Edit an existing data extraction + .edit-an-existing-data-extraction = Modifica una estrazione dati esistente .editing-weighted-or-tolerance-fitted-interpolation-data-is-not-supported = Editing weighted or tolerance-fitted interpolation data is not supported. - .empty-table = Empty table - .enable-pointer-input-f12 = Enable Pointer Input (F12) - .enable-dimension-input-fields = Enable dimension input fields + .empty-table = Tabella vuota + .enable-pointer-input-f12 = Attiva input puntatore (F12) + .enable-dimension-input-fields = Attiva campi di input sulle quote .enter-a-body-editing-option-shell-exit = Enter a body editing option [Shell/eXit]: .enter-a-solid-editing-option-body-exit = Enter a solid editing option [Body/eXit]: - .entire-sheet = Entire sheet - .equal-x-and-y-spacing = Equal X and Y spacing - .external-file = External file - .extraction-settings = Extraction settings - .fillet-select-2d-polyline-r = FILLET Select 2D polyline [R=__ocs_fmt_0__]: - .fillet-select-first-object-or-polyline-radius-r = FILLET Select first object or [Polyline/Radius] [R=__ocs_fmt_0__]: - .fillet-specify-fillet-radius-or-first-point = FILLET Specify fillet radius or first point <__ocs_fmt_0__>: - .fillet-specify-second-point-for-radius = FILLET Specify second point for radius: + .entire-sheet = Foglio intero + .equal-x-and-y-spacing = Spaziature X e Y uguali + .external-file = File esterno + .extraction-settings = Impostazioni di estrazione + .fillet-select-2d-polyline-r = FILLET Seleziona una polilinea 2D [R=__ocs_fmt_0__]: + .fillet-select-first-object-or-polyline-radius-r = FILLET Selezionare primo oggetto o [Polyline/Radius] [R=__ocs_fmt_0__]: + .fillet-specify-fillet-radius-or-first-point = FILLET Specificare raggio di raccordo o primo punto <__ocs_fmt_0__>: + .fillet-specify-second-point-for-radius = FILLET Specificare secondo punto per il raggio: .file = File - .file-not-found-check-the-saved-path = File not found — check the saved path. - .first-row = First row + .file-not-found-check-the-saved-path = File non trovato — controllare il percorso salvato. + .first-row = Prima riga .fit-data-cannot-be-purged-without-a-stored-control-curve = Fit data cannot be purged without a stored control curve. .fit-data-does-not-define-a-valid-control-curve = Fit data does not define a valid control curve. .fit-points-do-not-define-a-valid-spline = Fit points do not define a valid spline. .formula = Formula - .full-path = Full path + .full-path = Percorso completo .fully-constrained-no-remaining-degrees-of-freedom = Fully constrained — no remaining degrees of freedom - .grid-on-f7 = Grid On (F7) - .grid-x-spacing = Grid X spacing: - .grid-y-spacing = Grid Y spacing: - .grid-behavior = Grid behavior - .grid-spacing = Grid spacing + .grid-on-f7 = Griglia On (F7) + .grid-x-spacing = Spaziatura X: + .grid-y-spacing = Spaziatura Y: + .grid-behavior = Aspetto griglia + .grid-spacing = Spaziatura griglia .hatchedit-pattern-scale-angle-style-origin-properties-color-layer-transparency-draw-order-associate-disassociate-annotative-recreate-boundary-separate-hatches-properties = HATCHEDIT Pattern:%__ocs_fmt_0__ Scale:%__ocs_fmt_1__ Angle:%__ocs_fmt_2__ [Style/Origin/Properties/COlor/LAyer/Transparency/DRaw order/ASsociate/DIsassociate/ANnotative/recreate Boundary/separate Hatches] : .intersect-created-result-object-s = INTERSECT: created %__ocs_fmt_0__ result object(s). .intersect-no-positive-area-common-result-original-object-s-retained = INTERSECT: no positive-area common result; %__ocs_fmt_0__ original object(s) retained. @@ -2040,17 +2040,17 @@ common = .intersect-the-selected-coincident-geometry-is-ambiguous = INTERSECT: the selected coincident geometry is ambiguous. .intersect-the-selected-objects-do-not-have-a-supported-coplanar-surface-intersection = INTERSECT: the selected objects do not have a supported coplanar surface intersection. .intersect-the-selected-topology-could-not-be-intersected-safely = INTERSECT: the selected topology could not be intersected safely. - .include-current-drawing = Include current drawing - .include-subfolders = Include subfolders - .increment-angle = Increment angle: + .include-current-drawing = Includi disegno corrente + .include-subfolders = Includi sottocartelle + .increment-angle = Angolo incremento: .indicator-fill-color = Indicator Fill Color .indicator-transparency = Indicator Transparency - .insert-table = Insert Table - .insert-data-extraction-table-into-drawing = Insert data extraction table into drawing - .insert-options = Insert options - .insert-table-after-saving-link = Insert table after saving link - .insertion-behavior = Insertion behavior - .keep-table-updated-to-source-formatting = Keep table updated to source formatting + .insert-table = Inserisci tabella + .insert-data-extraction-table-into-drawing = Inserisci tabella con i dati estratti nel disegno + .insert-options = Opzioni inserimento + .insert-table-after-saving-link = Inserisci tabella dopo aver salvato il link + .insertion-behavior = Comportamento inserimento + .keep-table-updated-to-source-formatting = Aggiorna tabella con formattazione di origine .knot = Knot .leader-enter-an-annotation-option-none-mtext = LEADER Enter an annotation option [None/Mtext]: .leader-enter-first-line-of-annotation-text-or-options = LEADER Enter first line of annotation text or : @@ -2071,30 +2071,30 @@ common = .measure-0-markers-placed = MEASURE: 0 markers placed. .measure-marker-s-placed = MEASURE: __ocs_fmt_0__ marker(s) placed. .maintain-associativity = Maintain associativity - .major-line-every = Major line every: - .midpoint-on-edge = Midpoint on edge - .moment-of-inertia = Moment of inertia + .major-line-every = Linea principale ogni: + .midpoint-on-edge = Punto medio su spigolo + .moment-of-inertia = Momento di inerzia .more-options = More options - .move-down = Move Down - .move-up = Move Up + .move-down = Sposta giù + .move-up = Sposta su .named-parameters = Named Parameters - .named-range = Named range - .nearest-to-face = Nearest to face - .no-path = No path + .named-range = Intervallo con nome + .nearest-to-face = Vicino sulla faccia + .no-path = Nessun percorso .no-preview-available = No preview available. .no-surfaces-selected = No surfaces selected. .objectscale-the-selected-objects-already-have-the-current-scale-or-do-not-support-annotation-scales = OBJECTSCALE: the selected objects already have the current scale, or do not support annotation scales. .objectscale-the-selected-objects-do-not-have-the-current-scale = OBJECTSCALE: the selected objects do not have the current scale. .objectscale-the-current-scale-on-object-s = OBJECTSCALE: __ocs_fmt_0__ the current scale on __ocs_fmt_1__ object(s). .object-snap-on-f3 = Object Snap On (F3) - .object-snap-tracking-on-f11 = Object Snap Tracking On (F11) - .object-snap-modes-2 = Object Snap modes + .object-snap-tracking-on-f11 = Traccia snap ad oggetti On (F11) + .object-snap-modes-2 = Modalità snap ad oggetti .on-by-default-turn-off-to-show-just-the-constraint-glyph-in-the-viewport-enough-to-see-that-a-constraint-is-present-without-the-driven-value-or-named-parameter-text-covering-nearby-geometry = On by default. Turn off to show just the constraint glyph in the viewport — enough to see that a constraint is present — without the driven value or named parameter text covering nearby geometry. .one-or-more-parametric-constraints-conflict-or-are-redundant-click-to-remove-one-and-re-solve = One or more parametric constraints conflict or are redundant Click to remove one and re-solve - .ortho-mode-f8 = Ortho mode (F8) - .output-data-to-external-file = Output data to external file - .output-file = Output file + .ortho-mode-f8 = Orto (F8) + .output-data-to-external-file = Output dei dati in un file esterno + .output-file = File di output .pedit-convert-lines-and-arcs-to-polylines-yes-no-yes = PEDIT Convert lines and arcs to polylines [Yes/No] : .pedit-enter-polyline-linetype-generation-option-on-off = PEDIT Enter polyline linetype generation option [ON/OFF]: .pedit-select-polyline-or-a-line-arc-to-convert-or-multiple = PEDIT Select polyline (or a line/arc to convert) or [Multiple]: @@ -2104,10 +2104,10 @@ common = .parameters = Parameters .perpendicular-objects-must-share-an-intersection-point = Perpendicular objects must share an intersection point .perpendicular-to-face = Perpendicular to face - .plane = Plane + .plane = Piano .pointer-input = Pointer Input - .polar-angle-settings = Polar Angle Settings - .polar-tracking-on-f10 = Polar Tracking On (F10) + .polar-angle-settings = Impostazioni angolo polare + .polar-tracking-on-f10 = Tracciamento polare On (F10) .polar-tracking-guides-cursor-movement-along-specified-angles-ortho-mode-constrains-movement-to-orthogonal-axes = Polar Tracking guides cursor movement along specified angles. Ortho mode constrains movement to orthogonal axes. .press-enter-to-accept-the-chamfer-or-distance = Press Enter to accept the chamfer or [Distance]: .principal-directions = Principal directions @@ -2120,7 +2120,7 @@ common = .recent-colors = Recent Colors .reference-changes-are-not-available-on-web-the-reference-list-is-read-only = Reference changes are not available on web — the reference list is read-only. .refine-data = Refine data - .relative-path = Relative path + .relative-path = Percorso relativo .remaining-degrees-of-freedom-in-the-current-parametric-constraint-scope = Remaining degrees of freedom in the current parametric constraint scope .requires-an-integer-between-0-and-99 = Requires an integer between 0 and 99. .requires-numeric-distance-or-two-points = Requires numeric distance or two points. @@ -2182,21 +2182,21 @@ common = .select-spline-fit-polylines = Select spline-fit polylines: .select-surfaces-to-thicken = Select surfaces to thicken: .selected-objects-in-current-drawing = Selected objects in current drawing - .set-cell-styles = Set cell styles - .sheet = Sheet + .set-cell-styles = Imposta stile celle + .sheet = Foglio .show-associativity = Show associativity .show-command-prompting-and-command-input-near-crosshairs = Show command prompting and command input near crosshairs - .show-count-column = Show count column + .show-count-column = Mostra colonna conteggio .show-cycling-badge-when-objects-overlap = Show cycling badge when objects overlap .show-name-column = Show name column .show-values-and-parameter-names-on-constraint-markers = Show values and parameter names on constraint markers .slice = Slice .slice-depth = Slice Depth .snap-on-f9 = Snap On (F9) - .snap-x-spacing = Snap X spacing: - .snap-y-spacing = Snap Y spacing: - .snap-spacing = Snap spacing - .snap-type = Snap type + .snap-x-spacing = Dist. Snap X: + .snap-y-spacing = Dist. Snap Y: + .snap-spacing = Spaziatura Snap + .snap-type = Tipo Snap .specify-distance1-or-expression = Specify Distance1 or [Expression] <__ocs_fmt_0__>: .specify-distance2-or-expression = Specify Distance2 or [Expression] <__ocs_fmt_0__>: .specify-insertion-point = Specify insertion point @@ -2208,10 +2208,10 @@ common = .specify-thickness = Specify thickness .specify-window = Specify window .spline-cannot-be-converted-within-the-requested-precision = Spline cannot be converted within the requested precision. - .spreadsheet-links = Spreadsheet Links - .start-from-data-extraction = Start from data extraction - .start-from-data-link = Start from data link - .start-from-empty-table = Start from empty table + .spreadsheet-links = Collegamenti foglio elettronico + .start-from-data-extraction = Basata su estrazione dati + .start-from-data-link = Basata su link dati + .start-from-empty-table = Inizia con tabella vuota .state2 = State2 .static-quadrant-location = Static quadrant location .status-linked = Status: Linked @@ -2338,13 +2338,13 @@ command = .command-aliases = Alias di comando .key = Chiave .keyboard-shortcuts = Scorciatoie da tastiera - .layer-0-and-the-current-layer-can-t-be-deleted = Il livello "0" e il livello corrente non possono essere eliminati: saltati. + .layer-0-and-the-current-layer-can-t-be-deleted = Il layer "0" e il layer corrente non possono essere eliminati: saltati. .no-drawing-open-use-new-or-open-to-start-a-dra = Nessun disegno aperto. Utilizzare NEW o OPEN per iniziare un disegno. - .object-is-on-locked-layer-layer-unlock-the-lay = L'oggetto si trova sul livello bloccato "__ocs_fmt_0__": sblocca il livello per modificarne gli attributi. + .object-is-on-locked-layer-layer-unlock-the-lay = L'oggetto si trova sul layer bloccato "__ocs_fmt_0__": sblocca il layer per modificarne gli attributi. .pan-ended = PAN è terminato. .polyline-broken-at-vertex = Polilinea spezzata al vertice. .rename-select-the-object-type-to-rename = RENAME Selezionare il tipo di oggetto da rinominare: - .rename-type-enter-the-current-name = RENAME __ocs_arg_type__ Enter il nome attuale: + .rename-type-enter-the-current-name = RENAME __ocs_arg_type__ Indica il nome attuale: .rename-type-rename-old-to = RENAME __ocs_arg_type__ Rinomina "__ocs_arg_old__" in: .specify-new-vertex-location = Specificare la nuova posizione del vertice: .type-shortcuts-set-key-cmd-to-add-custom-short = Digita SHORTCUTS SET per aggiungere scorciatoie personalizzate. @@ -2377,7 +2377,7 @@ file = .files-you-open-will-show-up-here = I file aperti verranno visualizzati qui. .new-drawing = Nuovo disegno .no-plot-style-table-loaded-load-or-create-one = Nessuna tabella stili di stampa caricata. Caricane o creane uno prima. - .nothing-to-plot-model-space-contains-no-printa = Niente da tracciare: lo spazio modello non contiene geometria stampabile. + .nothing-to-plot-model-space-contains-no-printa = Niente da stampare: lo spazio modello non contiene geometria stampabile. .oops-nothing-to-restore = OOPS: niente da ripristinare. .oops-restored-n-object-s = OOPS: oggetto/i __ocs_fmt_0__ ripristinato/i. .open-cad-studio-ready = Open CAD Studio pronto. @@ -2391,7 +2391,7 @@ file = .opened-read-only-against-other-editors-error = Aperto in sola lettura contro altri editor: __ocs_fmt_0__ .page-setup-applied-to-the-layout = Impostazione della pagina applicata al layout. .path-type = Tipo di percorso: - .plot-area-is-empty-pick-a-larger-window = L'area del tracciato è vuota. Scegli una finestra più grande. + .plot-area-is-empty-pick-a-larger-window = L'area di stampa è vuota. Scegli una finestra più grande. .plot-style-aci-aci-updated = Stile di stampa ACI __ocs_fmt_0__ aggiornato. .plot-style-table-is-not-loaded = La tabella stili di stampa '__ocs_fmt_0__' non è caricata. .quick-print-works-in-model-space = La stampa rapida funziona nello spazio modello. @@ -2511,7 +2511,7 @@ inquiry = .usage-cal-expression-e-g-cal-2-3-4 = Utilizzo: CAL ad es. CAL (2+3)*4 .usage-qselect-type-layer-color-linetype-value = Utilizzo: QSELECT TYPE|LAYER|COLOR|LINETYPE .x-x-y-y-z-z =X = __ocs_arg_x__, Y = __ocs_arg_y__, Z = __ocs_arg_z__ - .type-name-handle-x-layer-color-lt = __ocs_fmt_0__ Maniglia:__ocs_fmt_1__ Strato:__ocs_fmt_2__ Colore:__ocs_fmt_3__ LT:__ocs_fmt_4____ocs_fmt_5__ + .type-name-handle-x-layer-color-lt = __ocs_fmt_0__ Maniglia:__ocs_fmt_1__ Livello:__ocs_fmt_2__ Colore:__ocs_fmt_3__ LT:__ocs_fmt_4____ocs_fmt_5__ .area-4-perimeter-4-centroid-4-4 = __ocs_fmt_0__ Area=__ocs_fmt_1__ Perimetro=__ocs_fmt_2__ Centroide=(__ocs_fmt_3__,__ocs_fmt_4__) draw = @@ -2588,7 +2588,7 @@ draw = .axis = Asse .axis-end =Asse, Fine .boundary-pick-internal-point-miss = BOUNDARY Selezionare punto interno:__ocs_arg_miss__ - .boundary = Confine + .boundary = Contorno .centerline-select-first-line = CENTERLINE Selezionare la prima riga: .centerline-select-second-line = CENTERLINE Selezionare la seconda riga: .circle-specify-center-point = CIRCLE Specificare il punto centrale: @@ -2607,8 +2607,8 @@ draw = .circle-ttt-select-second-tangent-object = CIRCLE TTT Selezionare il secondo oggetto tangente: .circle-ttt-select-third-tangent-object = CIRCLE TTT Seleziona il terzo oggetto tangente: .copy-specify-base-point-count-objects = COPY Specificare il punto base [oggetti __ocs_arg_count__]: - .center-line = Linea centrale - .center-mark = Marchio centrale + .center-line = Linea d'asse + .center-mark = Contrassegno del centro .center-axes = Centro, Assi .center-diameter = Centro, Diametro .center-radius = Centro, Raggio @@ -2634,7 +2634,7 @@ draw = .ellipse-arc-specify-major-axis-endpoint = ELLIPSE ARC Specificare il punto finale dell'asse principale: .ellipse-arc-specify-minor-axis-point-or-type-h = ELLIPSE ARC Specificare il punto dell'asse minore o tipo mezza lunghezza [maggiore r=__ocs_arg_r__]: .ellipse-arc-specify-start-angle-point-or-type = ELLIPSE ARC Specificare il punto dell'angolo iniziale o digitare i gradi: - .ellipse-arc = Arco dell'ellisse + .ellipse-arc = Arco di ellisse .ext-line-color-aci-dimclre = Colore linea esterna ACI (DIMCLRE) .ext-line-weight-dimlwe = Spessore linea esterno (DIMLWE) .gradient-boundary-point-1 = GRADIENT Punto di confine 1: @@ -2645,7 +2645,7 @@ draw = .hatch-pick-internal-point-miss = HATCH Selezionare punto interno:__ocs_arg_miss__ .hatch-point-n = HATCH Punto __ocs_arg_n__: .hatchedit-pattern-name-scale-scale-angle-angle = HATCHEDIT Modello:__ocs_arg_name__ Scala:__ocs_arg_scale__ Angolo:__ocs_arg_angle__ [P / S / A | Enter da applicare]: - .hatchedit-select-hatch = HATCHEDIT Seleziona portello: + .hatchedit-select-hatch = HATCHEDIT Seleziona tratteggio: .hatchedit-selected-entity-is-not-a-hatch = HATCHEDIT: l'entità selezionata non è un tratteggio. .helix-enter-number-of-turns-3 = HELIX Enter numero di giri <3>: .helix-specify-base-radius = HELIX Specificare il raggio della base: @@ -2661,7 +2661,7 @@ draw = .line-specify-next-point-undo =LINE Specificare il punto successivo [Undo]: .line-color-aci = Colore linea (ACI): .line-type = Tipo di linea: - .line-weight = Peso della linea: + .line-weight = Spessore di linea: .linear-factor-dimlfac = Fattore lineare (DIMLFAC) .literal-spaces-space-stays-in-the-line-instead = Spazi letterali: Space rimane nella riga invece di eseguire il comando (come digitare un '>' iniziale). Rimane acceso finché non viene disattivato. .mline-enter-scale-factor = MLINE Enter fattore di scala: @@ -2803,8 +2803,8 @@ draw = .trace-specify-next-point-undo = TRACE Specificare il punto successivo [Undo]: .trace-specify-start-point = TRACE Specificare il punto iniziale: .trace-specify-trace-width-w = TRACE Specificare la larghezza della traccia <__ocs_arg_w__>: - .tan-tan-radius = Abbronzatura, Abbronzatura, Raggio - .tan-tan-tan = Abbronzatura, abbronzatura, abbronzatura + .tan-tan-radius = Tangente, Tangente, Raggio + .tan-tan-tan = Tangente, Tangente, Tangente .trace = Traccia .ttt = Ttt .usage-attdisp-on-off-normal = Utilizzo: ATTDISP ON | OFF | NORMAL @@ -3022,6 +3022,8 @@ modify = .offset-n-click-through-point-or-multiple = OFFSET__ocs_arg_n__ Punto di passaggio o [Multiple]: .offset-n-multiple-click-next-side-distance-d = OFFSET__ocs_arg_n__ Clic multiplo sul lato successivo [distanza __ocs_arg_d__]: .offset-n-multiple-click-next-through-point = OFFSET__ocs_arg_n__ Clic multiplo sul punto successivo: + .offset-n-click-through-point-or-type-distance = OFFSET__ocs_arg_n__ Fare clic sul punto di passaggio o digitare la distanza, negativa per l'altro lato [Multiple/Erase/Layer] <__ocs_arg_d__>: + .offset-n-multiple-click-through-point-or-type-distance = OFFSET__ocs_arg_n__ Multiplo Fare clic sul punto di passaggio o digitare la distanza, negativa per l'altro lato [Erase/Layer] <__ocs_arg_d__>: .offset-dimexo = Spostamento (DIMEXO) .overall-scale-dimscale = Scala totale (DIMSCALE) .pedit-enter-option = Opzione PEDIT Enter: @@ -3029,7 +3031,7 @@ modify = .pedit-select-polyline-or-a-line-arc-to-convert = PEDIT Seleziona la polilinea (o una linea/arco da convertire): .pedit-specify-new-width = PEDIT Specificare la nuova larghezza: .pedit-join-select-objects-to-join-count-picked = PEDIT Unisci Seleziona gli oggetti da unire (__ocs_arg_count__ selezionato), Enter da unire: - .path-array = Matrice di percorsi + .path-array = Matrice su percorso .polar-array = Matrice polare .refclose-save-discard-save = REFCLOSE [Salva/Elimina] : .refedit-select-block-reference-to-edit = REFEDIT Selezionare il riferimento di blocco da modificare: @@ -3068,7 +3070,7 @@ modify = .trim-edge-fence-pick-points-placed-enter-trim = TRIM__ocs_fmt_0__ Recinzione: punti di selezione [__ocs_fmt_1__ posizionato, Enter = taglio incrociato]: .through = Attraverso .tol-scale-dimtfac = Tol. scala (DIMTFAC) - .trim = Tagliare + .trim = Taglia .exit = esci .area-keyword = Area .displacement = Spostamento @@ -3076,7 +3078,7 @@ modify = .array-keyword = Serie .use-first-point-as-displacement = Primo punto come spostamento .offset-erase-source-object-after-offsetting = OFFSET Cancellare l'oggetto di origine dopo l'offset? [Yes/No] : - .offset-enter-layer-option-for-offset-objects = OFFSET Layer degli oggetti di offset [Current/Source] : + .offset-enter-layer-option-for-offset-objects = OFFSET Livello per gli oggetti di offset [Current/Source] : groups = .group-enter-group-name-name = GROUP Enter nome del gruppo [__ocs_arg_name__]: @@ -3084,11 +3086,11 @@ groups = .ungroup = Separa layers = - .n-layers = Strati __ocs_arg_n__ + .n-layers = Layer __ocs_arg_n__ .n-saved-layers = __ocs_arg_n__ livelli salvati .active-ucs-named = UCS attivo: __ocs_fmt_0__ | Denominato: __ocs_fmt_1__ .active-ucs-no-named-ucss-defined = UCS attivo: __ocs_fmt_0__ | Nessun UCS con nome definito. - .all-layers-thawed = Tutti gli strati si sono scongelati. + .all-layers-thawed = Tutti i livelli si sono scongelati. .all-layers-turned-on = Tutti i livelli sono accesi. .changes-affect-the-saved-state-only-the-drawin = Le modifiche influiscono solo sullo stato salvato; il disegno rimane invariato fino al ripristino. .choose-new-to-capture-the-current-layer-settin = Scegli Nuovo per acquisire le impostazioni del livello corrente. @@ -3113,7 +3115,7 @@ layers = .isolated-n-object-s-unisolateobjects-to-restor = Oggetto(i) __ocs_fmt_0__ isolato(i). UNISOLATEOBJECTS da ripristinare. .isolation-ended-all-objects-shown = Isolamento terminato: tutti gli oggetti visualizzati. .laydel-layer-to-delete = Livello LAYDEL da eliminare: - .laydel-deleted-layer-layer-and-n-object-s = LAYDEL: layer eliminato "__ocs_fmt_0__" e oggetto(i) __ocs_fmt_1__. + .laydel-deleted-layer-layer-and-n-object-s = LAYDEL: livello eliminato "__ocs_fmt_0__" e oggetto(i) __ocs_fmt_1__. .laydel-layer-0-cannot-be-deleted = LAYDEL: il livello "0" non può essere eliminato. .laydel-no-layer-named-name = LAYDEL: nessun livello denominato "__ocs_fmt_0__". .layer-already-exists = LAYER: '__ocs_fmt_0__' esiste già. @@ -3121,9 +3123,9 @@ layers = .layer-created = LAYER: '__ocs_fmt_0__' creato. .layer-not-found = LAYER: '__ocs_fmt_0__' non trovato. .layer-current-layer-set-to = LAYER: livello corrente impostato su '__ocs_fmt_0__'. - .layer-layers-frozen = LAYER: strati congelati. + .layer-layers-frozen = LAYER: livelli congelati. .layer-layers-locked = LAYER: livelli bloccati. - .layer-layers-thawed = LAYER: strati scongelati. + .layer-layers-thawed = LAYER: livelli scongelati. .layer-layers-turned-off = LAYER: livelli disattivati. .layer-layers-turned-on = LAYER: livelli attivati. .layer-layers-unlocked = LAYER: livelli sbloccati. @@ -3131,8 +3133,8 @@ layers = .layerstate-no-saved-state-named-arg = LAYERSTATE: nessuno stato salvato denominato "__ocs_fmt_0__". .layerstate-restored-arg-n-layer-s = LAYERSTATE: ripristinato "__ocs_fmt_0__" (livelli __ocs_fmt_1__). .layerstate-saved-arg = LAYERSTATE: salvato "__ocs_fmt_0__". - .layiso-isolated-layer-s = LAYISO: strato/i __ocs_fmt_0__ isolato/i. - .layiso-select-entities-on-the-layers-to-isolat = LAYISO: seleziona prima le entità sui layer da isolare. + .layiso-isolated-layer-s = LAYISO: livello/i __ocs_fmt_0__ isolato/i. + .layiso-select-entities-on-the-layers-to-isolat = LAYISO: seleziona prima le entità sui livelli da isolare. .laymatch-select-objects-to-change-layer = LAYMATCH Seleziona gli oggetti per cambiare livello: .laymatch-select-source-object-to-match-layer-f = LAYMATCH Seleziona l'oggetto sorgente per far corrispondere il livello da: .laymrg-source-layer-merged-away = Livello sorgente LAYMRG (unito): @@ -3140,21 +3142,21 @@ layers = .laymrg-layer-0-cannot-be-merged-away = LAYMRG: il livello "0" non può essere unito. .laymrg-merged-src-into-dst-moved-object-s = LAYMRG: unito "__ocs_fmt_0__" in "__ocs_fmt_1__" (oggetto(i) __ocs_fmt_2__). .laymrg-source-and-target-are-the-same-layer = LAYMRG: sorgente e destinazione sono lo stesso livello. - .laymrg-source-and-target-layers-must-both-exis = LAYMRG: i layer di origine e di destinazione devono esistere entrambi. + .laymrg-source-and-target-layers-must-both-exis = LAYMRG: i livelli di origine e di destinazione devono esistere entrambi. .layuniso-all-layers-restored = LAYUNISO: tutti i livelli ripristinati. .layer-freeze = Congelamento dei livelli .layer-lock = Blocco livello .layer-manager = Gestore livelli - .layer-off = Strato disattivato + .layer-off = Livello disattivato .layer-state-manager = Gestore dello stato dei livelli .layer-unlock = Sblocco livello .layer-state-name = Nome dello stato del livello - .layer-states-are-stored-inside-the-drawing-and = Gli stati dei layer vengono memorizzati all'interno del disegno e rimangono disponibili dopo averlo riaperto. - .layer-s-frozen = Strato/i congelato/i. - .layer-s-locked = Strati bloccati. - .layer-s-turned-off = Strati disattivati. - .layer-s-unlocked = Strati sbloccati. - .layers = Strati: __ocs_fmt_0__ + .layer-states-are-stored-inside-the-drawing-and = Gli stati dei livelli vengono memorizzati all'interno del disegno e rimangono disponibili dopo averlo riaperto. + .layer-s-frozen = Livello/i congelato/i. + .layer-s-locked = Livelli bloccati. + .layer-s-turned-off = Livelli disattivati. + .layer-s-unlocked = Livelli sbloccati. + .layers = Livelli: __ocs_fmt_0__ .lock = Bloccare .mleaderstyle-style-name-prop-val-3 = MLEADERSTYLE: '__ocs_fmt_0__'.__ocs_fmt_1__ = __ocs_fmt_2__ .mleaderstyle-already-exists = MLEADERSTYLE: '__ocs_fmt_0__' esiste già. @@ -3178,7 +3180,7 @@ layers = .new-vp = Nuovo VP .new-vp-freeze = Nuovo congelamento VP .new-layer-state = Nuovo stato del livello - .no-dim-styles-defined = Nessuno stile attenuato definito. + .no-dim-styles-defined = Nessuno stile quota definito. .no-hidden-objects = Nessun oggetto nascosto. .no-layer-states-in-this-drawing =Nessuno stato layer in questo disegno .no-matching-layer-states = Nessuno stato di livello corrispondente @@ -3211,7 +3213,7 @@ layers = Stili tabella: __ocs_fmt_0__ .text-styles = Stili di testo: __ocs_fmt_0__ - .thaw-all-layers = Scongela tutti gli strati + .thaw-all-layers = Scongela tutti i livelli .turn-all-layers-on = Attiva tutti i livelli .ucs-name-not-found = UCS '__ocs_fmt_0__' non trovato. .ucs-activated = UCS '__ocs_fmt_0__' attivato. @@ -3361,10 +3363,10 @@ annotate = .dimspace-select-dimension-to-space-count-selec = DIMSPACE Seleziona la dimensione dello spazio (__ocs_arg_count__ selezionato, Enter al termine): .dimtedit-select-dimension = DIMTEDIT Seleziona dimensione: .dimtedit-specify-new-location-for-dimension-te = DIMTEDIT Specificare la nuova posizione per il testo della quota: - .dim-break = Pausa oscura - .dim-edit = Modifica attenuata + .dim-break = Interruzione quota + .dim-edit = Modifica quota .dim-space = Dimmer Space - .dim-text-edit = Modifica testo attenuato + .dim-text-edit = Modifica testo quota .explode-no-explodable-objects-selected = EXPLODE: nessun oggetto esplodibile selezionato. .edit-text = Modifica testo .extents-all = Estende tutto @@ -3412,7 +3414,7 @@ annotate = .qdim-made-dimensions-created = Dimensioni QDIM __ocs_fmt_0__ create. .qdim-endpoints-collapse-to-a-single-position = QDIM: gli endpoint collassano in un'unica posizione. .qdim-no-dimensionable-endpoints-in-the-selecti = QDIM: nessun punto finale dimensionabile nella selezione. - .quick-dim = Oscuramento rapido + .quick-dim = Quota rapida .requires-0-or-1-or-multiple-or-single = Richiede 0 OR 1 OR MULTIPLE OR SINGLE. .requires-single-or-multiple = Richiede singolo o multiplo. .scale-edit = Modifica scala @@ -3641,7 +3643,7 @@ insert = .attsync-block-name-to-sync = ATTSYNC nome del blocco da sincronizzare: .attsync-no-block-named-arg = ATTSYNC: nessun blocco denominato "__ocs_fmt_0__". .attsync-synchronised-synced-insert-s-of-block = ATTSYNC: inserimento/i __ocs_fmt_0__ sincronizzato/i di "__ocs_fmt_1__" rispetto alle definizioni degli attributi __ocs_fmt_2__. - .adjust = Regolare + .adjust = Regolazione .array-insert = Inserimento di matrice .attach = Allega .attach-xref = Allega XREF @@ -3729,8 +3731,8 @@ insert = Sottofondi .synchronize = Sincronizza .underlay-layers = - Sottofondo - Strati + Livelli + Sottoposti .usage-attsync-block-name-blocks = Utilizzo: ATTSYNC . Blocchi: __ocs_fmt_0__ .usage-base-x-y-z = Utilizzo: BASE [z] .write-block = Scrivi blocco @@ -4092,6 +4094,12 @@ insert = .field-select-object = Select object .field-expression = Field expression .field-help = Inserts a field: text that updates from the drawing, the date or another source. + .field-average = Average + .field-sum = Sum + .field-object-name = Object name + .field-block-placeholder = Block placeholder + .field-block-editor-only = Only accessible in the block editor + .field-block-reference-property = Block reference property .field-cat-linked = Linked .field-current-precision = Current precision .field-evaluate = Evaluate @@ -4432,7 +4440,7 @@ view = .mview-switch-to-a-paper-space-layout-first = MVIEW: passa prima al layout dello spazio carta. .model-viewports = Viste del modello .mono = Mono - .named = Nominato + .named = Con nome .navigate = Navigare .no-annotation-scale-named-arg = Nessuna scala di annotazione denominata __ocs_fmt_0__. .no-annotation-scales-defined = Nessuna scala di annotazione definita. @@ -4447,13 +4455,13 @@ view = .opening-feedback-page = Apertura della pagina di feedback... .opening-release-notes = Note sulla versione di apertura... .pan-drag-with-the-left-mouse-button-press-esc =PAN: trascinare con il tasto sinistro del mouse. Premere Esc per uscire. - .plotwindow-specify-first-corner-of-plot-window = PLOTWINDOW Specificare il primo angolo della finestra del tracciato: + .plotwindow-specify-first-corner-of-plot-window = PLOTWINDOW Specificare il primo angolo della finestra di stampa: .plotwindow-specify-opposite-corner = PLOTWINDOW Specificare l'angolo opposto: .palettes = Tavolozze .pan = Padella .persp = Pers .projection = Proiezione - .quickprint-count-selected-enter-to-plot-or-kee = QUICKPRINT __ocs_arg_count__ selezionato: Enter per tracciare o continuare a selezionare: + .quickprint-count-selected-enter-to-plot-or-kee = QUICKPRINT __ocs_arg_count__ selezionato: Enter per stampare o continuare a selezionare: .quickprint-select-objects-to-quick-print = QUICKPRINT Selezionare gli oggetti da stampare rapidamente: .removed-annotation-scale-arg = Scala di annotazione __ocs_fmt_0__ rimossa. .scalelistedit-add-delete = SCALELISTEDIT [Aggiungi/Elimina]: @@ -4466,8 +4474,8 @@ view = .scale-arg-already-exists = La scala __ocs_fmt_0__ esiste già. .shaded = Ombreggiato .sheet-set-manager = - Set di lenzuola - Direttore + Gestione + Set di fogli .shortcut-key-not-found = Scorciatoia "__ocs_fmt_0__" non trovata. .shortcut-key-removed = Scorciatoia "__ocs_fmt_0__" rimossa. .shortcut-set-key-cmd-str = Serie di scorciatoie: __ocs_fmt_0__ → __ocs_fmt_1__ @@ -4475,10 +4483,10 @@ view = .table-cell-set-row-col-text = TABLE CELL: impostare [__ocs_fmt_0__,__ocs_fmt_1__] = "__ocs_fmt_2__". .toolpalettes-tool-palettes-not-yet-implemented = TOOLPALETTES: tavolozze degli strumenti non ancora implementate. .tile-horiz = - Piastrella + Affianca Orizz. .tile-vert = - Piastrella + Affianca Vert. .ucs-icon = UCS @@ -4522,8 +4530,8 @@ view = .usage-userr-1-5-real = Utilizzo: USERR <1-5> .usage-xdata-list-set-app-value-clear-app = Utilizzo: XDATA LIST | SET | CLEAR [app] .vplayer-enter-a-viewport-first-double-click-or = VPLAYER: inserire prima una finestra (fare doppio clic o MS). - .vplayer-frozen-layers = VPLAYER: strati congelati: __ocs_fmt_0__ - .vplayer-no-frozen-layers-in-active-viewport = VPLAYER: nessun layer congelato nella vista attiva. + .vplayer-frozen-layers = VPLAYER: livelli congelati: __ocs_fmt_0__ + .vplayer-no-frozen-layers-in-active-viewport = VPLAYER: nessun livello congelato nella vista attiva. .vplayer-switch-to-a-paper-space-layout-first = VPLAYER: passa prima al layout dello spazio carta. .vports-configuration-single-2h-2v-4 = Configurazione VPORTS [Singola/2H/2V/4]: .vports-created-viewport-s = VPORTS: viste __ocs_fmt_0__ create [__ocs_fmt_1__]. @@ -4733,11 +4741,11 @@ properties = .diffuse-map = Mappa diffusa .dim-line-1 = Linea debole 1 .dim-line-2 = Linea debole 2 - .dim-line-color = Colore della linea attenuata + .dim-line-color = Colore della linea di quota .dim-line-ext = Linea fioca est .dim-line-forced = Linea fioca forzata .dim-line-inside = Linea debole all'interno - .dim-line-lineweight = Spessore di linea della linea attenuata + .dim-line-lineweight = Spessore di linea della linea di quota .dim-prefix-suffix = Prefisso/suffisso debole .dim-roundoff = Arrotondamento debole .dim-scale-linear = Scala debole lineare @@ -4814,7 +4822,7 @@ properties = .font = Carattere .frame-text = Testo della cornice .from-dxf = Da DXF - .frozen-layers = Strati congelati + .frozen-layers = Livelli congelati .geographic-position-marker = Indicatore di posizione geografica .global-width = Larghezza globale .gloss = Lucentezza @@ -4857,19 +4865,19 @@ properties = .leader-1-x = Direttrice 1 X .leader-1-y = Direttrice 1 Y .leader-1-z = Direttrice 1 Z - .leader-2-x = Capo 2 X - .leader-2-y = Capo 2 Y - .leader-2-z = Capo 2 Z + .leader-2-x = Leader 2 X + .leader-2-y = Leader 2 Y + .leader-2-z = Leader 2 Z .leader-length = Lunghezza della guida - .leader-x = Capo X - .leader-y = Capo Y - .leader-z = Capo Z - .leader-color = Colore capo + .leader-x = Leader X + .leader-y = Leader Y + .leader-z = Leader Z + .leader-color = Colore leader .leader-extension = Estensione del leader .leader-linetype = Tipo di linea direttrice .leader-lineweight = Spessore linea principale .leader-type = Tipo di leader - .left-attachment = Allegato sinistro + .left-attachment = Aggancio a sinistra .left-offset = Scostamento sinistro .left-to-right = Da sinistra a destra .legacy-entity = Entità legacy @@ -4904,7 +4912,7 @@ properties = .manual-positions = Posizioni manuali .map-size = Dimensioni della mappa .mass-properties = Proprietà di massa - .match = Partita + .match = Corrispondenza .material = Materiale .material-details = Dettagli sui materiali .maximum-intensity = Massima intensità @@ -4969,7 +4977,7 @@ properties = .physical-method = Metodo fisico .pitch-and-family = Campo e famiglia .planar = Planare - .plot-glyph = Trama Glifo + .plot-glyph = Stampa Glifo .plot-style-table = Tabella stili di stampa .plot-table-attached-to =Tabella di stampa allegata a .plot-table-type = Tipo di tabella di stampa @@ -5018,7 +5026,7 @@ properties = .render-flags = Rendering di bandiere .repeat-bottom-labels = Ripeti le etichette inferiori .repeat-top-labels = Ripeti le etichette superiori - .right-attachment = Giusto attaccamento + .right-attachment = Attacco a destra .right-offset = Scostamento destro .rows = Righe .shx = SHX @@ -5140,7 +5148,7 @@ properties = .ucs-per-viewport = UCS per finestra .underlay = Sottofondo .underlay-adjust = Regolazione sottofondo - .underlay-layers = Strati sottostanti + .underlay-layers = Livelli sottostanti .underlay-name = Nome del sottofondo .underlay-path = Percorso sottostante .underlined = Sottolineato @@ -5212,7 +5220,7 @@ properties = styles = .blocks = Blocchi: __ocs_fmt_0__ .color-cur-color = Colore: __ocs_arg_cur_color__ - .layers-value = Strati: __ocs_fmt_0__ + .layers-value = Livelli: __ocs_fmt_0__ .linetypes-value-indented-title-case = Tipi di linea: __ocs_fmt_0__ .lineweight-cur-lw = Spessore linea: __ocs_arg_cur_lw__ .screening-cur-scr = Schermatura: __ocs_arg_cur_scr__ @@ -5224,7 +5232,7 @@ styles = .aci = ACI: .audit-no-issues-found = AUDIT: nessun problema trovato. .audit-reference-s-to-undefined-block-s = AUDIT: riferimento/i a blocchi non definiti: __ocs_fmt_0__ - .audit-reference-s-to-undefined-layer-s = AUDIT: riferimento/i a strati non definiti: __ocs_fmt_0__ + .audit-reference-s-to-undefined-layer-s = AUDIT: riferimento/i a livelli non definiti: __ocs_fmt_0__ .audit-report-only-no-automatic-repair-performe = AUDIT: solo rapporto: nessuna riparazione automatica eseguita. .audit-scanned-total-object-s = AUDIT: oggetto/i __ocs_fmt_0__ scansionato/i. .above-text = Sopra il testo @@ -5241,7 +5249,7 @@ styles = .at-second-ext = Al secondo est .basepoint = Punto base .before-text = Prima del testo - .best-fit =La migliore vestibilità + .best-fit =Il miglior adattamento .big-font = Carattere grande: .blockextents = Blocca estensioni .bottomline = In conclusione @@ -5252,7 +5260,7 @@ styles = .by-annotation-scale = Per scala di annotazione .cdimsty-new-current-dimension-style-name = CDIMSTY nuovo nome dello stile di quota corrente: .cdimsty-cur = CDIMSTY = "__ocs_fmt_0__" - .cdimsty-dim-style-not-found = CDIMSTY: stile attenuato '__ocs_fmt_0__' non trovato. + .cdimsty-dim-style-not-found = CDIMSTY: stile quota '__ocs_fmt_0__' non trovato. .celtscale-new-current-object-line-type-scale = CELTSCALE nuova scala del tipo di linea dell'oggetto corrente: .celtscale-v-4 = CELTSCALE = __ocs_fmt_0__ .celtscale-set-to-v-4 = CELTSCALE impostato su __ocs_fmt_0__ @@ -5271,7 +5279,7 @@ styles = .clayer-cur = CLAYER = "__ocs_fmt_0__" .clayer-set-to-name-arg = CLAYER impostato su "__ocs_fmt_0__" .clayer-layer-not-found = CLAYER: livello '__ocs_fmt_0__' non trovato. - .color-new-object-colour-bylayer-byblock-1-255 = COLOR nuovo colore oggetto [PerLayer / PerBlocco / 1-255 / rosso / giallo / verde / ciano / blu / magenta / bianco]: + .color-new-object-colour-bylayer-byblock-1-255 = COLOR nuovo colore oggetto [DaLayer / DaBlocco / 1-255 / rosso / giallo / verde / ciano / blu / magenta / bianco]: .centeroftext = Centro del testo .centeroftextoverline = Centro del testo sopralineato .clear-table = Tabella chiara @@ -5284,9 +5292,9 @@ styles = .current-values = Valori attuali: .decimal-degrees = Gradi decimali .diagonal = Diagonale - .dim-line-between-ext-dimtofl = Linea attenuata tra esterno (DIMTOFL) - .dim-line-color-aci-dimclrd = Colore linea dimmerabile ACI (DIMCLRD) - .dim-line-linetype-dimltype = Tipo di linea linea attenuata (DIMLTYPE) + .dim-line-between-ext-dimtofl = Linea quota esterna (DIMTOFL) + .dim-line-color-aci-dimclrd = Colore linea quota ACI (DIMCLRD) + .dim-line-linetype-dimltype = Tipo di linea quota (DIMLTYPE) .dim-line-weight-dimlwd = Spessore linea ridotta (DIMLWD) .dimstyle-updated = DimStyle '__ocs_fmt_0__' aggiornato. .dimension-line = Linea di quota @@ -5318,9 +5326,9 @@ styles = .isavebak-set-to-0 = ISAVEBAK impostato su 0 .isavebak-set-to-1 = ISAVEBAK impostato su 1 .keep-with-dim-line = Continua con la linea debole - .layers = Strati + .layers = Livelli .linetype-list-set = LINETYPE [Elenco/Imposta]: - .linetype-set-linetype-name-bylayer-byblock = LINETYPE SET nome del tipo di linea (PerLayer / PerBlocco / …): + .linetype-set-linetype-name-bylayer-byblock = LINETYPE SET nome del tipo di linea (DaLayer / DaBlocco / …): .linetype-name-is-not-loaded-use-linetype-list = LINETYPE: "__ocs_fmt_0__" non è caricato. Utilizza LINETYPE LIST per vedere i tipi di linea disponibili. .ltscale-new-global-line-type-scale = LTSCALE nuova scala globale di tipo linea: .ltscale-v-4 =LTSCALE = __ocs_fmt_0__ @@ -5432,16 +5440,16 @@ plot = .no-page-setups = (nessuna impostazione di pagina) .none = .previous = - .center-the-plot = Centrare la trama + .center-the-plot = Centrare la stampa .copies = Copie .export-pdf = Esporta PDF .fit-to-paper = Adatta alla carta - .layout-plots-the-current-sheet-using-the-selec = Layout traccia il foglio corrente utilizzando il formato carta selezionato. + .layout-plots-the-current-sheet-using-the-selec = Layout stampa il foglio corrente utilizzando il formato carta selezionato. .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. .custom-paper-size = Custom paper size .printable-margins = Printable margins .enter-a-positive-width-and-height = Enter a positive width and height. - .paper-size = Paper size + .paper-size = Formato foglio .count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer. .printer-properties-printer = Printer properties — __ocs_fmt_0__ .this-printer-reports-no-driver-options = This printer reports no driver options. @@ -5457,14 +5465,14 @@ plot = .paper-space-last = Spazio carta per ultimo .pick = Scegli… .plot-area = Area del terreno - .plot-in-background = Trama sullo sfondo - .plot-offset = Offset della trama - .plot-options = Opzioni di trama - .plot-scale = Scala del grafico - .plot-stamp = Timbro di trama + .plot-in-background = Stampa in background + .plot-offset = Offset di stampa + .plot-options = Opzioni di stampa + .plot-scale = Scala di stampa + .plot-stamp = Timbro .plot-style-table-pen-assignments = Tabella stili di stampa (assegnazioni penne) - .plot-transparency =Trasparenza della trama - .plot-upside-down = Trama sottosopra + .plot-transparency =Trasparenza della stampa + .plot-upside-down = Stampa sottosopra .named-plot-style-tables-stb-are-not-supported-yet-name = Le tabelle di stili di stampa con nome (.stb) non sono ancora supportate: __ocs_fmt_0__ .name-was-not-found-in-the-plot-styles-folder-folder = __ocs_fmt_0__ non è stato trovato nella cartella degli stili di stampa (__ocs_fmt_1__). .preview = Anteprima @@ -5473,10 +5481,10 @@ plot = .save-to-pdf-file = Salva nel file PDF… .save = Salva… .scale-lineweights = Ridimensiona gli spessori di linea - .set-current = Imposta la corrente + .set-current = Imposta corrente .shaded-viewport-options = Opzioni della vista ombreggiata .system-default-printer = Stampante predefinita del sistema - .what-to-plot = Cosa tracciare + .what-to-plot = Cosa stampare .x-mm = X (mm) .select-drawing-to-import-page-setups-from = Seleziona il disegno da cui importare le impostazioni di pagina .drawings-and-templates = Disegni e modelli @@ -5509,7 +5517,7 @@ plot = .y-mm = Y (mm) manage = - .about = Circa + .about = Informazioni .application = Applicazione .audit = Controllo .cleanup = Pulizia @@ -5517,14 +5525,14 @@ manage = .edit-aliases = Modifica alias .export = Esportazione .find-non-purgeable-items = - Trova non- - Articoli eliminabili + Trova elementi + non eliminabili .load-partial-cui = Carica CUI parziale - .overkill = Eccessivo + .overkill = Elimina duplicati .purge = Eliminazione .user-interface = - Utente Interfaccia + Utente plugins = .api-incompatible = API incompatibile @@ -5595,9 +5603,9 @@ ui = .bring-to-front = Portare in primo piano .building-scene-caches = Creazione delle cache delle scene… .cad-application-for-architecture-engineering = Applicazione CAD per Architettura e Ingegneria - .clean-screen = Schermo pulito + .clean-screen = Schermo intero .clean-screen-hide-ribbon-and-panels = - Schermo pulito + Schermo intero Nascondi nastro e pannelli .collapsed = Crollato .color =Colore __ocs_fmt_0__ @@ -5610,7 +5618,7 @@ ui = Personalizzazione Mostra o nascondi gli elementi della barra di stato .custom = Personalizzato… - .draw-order = Disegna l'ordine + .draw-order = Ordine di visualizzazione .dynamic-input = Ingresso dinamico .dynamic-input-f12 = Ingresso dinamico @@ -5685,16 +5693,16 @@ ui = .replace-with = Sostituisci con: .searches-text-mtext-attribute-definitions-and = Cerca testo, testoM, definizioni di attributo e valori di attributo di blocco. .select-similar = Seleziona Simile - .selection-cycling = Selezione Ciclismo + .selection-cycling = Selezione Ciclica .selection-cycling-repeat-click-to-step-through = - Selezione Ciclismo + Selezione Ciclica Fai ripetutamente clic per spostarti tra gli oggetti sovrapposti .selection-filtering = Filtraggio della selezione .selection-filtering-limit-which-object-types-c = Filtraggio della selezione Limita i tipi di oggetto che possono essere selezionati - .send-under-object = Invia sotto oggetto - .send-to-back = Invia a Indietro + .send-under-object = Manda sotto oggetto + .send-to-back = Manda dietro .show-annotation-objects = Mostra oggetti di annotazione .show-lineweight = Mostra spessore linea .show-lineweight-lwdisplay = @@ -5754,7 +5762,7 @@ dimstyle = .display-alternate-units = Visualizza unità alternative .draw-dimension-line-between-extension-lines = Disegna la linea di quota tra le linee di estensione .extend-beyond-dimension-line = Estendere oltre la linea di quota - .extend-beyond-ticks = Estendersi oltre le zecche + .extend-beyond-ticks = Estendersi oltre le linee .extension-line-color = Colore della linea di estensione .extension-lineweight = Spessore linea di estensione .feet-and-inches = Piedi e pollici @@ -5872,7 +5880,7 @@ style-manager = .left-attachment = Attacco sinistro .line-color = Colore della linea .line-type = Tipo di linea - .line-weight = Peso della linea + .line-weight = Spessore di linea .maximum-leader-points = Punti leader massimi .multileader-draw-order = Ordine di visualizzazione delle multidirettrici .no-preview = Nessuna anteprima @@ -5881,7 +5889,7 @@ style-manager = .paper-height = Altezza della carta .paper-text-height = Altezza del testo cartaceo .path-type = Tipo di percorso - .right-attachment = Attaccamento giusto + .right-attachment = Attacco a destra .scale-factor = Fattore di scala .second-segment-angle = Angolo del secondo segmento .size-and-effects = Dimensioni ed effetti diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index 88aaa76ca..d819a86ee 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -3013,6 +3013,8 @@ modify = .offset-n-click-through-point-or-multiple = OFFSET__ocs_arg_n__ 通過点をクリックまたは [複数]: .offset-n-multiple-click-next-side-distance-d = OFFSET__ocs_arg_n__ 複数 次の側をクリック [距離 __ocs_arg_d__]: .offset-n-multiple-click-next-through-point = OFFSET__ocs_arg_n__ 複数 次の通過点をクリック: + .offset-n-click-through-point-or-type-distance = OFFSET__ocs_arg_n__ 通過点をクリックまたは距離を入力、負の値で反対側 [Multiple/Erase/Layer] <__ocs_arg_d__>: + .offset-n-multiple-click-through-point-or-type-distance = OFFSET__ocs_arg_n__ 複数 通過点をクリックまたは距離を入力、負の値で反対側 [Erase/Layer] <__ocs_arg_d__>: .offset-dimexo = オフセット (DIMEXO) .overall-scale-dimscale = 全体尺度 (DIMSCALE) .pedit-enter-option = PEDIT オプションを入力: @@ -4081,6 +4083,12 @@ insert = .field-select-object = Select object .field-expression = Field expression .field-help = Inserts a field: text that updates from the drawing, the date or another source. + .field-average = Average + .field-sum = Sum + .field-object-name = Object name + .field-block-placeholder = Block placeholder + .field-block-editor-only = Only accessible in the block editor + .field-block-reference-property = Block reference property .field-cat-linked = Linked .field-current-precision = Current precision .field-evaluate = Evaluate diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index 599abf44d..254a0f857 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -3022,6 +3022,8 @@ modify = .offset-n-click-through-point-or-multiple = OFFSET__ocs_arg_n__ 클릭 통과 점 또는 [Multiple]: .offset-n-multiple-click-next-side-distance-d = OFFSET__ocs_arg_n__ 다중 다음 측면 클릭 [거리 __ocs_arg_d__]: .offset-n-multiple-click-next-through-point = OFFSET__ocs_arg_n__ 다중 클릭 다음 통과 지점: + .offset-n-click-through-point-or-type-distance = OFFSET__ocs_arg_n__ 통과점을 클릭하거나 거리 입력, 음수는 반대쪽 [Multiple/Erase/Layer] <__ocs_arg_d__>: + .offset-n-multiple-click-through-point-or-type-distance = OFFSET__ocs_arg_n__ 다중 통과점을 클릭하거나 거리 입력, 음수는 반대쪽 [Erase/Layer] <__ocs_arg_d__>: .offset-dimexo = 오프셋(DIMEXO) .overall-scale-dimscale = 전체 규모(DIMSCALE) .pedit-enter-option = PEDIT Enter 옵션: @@ -4092,6 +4094,12 @@ insert = .field-select-object = Select object .field-expression = Field expression .field-help = Inserts a field: text that updates from the drawing, the date or another source. + .field-average = Average + .field-sum = Sum + .field-object-name = Object name + .field-block-placeholder = Block placeholder + .field-block-editor-only = Only accessible in the block editor + .field-block-reference-property = Block reference property .field-cat-linked = Linked .field-current-precision = Current precision .field-evaluate = Evaluate diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 9694b0e11..105fe9086 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -3023,6 +3023,8 @@ modify = .offset-n-click-through-point-or-multiple = OFFSET__ocs_arg_n__ Klik door punt of [meerdere]: .offset-n-multiple-click-next-side-distance-d = OFFSET__ocs_arg_n__ Meerdere klik op volgende zijde [afstand __ocs_arg_d__]: .offset-n-multiple-click-next-through-point = OFFSET__ocs_arg_n__ Meerdere klik volgende door punt: + .offset-n-click-through-point-or-type-distance = OFFSET__ocs_arg_n__ Klik door punt of typ afstand, negatief voor de andere kant [Multiple/Erase/Layer] <__ocs_arg_d__>: + .offset-n-multiple-click-through-point-or-type-distance = OFFSET__ocs_arg_n__ Meerdere Klik door punt of typ afstand, negatief voor de andere kant [Erase/Layer] <__ocs_arg_d__>: .offset-dimexo = Verschuiving (DIMEXO) .overall-scale-dimscale = Totale schaal (DIMSCALE) .pedit-enter-option = PEDIT Voer optie in: @@ -4091,6 +4093,12 @@ insert = .field-select-object = Select object .field-expression = Field expression .field-help = Inserts a field: text that updates from the drawing, the date or another source. + .field-average = Average + .field-sum = Sum + .field-object-name = Object name + .field-block-placeholder = Block placeholder + .field-block-editor-only = Only accessible in the block editor + .field-block-reference-property = Block reference property .field-cat-linked = Linked .field-current-precision = Current precision .field-evaluate = Evaluate diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index dad85cf06..89854dd6f 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -3022,6 +3022,8 @@ modify = .offset-n-click-through-point-or-multiple = OFFSET__ocs_arg_n__ Punkt przelotowy lub [Multiple]: .offset-n-multiple-click-next-side-distance-d = OFFSET__ocs_arg_n__ Wielokrotne kliknięcie następnej strony [odległość __ocs_arg_d__]: .offset-n-multiple-click-next-through-point = OFFSET__ocs_arg_n__ Wielokrotne kliknięcie następnego punktu przelotowego: + .offset-n-click-through-point-or-type-distance = OFFSET__ocs_arg_n__ Kliknij punkt przelotowy lub wpisz odległość, ujemną dla drugiej strony [Multiple/Erase/Layer] <__ocs_arg_d__>: + .offset-n-multiple-click-through-point-or-type-distance = OFFSET__ocs_arg_n__ Wielokrotnie Kliknij punkt przelotowy lub wpisz odległość, ujemną dla drugiej strony [Erase/Layer] <__ocs_arg_d__>: .offset-dimexo = Przesunięcie (DIMEXO) .overall-scale-dimscale = Skala ogólna (DIMSCALE) .pedit-enter-option = Opcja PEDIT Enter: @@ -4092,6 +4094,12 @@ insert = .field-select-object = Select object .field-expression = Field expression .field-help = Inserts a field: text that updates from the drawing, the date or another source. + .field-average = Average + .field-sum = Sum + .field-object-name = Object name + .field-block-placeholder = Block placeholder + .field-block-editor-only = Only accessible in the block editor + .field-block-reference-property = Block reference property .field-cat-linked = Linked .field-current-precision = Current precision .field-evaluate = Evaluate diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index 820c37af5..395f9eef5 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -3023,6 +3023,8 @@ modify = .offset-n-click-through-point-or-multiple = OFFSET__ocs_arg_n__ Clique no ponto de passagem ou [Múltiplo]: .offset-n-multiple-click-next-side-distance-d = OFFSET__ocs_arg_n__ Múltiplo Clique no próximo lado [distância __ocs_arg_d__]: .offset-n-multiple-click-next-through-point = OFFSET__ocs_arg_n__ Múltiplo Clique no próximo ponto de passagem: + .offset-n-click-through-point-or-type-distance = OFFSET__ocs_arg_n__ Clique no ponto de passagem ou digite a distância, negativa para o outro lado [Multiple/Erase/Layer] <__ocs_arg_d__>: + .offset-n-multiple-click-through-point-or-type-distance = OFFSET__ocs_arg_n__ Múltiplo Clique no ponto de passagem ou digite a distância, negativa para o outro lado [Erase/Layer] <__ocs_arg_d__>: .offset-dimexo = Deslocamento (DIMEXO) .overall-scale-dimscale = Escala geral (DIMSCALE) .pedit-enter-option = PEDIT Insira a opção: @@ -4093,6 +4095,12 @@ insert = .field-select-object = Select object .field-expression = Field expression .field-help = Inserts a field: text that updates from the drawing, the date or another source. + .field-average = Average + .field-sum = Sum + .field-object-name = Object name + .field-block-placeholder = Block placeholder + .field-block-editor-only = Only accessible in the block editor + .field-block-reference-property = Block reference property .field-cat-linked = Linked .field-current-precision = Current precision .field-evaluate = Evaluate diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index f6fdb4c9c..0c6c0735f 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -3026,6 +3026,8 @@ modify = .offset-n-click-through-point-or-multiple = OFFSET__ocs_arg_n__ Нажмите на точку или [Множественное число]: .offset-n-multiple-click-next-side-distance-d = OFFSET__ocs_arg_n__ Несколько кликов на следующую сторону [расстояние __ocs_arg_d__]: .offset-n-multiple-click-next-through-point = OFFSET__ocs_arg_n__ Несколько кликов по следующей точке: + .offset-n-click-through-point-or-type-distance = OFFSET__ocs_arg_n__ Укажите точку прохождения или введите расстояние, отрицательное для другой стороны [Multiple/Erase/Layer] <__ocs_arg_d__>: + .offset-n-multiple-click-through-point-or-type-distance = OFFSET__ocs_arg_n__ Несколько Укажите точку прохождения или введите расстояние, отрицательное для другой стороны [Erase/Layer] <__ocs_arg_d__>: .offset-dimexo = Отступ от объекта (DIMEXO) .overall-scale-dimscale = Общий масштаб (DIMSCALE) .pedit-enter-option = PEDIT Введите опцию: @@ -4094,6 +4096,12 @@ insert = .field-select-object = Select object .field-expression = Field expression .field-help = Inserts a field: text that updates from the drawing, the date or another source. + .field-average = Average + .field-sum = Sum + .field-object-name = Object name + .field-block-placeholder = Block placeholder + .field-block-editor-only = Only accessible in the block editor + .field-block-reference-property = Block reference property .field-cat-linked = Linked .field-current-precision = Current precision .field-evaluate = Evaluate diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index ab1501909..faeb502e3 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -3005,6 +3005,8 @@ modify = .offset-n-click-through-point-or-multiple = OFFSET__ocs_arg_n__ Geçiş noktasına tıklayın veya [Birden Çok]: .offset-n-multiple-click-next-side-distance-d = OFFSET__ocs_arg_n__ Birden Çok Sonraki tarafa tıklayın [mesafe __ocs_arg_d__]: .offset-n-multiple-click-next-through-point = OFFSET__ocs_arg_n__ Birden Çok Sonraki geçiş noktasına tıklayın: + .offset-n-click-through-point-or-type-distance = OFFSET__ocs_arg_n__ Geçiş noktasına tıklayın veya mesafe yazın, diğer taraf için negatif [Multiple/Erase/Layer] <__ocs_arg_d__>: + .offset-n-multiple-click-through-point-or-type-distance = OFFSET__ocs_arg_n__ Birden Çok Geçiş noktasına tıklayın veya mesafe yazın, diğer taraf için negatif [Erase/Layer] <__ocs_arg_d__>: .offset-dimexo = Ofset (DIMEXO) .overall-scale-dimscale = Genel ölçek (DIMSCALE) .pedit-enter-option = PEDIT Seçeneği girin: @@ -4053,6 +4055,12 @@ insert = .field-select-object = Nesne seç .field-expression = Alan ifadesi .field-help = Bir alan ekler: çizimden, tarihten veya başka bir kaynaktan güncellenen metin. + .field-average = Ortalama + .field-sum = Toplam + .field-object-name = Nesne adı + .field-block-placeholder = Blok yer tutucusu + .field-block-editor-only = Yalnız blok düzenleyicide kullanılabilir + .field-block-reference-property = Blok başvurusu özelliği .field-cat-linked = Bağlantılı .field-current-precision = Geçerli hassasiyet .field-evaluate = Hesapla diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 770c2600c..ca77965ed 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -3010,6 +3010,8 @@ modify = .offset-n-click-through-point-or-multiple = OFFSET__ocs_arg_n__ 单击通过点或 [多个]: .offset-n-multiple-click-next-side-distance-d = OFFSET__ocs_arg_n__ 多个 单击下一侧 [距离 __ocs_arg_d__]: .offset-n-multiple-click-next-through-point = OFFSET__ocs_arg_n__ 多个 单击下一个通过点: + .offset-n-click-through-point-or-type-distance = OFFSET__ocs_arg_n__ 单击通过点或输入距离,负值偏移到另一侧 [Multiple/Erase/Layer] <__ocs_arg_d__>: + .offset-n-multiple-click-through-point-or-type-distance = OFFSET__ocs_arg_n__ 多个 单击通过点或输入距离,负值偏移到另一侧 [Erase/Layer] <__ocs_arg_d__>: .offset-dimexo = 偏移 (DIMEXO) .overall-scale-dimscale = 全局比例 (DIMSCALE) .pedit-enter-option = PEDIT 输入选项: @@ -4076,6 +4078,12 @@ insert = .field-select-object = Select object .field-expression = Field expression .field-help = Inserts a field: text that updates from the drawing, the date or another source. + .field-average = Average + .field-sum = Sum + .field-object-name = Object name + .field-block-placeholder = Block placeholder + .field-block-editor-only = Only accessible in the block editor + .field-block-reference-property = Block reference property .field-cat-linked = Linked .field-current-precision = Current precision .field-evaluate = Evaluate diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index b32682a8c..d7013d976 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -3022,6 +3022,8 @@ modify = .offset-n-click-through-point-or-multiple = OFFSET__ocs_arg_n__ 按一下通過點或 [多個]: .offset-n-multiple-click-next-side-distance-d = OFFSET__ocs_arg_n__ 多個 按一下下一側 [距離 __ocs_arg_d__]: .offset-n-multiple-click-next-through-point = OFFSET__ocs_arg_n__ 多個 按一下下一個通過點: + .offset-n-click-through-point-or-type-distance = OFFSET__ocs_arg_n__ 按一下通過點或輸入距離,負值偏移到另一側 [Multiple/Erase/Layer] <__ocs_arg_d__>: + .offset-n-multiple-click-through-point-or-type-distance = OFFSET__ocs_arg_n__ 多個 按一下通過點或輸入距離,負值偏移到另一側 [Erase/Layer] <__ocs_arg_d__>: .offset-dimexo = 偏移 (DIMEXO) .overall-scale-dimscale = 全局比例 (DIMSCALE) .pedit-enter-option = PEDIT 輸入選項: @@ -4088,6 +4090,12 @@ insert = .field-select-object = Select object .field-expression = Field expression .field-help = Inserts a field: text that updates from the drawing, the date or another source. + .field-average = Average + .field-sum = Sum + .field-object-name = Object name + .field-block-placeholder = Block placeholder + .field-block-editor-only = Only accessible in the block editor + .field-block-reference-property = Block reference property .field-cat-linked = Linked .field-current-precision = Current precision .field-evaluate = Evaluate diff --git a/plugins/opencad-python/Cargo.lock b/plugins/opencad-python/Cargo.lock index ac619efe5..35605b2ad 100644 --- a/plugins/opencad-python/Cargo.lock +++ b/plugins/opencad-python/Cargo.lock @@ -1265,7 +1265,7 @@ dependencies = [ [[package]] name = "opencadcodec" version = "0.5.5" -source = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=d96e3fa#d96e3fa2fe5acbeac966f1db4c01142618bf9c79" +source = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=fe69506#fe69506cb99dea6f4c4a73b690a27fdf04403ea0" dependencies = [ "ahash 0.8.12", "anyhow", diff --git a/src/app/automation.rs b/src/app/automation.rs index d4feb67e3..559575263 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -1398,7 +1398,13 @@ impl OpenCADStudio { // The same snapshot a normal save writes: display-only overrides and // resolved xref content stay out of the file. let snapshot = self.tabs[i].scene.document_for_save(); - let before = document_manifest(&snapshot); + // Bake simulation: save_as_version mints *D geometry blocks on its + // private clone, so the reopened manifest always contains baked + // sub-entities. Compare against the same baked view or every new + // (still blockless) dimension trips a false semantic_mismatch. + let mut baked_view = snapshot.clone(); + crate::modules::draw::modify::explode::bake_dimension_blocks(&mut baked_view); + let before = document_manifest(&baked_view); crate::io::save_as_version(&snapshot, &path, version) .map_err(|error| json!({ "ok":false,"status":"failed","code":"save_failed","error":error, @@ -1572,6 +1578,43 @@ mod tests { let _ = std::fs::remove_file(path); } + #[test] + fn save_verified_accepts_new_dimension_with_baked_block() { + use codec::entities::{Dimension, DimensionLinear}; + use codec::{EntityType, Vector3}; + + let mut app = super::OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + let mut d = DimensionLinear::new(Vector3::new(0.0, 0.0, 0.0), Vector3::new(10.0, 0.0, 0.0)); + d.definition_point = Vector3::new(0.0, 5.0, 0.0); + d.base.text_middle_point = Vector3::new(5.0, 5.0, 0.0); + app.tabs[i] + .scene + .document + .add_entity(EntityType::Dimension(Dimension::Linear(d))) + .unwrap(); + let path = std::env::temp_dir().join(format!( + "ocs_verified_dim_{}_{}.dwg", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(), + )); + let _ = std::fs::remove_file(&path); + let result = app + .save_verified_request(&serde_json::json!({ + "path":path, + "target_format":"dwg", + "overwrite":true, + })) + .expect("verified save of a new dimension"); + assert_eq!(result["verified"], true, "{result}"); + assert!(path.is_file()); + let _ = std::fs::remove_file(path); + } + #[test] fn explicit_target_version_parser_never_silently_defaults() { assert_eq!( diff --git a/src/app/command_driver/clipboard.rs b/src/app/command_driver/clipboard.rs index d566fa7fa..74738f0f7 100644 --- a/src/app/command_driver/clipboard.rs +++ b/src/app/command_driver/clipboard.rs @@ -170,11 +170,20 @@ impl OpenCADStudio { } let label = self.history_label_from_active_cmd(i, "TRIM"); self.push_undo_snapshot(i, label); - self.tabs[i].scene.erase_entities(&[handle]); + // A replacement reusing the original handle must land after the erase; + // fresh pieces take over its hatch associations before it goes. + let reuses_handle = new_entities.iter().any(|e| e.common().handle == handle); + if reuses_handle { + self.tabs[i].scene.erase_entities(&[handle]); + } let new_handles: Vec = new_entities .into_iter() .map(|e| self.tabs[i].scene.add_entity(e)) .collect(); + if !reuses_handle { + self.tabs[i].scene.split_hatch_source(handle, &new_handles); + self.tabs[i].scene.erase_entities(&[handle]); + } // Rebuild replaced dimensions from edited data. for &nh in &new_handles { if matches!( diff --git a/src/app/command_driver/entity_commit.rs b/src/app/command_driver/entity_commit.rs index b2802535d..8caae44ad 100644 --- a/src/app/command_driver/entity_commit.rs +++ b/src/app/command_driver/entity_commit.rs @@ -200,6 +200,7 @@ impl OpenCADStudio { pub(super) fn handle_commit_and_exit( &mut self, entity: codec::EntityType, + preserve_commit_layer: bool, ) -> Option> { let i = self.active_tab; // XATTACH: the definition and its INSERT are created together @@ -231,7 +232,15 @@ impl OpenCADStudio { // objects are not part of the add delta, so the step is a snapshot. let text_field = self.tabs[i].active_cmd.as_ref().and_then(|cmd| cmd.text_field()); let label = self.history_label_from_active_cmd(i, "ENTITY"); - let delta_safe = text_field.is_none() && self.delta_add_safe(i, &entity); + // A block reference whose attribute definitions carry fields gets + // them copied onto its attributes (field objects: snapshot step). + let attribute_fields = match &entity { + codec::EntityType::Insert(insert) if !insert.attributes.is_empty() => { + crate::entities::field::block_has_attribute_fields(&self.tabs[i].scene.document, &insert.block_name) + } + _ => false, + }; + let delta_safe = text_field.is_none() && !attribute_fields && self.delta_add_safe(i, &entity); let field_value = match &entity { codec::EntityType::MText(m) => m.value.clone(), codec::EntityType::AttributeDefinition(a) => a.default_value.clone(), @@ -247,7 +256,11 @@ impl OpenCADStudio { ); let association_enabled = self.tabs[i].scene.document.header.dimension_associativity == 2; - let committed = self.commit_entity_handle(entity); + let committed = if preserve_commit_layer { + self.commit_entity_handle_preserve_layer(entity) + } else { + self.commit_entity_handle(entity) + }; if is_associative_dimension && association_enabled { if let Some(handle) = committed { let sources = self.infer_dimension_sources_guarded(i, handle); @@ -264,6 +277,10 @@ impl OpenCADStudio { &field_value, ); } + if let (Some(handle), true) = (committed, attribute_fields) { + crate::entities::field::attach_attribute_fields(&mut self.tabs[i].scene.document, handle); + self.tabs[i].scene.bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]); + } if let Some(handle) = committed { self.apply_continuous_constraints(i, &[handle]); } @@ -972,7 +989,7 @@ impl OpenCADStudio { let i = self.active_tab; // Collect the full AttributeDefinitions owned by this block // record so each created attribute keeps its geometry (#255). - let attdefs: Vec = { + let mut attdefs: Vec = { let doc = &self.tabs[i].scene.document; if let Some(br) = doc.block_records.get(&block_name) { br.entity_handles @@ -992,6 +1009,12 @@ impl OpenCADStudio { } }; + // ATTREQ 0: attributes take their defaults without prompting. + if !self.tabs[i].scene.document.header.attribute_request { + for attdef in &mut attdefs { + attdef.flags.preset = true; + } + } if attdefs.is_empty() { // No attribute definitions — commit the INSERT directly. let entity = self.tabs[i] diff --git a/src/app/command_driver/mod.rs b/src/app/command_driver/mod.rs index 76ba3bfb0..33edae656 100644 --- a/src/app/command_driver/mod.rs +++ b/src/app/command_driver/mod.rs @@ -381,8 +381,7 @@ impl OpenCADStudio { .document .get_entity(handle) .map(|entity| entity.common().owner_handle); - self.tabs[tab].scene.erase_entities(&[handle]); - entities + let pieces: Vec = entities .into_iter() .map(|mut entity| { entity.common_mut().handle = Handle::NULL; @@ -391,7 +390,11 @@ impl OpenCADStudio { } self.tabs[tab].scene.add_entity(entity) }) - .collect() + .collect(); + // Hand the hatch associations over before the original goes. + self.tabs[tab].scene.split_hatch_source(handle, &pieces); + self.tabs[tab].scene.erase_entities(&[handle]); + pieces }; for &updated in &handles { if matches!( @@ -507,7 +510,7 @@ impl OpenCADStudio { self.handle_copy_to_clipboard(handles, base); } CmdResult::CommitAndExit(entity) => { - if let Some(task) = self.handle_commit_and_exit(entity) { + if let Some(task) = self.handle_commit_and_exit(entity, preserve_commit_layer) { return task; } } diff --git a/src/app/commands/dim.rs b/src/app/commands/dim.rs index 2bc34742a..297e89d2a 100644 --- a/src/app/commands/dim.rs +++ b/src/app/commands/dim.rs @@ -1268,7 +1268,8 @@ impl OpenCADStudio { }) .collect(); // Pick-first (#422): with offsettable objects already selected, - // skip the pick step and go straight to distance / side. + // skip the distance and pick steps and open on the live side + // preview. let preselected: Vec<_> = self.tabs[i] .scene .selected_entities() diff --git a/src/app/commands/field.rs b/src/app/commands/field.rs index 123e23582..3f0ee8a93 100644 --- a/src/app/commands/field.rs +++ b/src/app/commands/field.rs @@ -3,7 +3,7 @@ use crate::app::{Message, OpenCADStudio}; use crate::command::CadCommand; -use crate::modules::annotate::field_cmd::{FieldObjectPickCommand, FieldPlaceCommand}; +use crate::modules::annotate::field_cmd::{FieldObjectPickCommand, FieldPlaceCommand, FieldTablePickCommand}; use crate::ui::window::field_dialog::{ fields_of, object_properties, FieldDialogMsg, FieldDialogState, FieldTarget, DATE_FORMATS, NAMED_TYPES, @@ -35,13 +35,70 @@ impl OpenCADStudio { } Some(Task::none()) } + cmd if cmd == "_FIELD_CELL" || cmd.starts_with("_FIELD_CELL ") => { + let parts: Vec<&str> = cmd.split_whitespace().skip(1).collect(); + if let Some(expression) = self.table_cell_expression(i, &parts) { + if let Some(state) = self.field_dialog.as_mut() { + // The reference appends at the end, with no operator. + state.formula.push_str(&expression); + } + self.refresh_field_preview(); + // A real result gets the Decimal format, as the reference picks it. + if let Some(state) = self.field_dialog.as_mut() { + if state.formula_format == 0 && state.preview.contains('.') { + state.formula_format = 2; + } + } + } + if self.field_dialog.is_some() { + self.active_modal = Some(crate::app::ModalKind::Field); + self.refresh_field_preview(); + } + Some(Task::none()) + } _ => None, } } + /// The formula text for a picked table cell or cell range: + /// `Table(%<\_ObjId H>%).B4` or `Table(%<\_ObjId H>%).Evaluate(Sum(A3:B5))`. + fn table_cell_expression(&self, i: usize, parts: &[&str]) -> Option { + let function = *parts.first()?; + let cell = |at: &[&str]| -> Option<(codec::Handle, usize, usize)> { + let handle = codec::Handle::new(u64::from_str_radix(at.first()?, 16).ok()?); + let point = glam::DVec3::new(at.get(1)?.parse().ok()?, at.get(2)?.parse().ok()?, at.get(3)?.parse().ok()?); + let document = &self.tabs[i].scene.document; + let codec::EntityType::Table(table) = document.get_entity(handle)? else { + return None; + }; + let hit = crate::modules::annotate::table_cmd::table_cell_at(table, None, point)?; + Some((handle, hit.row, hit.column)) + }; + let name = |row: usize, column: usize| format!("{}{}", column_letters(column), row + 1); + let (table, row, column) = cell(parts.get(1..5)?)?; + if function == "Cell" { + return Some(format!("Table(%<\\_ObjId {:X}>%).{}", table.value(), name(row, column))); + } + let (second_table, row2, column2) = cell(parts.get(5..9)?)?; + if second_table != table { + return None; + } + Some(format!( + "Table(%<\\_ObjId {:X}>%).Evaluate({function}({}:{}))", + table.value(), + name(row.min(row2), column.min(column2)), + name(row.max(row2), column.max(column2)), + )) + } + /// Open the Field dialog for `target`. pub(in crate::app) fn open_field_dialog(&mut self, target: FieldTarget) { let mut state = FieldDialogState::new(target); + if !matches!(target, FieldTarget::NewText) { + state.placeholder_block = self.tabs[self.active_tab] + .active_block_edit_session() + .map(|session| session.block_name.clone()); + } state.examples = DATE_FORMATS .iter() .map(|f| self.field_value(&format!("\\AcVar Date \\f \"{f}\""), &[])) @@ -62,7 +119,17 @@ impl OpenCADStudio { return; }; let (code, objects) = state.code(); - let preview = if state.complete() { self.field_value(&code, &objects) } else { String::new() }; + let preview = if state.complete() { + if state.name == "BlockPlaceholder" { + // A placeholder shows its property name until a block reference resolves it. + let (label, _, _) = codec::fields::BLOCK_PLACEHOLDER_PROPERTIES[state.placeholder_property]; + text_case(label, state.text_case) + } else { + self.field_value(&code, &objects) + } + } else { + String::new() + }; if let Some(state) = self.field_dialog.as_mut() { state.preview = preview; } @@ -164,6 +231,18 @@ impl OpenCADStudio { self.show_count_instances(i, &json); return Task::none(); } + FieldDialogMsg::PlaceholderProperty(k) => { + state.placeholder_property = k; + state.text_case = 0; + } + FieldDialogMsg::TableFunction(function) => { + // The dialog waits while the cells are picked. + self.active_modal = None; + let command = FieldTablePickCommand::new(function); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + return Task::none(); + } FieldDialogMsg::SelectObject => { // The dialog waits while one object is picked. self.active_modal = None; @@ -235,3 +314,40 @@ impl OpenCADStudio { }; } } + +/// Spreadsheet column letters: A … Z, AA, AB … +fn column_letters(mut column: usize) -> String { + let mut letters = Vec::new(); + loop { + letters.push(b'A' + (column % 26) as u8); + if column < 26 { + break; + } + column = column / 26 - 1; + } + letters.reverse(); + String::from_utf8(letters).unwrap_or_default() +} + +/// The Field dialog's text cases: (none), Uppercase, Lowercase, First capital, Title case. +fn text_case(value: &str, case: usize) -> String { + match case { + 1 => value.to_uppercase(), + 2 => value.to_lowercase(), + 3 => { + let lower = value.to_lowercase(); + let mut chars = lower.chars(); + chars.next().map(|c| c.to_uppercase().chain(chars).collect()).unwrap_or_default() + } + 4 => value + .split(' ') + .map(|word| { + let lower = word.to_lowercase(); + let mut chars = lower.chars(); + chars.next().map(|c| c.to_uppercase().chain(chars).collect::()).unwrap_or_default() + }) + .collect::>() + .join(" "), + _ => value.to_string(), + } +} diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 558a87546..5bca869e3 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -93,7 +93,26 @@ impl OpenCADStudio { } pub(super) fn dispatch_command(&mut self, cmd: &str) -> Task { - self.dispatch_command_inner(cmd, false) + let task = self.dispatch_command_inner(cmd, false); + self.clear_idle_tool_highlight(); + task + } + + /// Turn the ribbon highlight off when the command just dispatched left + /// nothing running: no interactive command, dialog or navigation mode. + /// One-shot commands (view changes, clipboard, toggles, audits…) would + /// otherwise leave it lit forever; interactive commands and dialog owners + /// keep theirs until the command end / modal close clears it. (#355) + pub(in crate::app) fn clear_idle_tool_highlight(&mut self) { + let i = self.active_tab; + if self.tabs[i].active_cmd.is_none() + && self.active_modal.is_none() + && !self.tabs[i].pan_mode + && !self.tabs[i].orbit_mode + && !self.tabs[i].zoom_dynamic_mode + { + self.ribbon.deactivate_tool(); + } } /// Dispatch a verb typed at the interactive command line, falling back to @@ -105,7 +124,9 @@ impl OpenCADStudio { /// callers (ribbon, plugins, headless automation) use `dispatch_command` /// and never get silent substitution. pub(super) fn dispatch_command_or_suggest(&mut self, cmd: &str) -> Task { - self.dispatch_command_inner(cmd, true) + let task = self.dispatch_command_inner(cmd, true); + self.clear_idle_tool_highlight(); + task } fn dispatch_command_inner(&mut self, cmd: &str, allow_suggest: bool) -> Task { @@ -206,6 +227,13 @@ impl OpenCADStudio { return Task::none(); } + // Light the ribbon button of the command now starting, however it was + // started (typed, alias, shortcut, Repeat), so the user sees where it + // lives. A transparent command returned above and leaves the running + // command's highlight alone. + self.ribbon + .show_command(strip_command_prefixes(cmd).unwrap_or(cmd)); + if !self.suppress_plugin_dispatch && crate::plugin::try_dispatch(self, i, cmd) { // try_dispatch returns true for both finished commands and interactive // commands that it just installed. If no command is now active, the @@ -1075,3 +1103,44 @@ fn strip_command_prefixes(cmd: &str) -> Option<&str> { let bare = cmd.trim_start_matches(['_', '.']); (bare.len() != cmd.len() && !bare.is_empty()).then_some(bare) } + +#[cfg(test)] +mod ribbon_highlight_tests { + use crate::app::OpenCADStudio; + + fn fresh() -> OpenCADStudio { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + app + } + + #[test] + fn a_typed_alias_lights_the_button_of_the_command_it_starts() { + let mut app = fresh(); + let _ = app.dispatch_command_or_suggest("O"); + assert!(app.tabs[app.active_tab].active_cmd.is_some()); + assert_eq!(app.ribbon.active_tool(), Some("OFFSET")); + let _ = app.dispatch_command_or_suggest("L"); + assert_eq!(app.ribbon.active_tool(), Some("LINE")); + } + + #[test] + fn a_transparent_command_keeps_the_running_command_lit() { + let mut app = fresh(); + let _ = app.dispatch_command_or_suggest("LINE"); + let _ = app.dispatch_command_or_suggest("'ZOOM E"); + assert!(app.tabs[app.active_tab].active_cmd.is_some()); + assert_eq!(app.ribbon.active_tool(), Some("LINE")); + } + + #[test] + fn a_one_shot_command_leaves_nothing_lit() { + // REGEN cancels the running LINE and finishes at once, so neither + // its own button nor LINE's may stay lit. + let mut app = fresh(); + let _ = app.dispatch_command_or_suggest("LINE"); + let _ = app.dispatch_command_or_suggest("REGEN"); + assert!(app.tabs[app.active_tab].active_cmd.is_none()); + assert_eq!(app.ribbon.active_tool(), None); + } +} diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 5f9fee957..c669354e8 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -1086,6 +1086,7 @@ impl OpenCADStudio { | "CONSTRAINTNAMEFORMAT" | "DYNCONSTRAINTDISPLAY" | "CCONSTRAINTFORM" + | "DYNMODE" ) => { return self.dispatch_styleprops(&format!("SETVAR {cmd}"), i); @@ -1110,7 +1111,7 @@ impl OpenCADStudio { self.command_line.push_info(&super::plotvars::setvar_listing()); self.command_line.push_info(&super::blockvars::setvar_listing()); self.command_line.push_info( - crate::t!("SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR SHORTCUTMENU SHORTCUTMENUDURATION CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE CONSTRAINTSOLVEMODE CONSTRAINTINFER CONSTRAINTBARDISPLAY CONSTRAINTBARMODE CONSTRAINTNAMEFORMAT DYNCONSTRAINTDISPLAY ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only)").as_ref(), + crate::t!("SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR SHORTCUTMENU SHORTCUTMENUDURATION CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE CONSTRAINTSOLVEMODE CONSTRAINTINFER CONSTRAINTBARDISPLAY CONSTRAINTBARMODE CONSTRAINTNAMEFORMAT DYNCONSTRAINTDISPLAY DYNMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only)").as_ref(), ); } else { // Point cloud settings, kept with the drawing. @@ -1703,6 +1704,17 @@ impl OpenCADStudio { .ok_or_else(|| "SETVAR: 0 or 1 required.".into()), None => Ok((format!("MIRRTEXT = {}", h.mirror_text as i32), false)), }, + "DYNMODE" => match &value { + Some(v) => match v.parse::() { + Ok(mode) if (-3..=3).contains(&mode) => { + self.dyn_mode = mode; + self.dyn_input = mode > 0; + Ok((format!("DYNMODE = {mode}"), true)) + } + _ => Err("SETVAR: integer from -3 to 3 required.".into()), + }, + None => Ok((format!("DYNMODE = {}", self.dyn_mode), false)), + }, "ZOOMWHEEL" => match &value { Some(v) => match parse_bool(v) { Some(reversed) => { @@ -2685,6 +2697,7 @@ impl OpenCADStudio { // Without this it marked the drawing // modified instead of persisting. | "TEXTFILL" + | "DYNMODE" ) { self.persist_settings_if_changed(); } else { @@ -3782,6 +3795,51 @@ mod tests { let _ = app.run_command_line("SETVAR DELOBJ 4"); assert_eq!(app.delete_objects, 3); } + + #[test] + fn dynmode_direct_and_setvar_toggles_dynamic_input() { + let mut app = fresh_app(); + assert!(app.dyn_input); + assert_eq!(app.dyn_mode, 3); + + // Turn off via direct command: DYNMODE 0 + let _ = app.run_command_line("DYNMODE 0"); + assert!(!app.dyn_input); + assert_eq!(app.dyn_mode, 0); + + // Turn on via direct command: DYNMODE 3 + let _ = app.run_command_line("DYNMODE 3"); + assert!(app.dyn_input); + assert_eq!(app.dyn_mode, 3); + + // Turn off via SETVAR: SETVAR DYNMODE 0 + let _ = app.run_command_line("SETVAR DYNMODE 0"); + assert!(!app.dyn_input); + assert_eq!(app.dyn_mode, 0); + + // Turn on via SETVAR: SETVAR DYNMODE 1 (pointer input only) + let _ = app.run_command_line("SETVAR DYNMODE 1"); + assert!(app.dyn_input); + assert_eq!(app.dyn_mode, 1); + + // Out of range rejected + let _ = app.run_command_line("DYNMODE 5"); + assert_eq!(app.dyn_mode, 1); + assert!(app.dyn_input); + + let _ = app.run_command_line("DYNMODE -5"); + assert_eq!(app.dyn_mode, 1); + assert!(app.dyn_input); + + // F12 turns it off temporarily (negative) and restores the mode. + let _ = app.update(crate::app::Message::ToggleDynInput); + assert!(!app.dyn_input); + assert_eq!(app.dyn_mode, -1); + + let _ = app.update(crate::app::Message::ToggleDynInput); + assert!(app.dyn_input); + assert_eq!(app.dyn_mode, 1); + } } #[cfg(test)] diff --git a/src/app/control/actions.rs b/src/app/control/actions.rs index e49f72008..d40ccbae7 100644 --- a/src/app/control/actions.rs +++ b/src/app/control/actions.rs @@ -135,63 +135,130 @@ fn parse_point_2d(val: &Value) -> Result<[f64; 2], Value> { Ok([x, y]) } +fn parse_calibrate( + cal: &Value, + pixel_width: u32, + pixel_height: u32, +) -> Result<(codec::types::Vector3, f64, f64), Value> { + let pt_a = cal + .get("point_a") + .or_else(|| cal.get("pixel_a")) + .or_else(|| cal.get("p1")) + .or_else(|| cal.get("source_a")); + let pt_b = cal + .get("point_b") + .or_else(|| cal.get("pixel_b")) + .or_else(|| cal.get("p2")) + .or_else(|| cal.get("source_b")); + let dist = cal + .get("distance") + .or_else(|| cal.get("real_distance")) + .or_else(|| cal.get("length")) + .and_then(Value::as_f64); + + if let (Some(pa), Some(pb), Some(d_real)) = (pt_a, pt_b, dist) { + if !d_real.is_finite() || d_real <= 0.0 { + return Err(failure( + "invalid_distance", + "Calibration distance must be positive", + )); + } + let p1 = parse_point_2d(pa)?; + let p2 = parse_point_2d(pb)?; + let d_px = ((p2[0] - p1[0]).powi(2) + (p2[1] - p1[1]).powi(2)).sqrt(); + if d_px <= 1e-6 { + return Err(failure( + "invalid_points", + "Calibration reference points must be distinct", + )); + } + let scale = d_real / d_px; + let width = pixel_width as f64 * scale; + + let align_to = cal + .get("align_to") + .or_else(|| cal.get("origin")) + .or_else(|| cal.get("target_a")) + .map(parse_point_2d) + .transpose()? + .unwrap_or([0.0, 0.0]); + + let x_ll = align_to[0] - p1[0] * scale; + let y_ll = align_to[1] - (pixel_height as f64 - p1[1]) * scale; + return Ok((codec::types::Vector3::new(x_ll, y_ll, 0.0), width, scale)); + } + + if let (Some(src_pts), Some(tgt_pts)) = ( + cal.get("source_points").and_then(Value::as_array), + cal.get("target_points").and_then(Value::as_array), + ) { + if src_pts.len() >= 2 && tgt_pts.len() >= 2 { + let p_s1 = parse_point_2d(&src_pts[0])?; + let p_s2 = parse_point_2d(&src_pts[1])?; + let p_t1 = parse_point_2d(&tgt_pts[0])?; + let p_t2 = parse_point_2d(&tgt_pts[1])?; + let d_px = ((p_s2[0] - p_s1[0]).powi(2) + (p_s2[1] - p_s1[1]).powi(2)).sqrt(); + let d_cad = ((p_t2[0] - p_t1[0]).powi(2) + (p_t2[1] - p_t1[1]).powi(2)).sqrt(); + if d_px <= 1e-6 { + return Err(failure( + "invalid_points", + "Source calibration points must be distinct", + )); + } + let scale = d_cad / d_px; + let width = pixel_width as f64 * scale; + let x_ll = p_t1[0] - p_s1[0] * scale; + let y_ll = p_t1[1] - (pixel_height as f64 - p_s1[1]) * scale; + return Ok((codec::types::Vector3::new(x_ll, y_ll, 0.0), width, scale)); + } + } + + Err(failure( + "invalid_calibrate", + "calibrate requires point_a:[x,y], point_b:[x,y], distance:number, and optional align_to:[x,y]", + )) +} + impl OpenCADStudio { - /// `embed_image` — pack the picture at `path` into an OLE2FRAME placed - /// with its lower-left corner at `at` (default width ≈ pixel_width/100, - /// like the interactive command's Enter answer) and commit it as one - /// undo step. The drawing stays self-contained: no external file to lose. - /// Supports automatic 2-point calibration via `source_points` and `target_points`. + /// `embed_image` — pack the picture at `path` into an OLE2FRAME or linked RasterImage + /// placed with its lower-left corner at `at` (default width ≈ pixel_width/100). + /// Supports automatic reference scaling and calibration via `calibrate`: + /// `{"point_a": [px1, py1], "point_b": [px2, py2], "distance": 6500.0, "align_to": [0, 0]}`. #[cfg(not(target_arch = "wasm32"))] pub(super) fn control_embed_image(&mut self, req: &Value) -> Result, Value> { let i = self.active_tab; let path = string(req, "path")?; let image = crate::io::ole_embed::EmbeddedImage::from_file(std::path::Path::new(&path)) .map_err(|e| failure("embed_failed", e))?; - let (at, width) = if let (Some(src_pts), Some(tgt_pts)) = ( + + let (at, width, scale) = if let Some(cal) = req.get("calibrate") { + parse_calibrate(cal, image.pixel_width, image.pixel_height)? + } else if let (Some(src_pts), Some(tgt_pts)) = ( req.get("source_points").and_then(Value::as_array), req.get("target_points").and_then(Value::as_array), ) { - if src_pts.len() >= 2 && tgt_pts.len() >= 2 { - let p_s1 = parse_point_2d(&src_pts[0])?; - let p_s2 = parse_point_2d(&src_pts[1])?; - let p_t1 = parse_point_2d(&tgt_pts[0])?; - let p_t2 = parse_point_2d(&tgt_pts[1])?; - let d_px = ((p_s2[0] - p_s1[0]).powi(2) + (p_s2[1] - p_s1[1]).powi(2)).sqrt(); - let d_cad = ((p_t2[0] - p_t1[0]).powi(2) + (p_t2[1] - p_t1[1]).powi(2)).sqrt(); - if d_px <= 1e-6 { - return Err(failure( - "invalid_points", - "Source calibration points must be distinct", - )); - } - let scale = d_cad / d_px; - let w = image.pixel_width as f64 * scale; - // In image pixel coords, y is 0 at top and pixel_height at bottom. - // In CAD coordinates, lower-left is (x_ll, y_ll). - // x_target = x_ll + px * scale => x_ll = x_target - px * scale - // y_target = y_ll + (pixel_height - py) * scale => y_ll = y_target - (pixel_height - py) * scale - let x_ll = p_t1[0] - p_s1[0] * scale; - let y_ll = p_t1[1] - (image.pixel_height as f64 - p_s1[1]) * scale; - let calc_at = codec::types::Vector3::new(x_ll, y_ll, 0.0); - let final_at = embed_point(req).unwrap_or(calc_at); - let final_w = req["width"].as_f64().filter(|w| *w > 0.0).unwrap_or(w); - (final_at, final_w) - } else { - let default_width = (image.pixel_width as f64 / 100.0).max(1.0); - let width = req["width"].as_f64().filter(|w| *w > 0.0).unwrap_or(default_width); - (embed_point(req)?, width) - } + let dummy = json!({"source_points": src_pts, "target_points": tgt_pts}); + parse_calibrate(&dummy, image.pixel_width, image.pixel_height)? } else { let default_width = (image.pixel_width as f64 / 100.0).max(1.0); let width = req["width"].as_f64().filter(|w| *w > 0.0).unwrap_or(default_width); - (embed_point(req)?, width) + let scale = width / image.pixel_width as f64; + (embed_point(req)?, width, scale) }; + + let final_at = if req.get("calibrate").is_none() && req.get("source_points").is_none() { + at + } else { + embed_point(req).unwrap_or(at) + }; + let final_width = req["width"].as_f64().filter(|w| *w > 0.0).unwrap_or(width); + let height = final_width * image.pixel_height as f64 / image.pixel_width as f64; + + let layer_name = req["layer"].as_str().filter(|l| !l.is_empty()); + let lock_layer = req["lock_layer"].as_bool().unwrap_or(false); + if req["linked"].as_bool().unwrap_or(false) { - // Path-linked RasterImage + ImageDefinition: the drawing stores - // only the file path, so the picture file must travel with the - // drawing (unlike the embedded OLE2FRAME default below). self.push_undo_snapshot(i, "IMAGEATTACH"); - let height = width * image.pixel_height as f64 / image.pixel_width as f64; let handle = { let document = &mut self.tabs[i].scene.document; let definition_handle = document.allocate_handle(); @@ -207,45 +274,91 @@ impl OpenCADStudio { ); let mut entity = codec::entities::RasterImage::with_size( path, - at, + final_at, image.pixel_width as f64, image.pixel_height as f64, - width, + final_width, height, ); entity.definition_handle = Some(definition_handle); + if let Some(l_name) = layer_name { + entity.common.layer = l_name.to_string(); + if !document.layers.contains(l_name) { + let mut layer_record = codec::tables::Layer::new(l_name.to_string()); + layer_record.handle = document.allocate_handle(); + if lock_layer { + layer_record.flags.locked = true; + } + let _ = document.layers.add(layer_record); + } else if lock_layer { + if let Some(l) = document.layers.get_mut(l_name) { + l.flags.locked = true; + } + } + } document .add_entity(codec::EntityType::RasterImage(entity)) .map_err(|e| failure("embed_failed", e))? }; self.tabs[i].scene.populate_images_from_document(); + if layer_name.is_some() { + self.refresh_layer_panel(); + } self.post_ref_op(i); self.set_control_result(json!({ "handle": format!("{:X}", handle.value()), "kind": "RasterImage", "path": path, - "at": [at.x, at.y, at.z], - "width": width, + "at": [final_at.x, final_at.y, final_at.z], + "width": final_width, "height": height, + "scale": scale, "linked": true, + "layer": layer_name.unwrap_or("0"), })); return Ok(Task::none()); } + self.push_undo_snapshot(i, "IMAGEEMBED"); let handle = crate::io::ole_embed::add_embedded_image( &mut self.tabs[i].scene.document, &image, - at, - width, + final_at, + final_width, ) .map_err(|e| failure("embed_failed", e))?; + + if let Some(l_name) = layer_name { + let document = &mut self.tabs[i].scene.document; + if !document.layers.contains(l_name) { + let mut layer_record = codec::tables::Layer::new(l_name.to_string()); + layer_record.handle = document.allocate_handle(); + if lock_layer { + layer_record.flags.locked = true; + } + let _ = document.layers.add(layer_record); + } else if lock_layer { + if let Some(l) = document.layers.get_mut(l_name) { + l.flags.locked = true; + } + } + if let Some(ent) = document.get_entity_mut(handle) { + ent.common_mut().layer = l_name.to_string(); + } + self.refresh_layer_panel(); + } + self.post_ref_op(i); self.set_control_result(json!({ "handle": format!("{:X}", handle.value()), "kind": "Ole2Frame", "path": path, - "at": [at.x, at.y, at.z], - "width": width, + "at": [final_at.x, final_at.y, final_at.z], + "width": final_width, + "height": height, + "scale": scale, + "linked": false, + "layer": layer_name.unwrap_or("0"), })); Ok(Task::none()) } @@ -363,6 +476,7 @@ impl OpenCADStudio { if !path.to_ascii_lowercase().ends_with(".pdf") { return Err(failure("invalid_path", "plot writes .pdf files")); } + self.stamp_plot_fields(); let i = self.active_tab; let names = self.tabs[i].scene.layout_names(); let requested = req["layout"].as_str().unwrap_or("Model").to_owned(); @@ -1090,6 +1204,13 @@ impl OpenCADStudio { "plot_scale" => F::PlotScale(index(codec::fields::PLOT_SCALE_FORMATS.len())?), "count" => F::CountExpression(v.into()), "show_instances" => F::ShowCountInstances, + "placeholder" => F::PlaceholderProperty(index(codec::fields::BLOCK_PLACEHOLDER_PROPERTIES.len())?), + "table_function" => F::TableFunction( + ["Average", "Sum", "Count", "Cell"] + .into_iter() + .find(|f| f.eq_ignore_ascii_case(v)) + .ok_or_else(|| failure("invalid_value", "Average|Sum|Count|Cell"))?, + ), _ => return Err(failure("invalid_value", "Unknown field dialog key")), }) } diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs index 5a0a19739..f77225cfd 100644 --- a/src/app/control/mod.rs +++ b/src/app/control/mod.rs @@ -528,6 +528,7 @@ impl OpenCADStudio { Task::none(), ); } + #[cfg(not(target_arch = "wasm32"))] if op == "tools" { return ( json!({ diff --git a/src/app/drafting_settings.rs b/src/app/drafting_settings.rs index 04f5753ed..058fff8ab 100644 --- a/src/app/drafting_settings.rs +++ b/src/app/drafting_settings.rs @@ -44,6 +44,18 @@ impl DraftingSettingsState { } impl OpenCADStudio { + /// Turn Dynamic Input on or off with DYNMODE in step: off keeps the mode + /// negative (temporarily off), on restores it, or 3 when there was none. + pub(in crate::app) fn set_dyn_input(&mut self, on: bool) { + self.dyn_input = on; + let mode = self.dyn_mode.abs(); + self.dyn_mode = match (on, mode) { + (true, 0) => 3, + (true, mode) => mode, + (false, mode) => -mode, + }; + } + pub(super) fn drafting_settings_dirty(&self) -> bool { match (&self.drafting_settings_state, &self.drafting_settings_saved) { (Some(curr), Some(saved)) => curr.is_dirty(saved), @@ -100,7 +112,7 @@ impl OpenCADStudio { self.snapper.enabled = state.snap_modes.clone(); self.snapper.snap3d_enabled = state.osnap3d_on; self.snapper.enabled3d = state.snap3d_modes.clone(); - self.dyn_input = state.dyn_input_on; + self.set_dyn_input(state.dyn_input_on); self.quick_properties = state.quick_props_on; self.selection_cycling = state.selection_cycling_on; self.sync_vport_display(self.active_tab); diff --git a/src/app/layers.rs b/src/app/layers.rs index 7b64c3b26..07e7d8b66 100644 --- a/src/app/layers.rs +++ b/src/app/layers.rs @@ -68,6 +68,15 @@ impl OpenCADStudio { self.sync_ribbon_layers(); } + /// Pick up layers an entity edit registered on the fly (`ensure_layer`). + pub(super) fn sync_registered_layers(&mut self, tab: usize) { + if !std::mem::take(&mut self.tabs[tab].scene.layer_table_dirty) { + return; + } + self.tabs[tab].dirty = true; + self.refresh_layer_panel(); + } + pub(super) fn sync_ribbon_layers(&mut self) { let i = self.active_tab; // The Start (welcome) tab has no document — leave the layer and diff --git a/src/app/mod.rs b/src/app/mod.rs index 03f541acd..d1b4665cf 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -615,6 +615,8 @@ pub(super) struct OpenCADStudio { pub grid_beyond_limits: bool, /// Dynamic input overlay (F12): show coordinate tooltip near cursor. dyn_input: bool, + /// Dynamic input mode (DYNMODE): 0=off, 1=pointer, 2=dimensional, 3=both. + dyn_mode: i16, /// Currently visible page in the application Options dialog. options_tab: crate::ui::window::options::OptionsTab, /// The Options window's commit point (see `options_session`). @@ -4138,6 +4140,7 @@ impl OpenCADStudio { grid_adaptive: true, grid_beyond_limits: true, dyn_input: true, + dyn_mode: 3, options_tab: crate::ui::window::options::OptionsTab::General, options_saved: None, options_close_confirm: false, diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index edf47fe0c..6b1d737de 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -350,6 +350,7 @@ impl<'a> HostSession<'a> { return handle; } let handle = self.app.tabs[self.tab].scene.add_entity(entity); + self.app.sync_registered_layers(self.tab); self.publish_document_view(); handle } @@ -362,6 +363,7 @@ impl<'a> HostSession<'a> { let _ = self.normalize_scripted_entity(None, entity); } let handles = self.app.tabs[self.tab].scene.add_entities(entities); + self.app.sync_registered_layers(self.tab); self.publish_document_view(); handles } @@ -381,6 +383,7 @@ impl<'a> HostSession<'a> { EntityType::AttributeEntity(attribute) => self.replace_nested_attribute(attribute), entity => self.app.tabs[self.tab].scene.update_entity(entity), }; + self.app.sync_registered_layers(self.tab); if ok { self.publish_document_view(); } @@ -439,6 +442,7 @@ impl<'a> HostSession<'a> { }; assert!(updated); } + self.app.sync_registered_layers(self.tab); self.set_dirty(); self.publish_document_view(); Ok(()) @@ -9697,6 +9701,44 @@ step('undo_move', lambda: M.move([u], (0, 0, 0), (7, 0, 0))) assert!(!host.modify_layer(non_existent)); } + #[test] + fn test_entity_creation_with_novel_layer_refreshes_gui() { + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + + // Verify COGO_LINES does not exist initially + assert!(!app.tabs[0].scene.document.layers.contains("COGO_LINES")); + assert!(!app.ribbon.layer_names.contains(&"COGO_LINES".to_string())); + + // 1. Add entity via HostSession (simulating plugin add_entity / add_line) + { + let mut host = HostSession::new(&mut app, 0); + let mut line = codec::entities::Line::from_coords(0.0, 0.0, 0.0, 10.0, 10.0, 0.0); + line.common.layer = "COGO_LINES".to_string(); + let handle = host.add_entity(codec::EntityType::Line(line)); + assert_ne!(handle, codec::Handle::NULL); + } + + // Verify layer is created in document AND refreshed in ribbon + layer panel + assert!(app.tabs[0].scene.document.layers.contains("COGO_LINES")); + assert!(app.ribbon.layer_names.contains(&"COGO_LINES".to_string()), + "Ribbon layers should contain COGO_LINES after add_entity"); + assert!(app.tabs[0].layers.layers.iter().any(|l| l.name == "COGO_LINES"), + "Layer panel should contain COGO_LINES after add_entity"); + + // 2. Commit entity via command driver (simulating interactive tool / palette commit) + let mut line2 = codec::entities::Line::from_coords(10.0, 10.0, 0.0, 20.0, 20.0, 0.0); + line2.common.layer = "SURVEY_TRAVERSE".to_string(); + let _ = app.commit_entity_handle_preserve_layer(codec::EntityType::Line(line2)); + + // Verify SURVEY_TRAVERSE layer is created and refreshed in ribbon + layer panel + assert!(app.tabs[0].scene.document.layers.contains("SURVEY_TRAVERSE")); + assert!(app.ribbon.layer_names.contains(&"SURVEY_TRAVERSE".to_string()), + "Ribbon layers should contain SURVEY_TRAVERSE after commit_entity"); + assert!(app.tabs[0].layers.layers.iter().any(|l| l.name == "SURVEY_TRAVERSE"), + "Layer panel should contain SURVEY_TRAVERSE after commit_entity"); + } + #[test] fn test_plugin_execute_command() { let mut app = OpenCADStudio::new_for_test(); diff --git a/src/app/properties.rs b/src/app/properties.rs index 6ab2ae1bb..aefb4131b 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -3491,6 +3491,7 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} if tracks_dimension_chain { self.tabs[i].scene.last_created_dimension = new_handle; } + self.sync_registered_layers(i); new_handle } } diff --git a/src/app/settings.rs b/src/app/settings.rs index ee18517f7..a8d302a39 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -383,6 +383,7 @@ pub const MAX_SELECTED_GRIPS: usize = 4096; pub struct UserSettings { pub spacemouse: crate::input::spacemouse::Preferences, pub dyn_input: bool, + pub dyn_mode: i16, pub polar: bool, pub polar_increment_deg: f32, pub zoom_wheel_reversed: bool, @@ -788,6 +789,7 @@ impl Default for UserSettings { Self { spacemouse: crate::input::spacemouse::Preferences::default(), dyn_input: true, + dyn_mode: 3, polar: false, polar_increment_deg: 45.0, zoom_wheel_reversed: false, diff --git a/src/app/update/blocks_palette.rs b/src/app/update/blocks_palette.rs index 8c88eb728..003c97c78 100644 --- a/src/app/update/blocks_palette.rs +++ b/src/app/update/blocks_palette.rs @@ -77,6 +77,10 @@ impl OpenCADStudio { BlockPaletteMsg::ToggleOptions => { self.block_palette.options_collapsed ^= true; } + // Browsers expose no folder picker. + #[cfg(target_arch = "wasm32")] + BlockPaletteMsg::LibraryBrowse => {} + #[cfg(not(target_arch = "wasm32"))] BlockPaletteMsg::LibraryBrowse => { return Task::perform( async { diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 1f3d6ee60..65a04a0d1 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -120,25 +120,9 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven self.ribbon.note_panel_tool(&tool_id); self.ribbon.activate_tool(&tool_id); match event { - ModuleEvent::Command(cmd) => { - let task = self.dispatch_command(&cmd); - // One-shot tools (view changes, clipboard, toggles, - // audits…) leave nothing running: no interactive - // command and no dialog. Their highlight would stick - // forever — turn it off now. Interactive commands and - // dialog owners keep theirs; the command end / modal - // close clears those. (#355) - let i = self.active_tab; - if self.tabs[i].active_cmd.is_none() - && self.active_modal.is_none() - && !self.tabs[i].pan_mode - && !self.tabs[i].orbit_mode - && !self.tabs[i].zoom_dynamic_mode - { - self.ribbon.deactivate_tool(); - } - return task; - } + // `dispatch_command` turns a one-shot tool's highlight off + // again once nothing is left running (#355). + ModuleEvent::Command(cmd) => return self.dispatch_command(&cmd), ModuleEvent::OpenFileDialog => { self.command_line .push_info(crate::t!("Open DWG/DXF: not yet implemented.").as_ref()); diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 91d8298db..ecc041ff1 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -659,6 +659,7 @@ impl OpenCADStudio { crate::app::settings::UserSettings { spacemouse: self.spacemouse_preferences, dyn_input: self.dyn_input, + dyn_mode: self.dyn_mode, polar: self.polar_mode, polar_increment_deg: self.polar_increment_deg, zoom_wheel_reversed: self.zoom_wheel_reversed, @@ -752,7 +753,8 @@ impl OpenCADStudio { /// Apply restored preferences to live state. pub(in crate::app) fn apply_settings(&mut self, s: &crate::app::settings::UserSettings) { - self.dyn_input = s.dyn_input; + self.dyn_mode = s.dyn_mode; + self.set_dyn_input(s.dyn_input); self.polar_mode = s.polar; self.polar_increment_deg = s.polar_increment_deg; self.zoom_wheel_reversed = s.zoom_wheel_reversed; @@ -3770,7 +3772,20 @@ impl OpenCADStudio { task } + /// A plot gives its fields (PlotDate) the plot time, as the reference + /// stores it; the hosts are redrawn before the plot reads the scene. + pub(in crate::app) fn stamp_plot_fields(&mut self) { + let i = self.active_tab; + let hosts = crate::entities::field::stamp_plot_fields(&mut self.tabs[i].scene.document); + if !hosts.is_empty() { + let changes: Vec<_> = hosts.into_iter().map(|h| (h, crate::scene::ChangeKind::Modified)).collect(); + self.tabs[i].scene.bump_entities(&changes); + self.tabs[i].dirty = true; + } + } + fn print_all_pages(&mut self) -> Result, String> { + self.stamp_plot_fields(); let available = self.tabs[self.active_tab].scene.layout_names(); let selected: Vec = self .print_all_layouts @@ -5343,6 +5358,9 @@ impl OpenCADStudio { } self.active_modal = None; self.reset_modal_geometry(); + if !preview { + self.stamp_plot_fields(); + } // Extents, Window and Display use one plot path in both spaces. Only // Paper-space Layout is special: it uses the physical sheet bounds. diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index c0bbd3c11..6408592db 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -4199,7 +4199,7 @@ impl OpenCADStudio { Task::none() } Message::ToggleDynInput => { - self.dyn_input ^= true; + self.set_dyn_input(!self.dyn_input); Task::none() } Message::ToggleViewCube => { diff --git a/src/entities/field.rs b/src/entities/field.rs index c05b5ccb3..375250697 100644 --- a/src/entities/field.rs +++ b/src/entities/field.rs @@ -360,3 +360,68 @@ fn display_name() -> Option { #[cfg(not(target_os = "windows"))] None } + +/// Copy each attribute definition's field onto the matching attribute of a +/// new block reference (block placeholders resolve against the reference). +/// Returns the attributes that received a field. +pub fn attach_attribute_fields(document: &mut CadDocument, insert: Handle) -> Vec { + let context = OcsFieldContext(None); + document.attach_attribute_fields(insert, &context) +} + +/// Whether any attribute definition of `block` hosts a field. +pub fn block_has_attribute_fields(document: &CadDocument, block: &str) -> bool { + let Some(record) = document.block_records.iter().find(|r| r.name.eq_ignore_ascii_case(block)) else { + return false; + }; + record.entity_handles.iter().filter_map(|h| document.get_entity(*h)).any(|e| { + matches!(e, codec::entities::EntityType::AttributeDefinition(_)) && hosts_field(document, e) + }) +} + +/// Give plot-time fields (PlotDate) the plot's time; returns the hosts whose +/// text changed. +pub fn stamp_plot_fields(document: &mut CadDocument) -> Vec { + // The context reads a snapshot of the whole drawing; most drawings hold + // no field, and a large one should not be copied for every plot. + let has_fields = document + .objects + .values() + .any(|object| matches!(object, codec::objects::ObjectType::Field(_))); + if !has_fields { + return Vec::new(); + } + let snapshot = document.clone(); + let context = PlotContext(OcsFieldContext(Some(&snapshot))); + document.stamp_plot_fields(&context) +} + +/// The app context while a plot is produced. +struct PlotContext<'a>(OcsFieldContext<'a>); + +impl FieldContext for PlotContext<'_> { + fn now_julian(&self) -> f64 { + self.0.now_julian() + } + fn file_times(&self) -> Option<(f64, f64)> { + self.0.file_times() + } + fn plotting(&self) -> bool { + true + } + fn login(&self) -> Option { + self.0.login() + } + fn getvar(&self, name: &str) -> Option { + self.0.getvar(name) + } + fn file_size(&self) -> Option { + self.0.file_size() + } + fn date_locale(&self) -> codec::fields::DateLocale { + self.0.date_locale() + } + fn getenv(&self, name: &str) -> Option { + self.0.getenv(name) + } +} diff --git a/src/entities/object_data.rs b/src/entities/object_data.rs index 1568dc78e..b0056a369 100644 --- a/src/entities/object_data.rs +++ b/src/entities/object_data.rs @@ -228,6 +228,8 @@ fn embedded_name(value: Option<&codec::entities::EmbeddedEntity>) -> &'static st Some(codec::entities::EmbeddedEntity::Ellipse(_)) => "Ellipse", Some(codec::entities::EmbeddedEntity::Spline(_)) => "Spline", Some(codec::entities::EmbeddedEntity::LwPolyline(_)) => "Polyline", + // A 2D / 3D polyline profile the modeler keeps as a wire body. + Some(codec::entities::EmbeddedEntity::Body { .. }) => "Polyline", Some(codec::entities::EmbeddedEntity::Region(_)) => "Region", Some(codec::entities::EmbeddedEntity::Ray(_)) => "Ray", Some(codec::entities::EmbeddedEntity::XLine(_)) => "XLine", diff --git a/src/entities/point.rs b/src/entities/point.rs index fecb7b0a0..02f24a6e6 100644 --- a/src/entities/point.rs +++ b/src/entities/point.rs @@ -51,7 +51,7 @@ fn point_render(pt: &Point, pdmode: i16, s: f64) -> RenderEntity { object: RenderObject::Lines(vec![]), snap_pts: vec![(snap, SnapHint::Node)], tangent_geoms: vec![], - key_vertices: vec![[wx, wy, wz]], + key_vertices: vec![], fill_tris: vec![], }; } @@ -63,7 +63,8 @@ fn point_render(pt: &Point, pdmode: i16, s: f64) -> RenderEntity { key_vertices: if pt.thickness.abs() > 1.0e-10 { vec![[wx, wy, wz], [top.x, top.y, top.z]] } else { - vec![[wx, wy, wz]] + // Snaps as a Node only, never as an endpoint. + vec![] }, fill_tris: vec![], } diff --git a/src/entities/solid3d.rs b/src/entities/solid3d.rs index b8721f0fe..e2e7c3add 100644 --- a/src/entities/solid3d.rs +++ b/src/entities/solid3d.rs @@ -447,6 +447,8 @@ fn embedded_name(entity: Option<&codec::entities::EmbeddedEntity>) -> &'static s Some(codec::entities::EmbeddedEntity::Ellipse(_)) => "Ellipse", Some(codec::entities::EmbeddedEntity::Spline(_)) => "Spline", Some(codec::entities::EmbeddedEntity::LwPolyline(_)) => "Polyline", + // A 2D / 3D polyline profile the modeler keeps as a wire body. + Some(codec::entities::EmbeddedEntity::Body { .. }) => "Polyline", Some(codec::entities::EmbeddedEntity::Region(_)) => "Region", Some(codec::entities::EmbeddedEntity::Ray(_)) => "Ray", Some(codec::entities::EmbeddedEntity::XLine(_)) => "XLine", diff --git a/src/io/mod.rs b/src/io/mod.rs index 85cfe2906..3a4c9d52a 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -1036,6 +1036,7 @@ fn finalize_loaded_outcome( ) -> Result { let doc = &mut outcome.document; normalize_block_origins(doc); + materialize_missing_block_markers(doc); normalize_knotless_splines(doc); if outcome.stats.source_format == Some(codec::SourceFormat::Dxf) { fix_dxf_dimension_rotations(doc); @@ -1240,6 +1241,84 @@ fn normalize_block_origins(doc: &mut CadDocument) { } } +/// The DXF section reader keeps BLOCK/ENDBLK marker identities on the block +/// record but never materializes the sentinel entities, so +/// `block_entity_handle`/`block_end_handle` dangle after every DXF load +/// (reopened baked `*D` dimension blocks hit this every time). Re-create any +/// missing sentinel so each record points at a real Block/BlockEnd again. +/// Idempotent: records whose markers already resolve (DWG loads, live docs) +/// are untouched. File handles are reused when still free so a repair + +/// resave stays handle-stable; otherwise a fresh handle is minted and the +/// record is pointed at it. +fn materialize_missing_block_markers(doc: &mut CadDocument) { + use codec::entities::{Block, BlockEnd}; + use codec::{EntityType, Handle}; + + struct Work { + name: String, + record_handle: Handle, + base_point: codec::types::Vector3, + block_handle: Handle, + end_handle: Handle, + need_block: bool, + need_end: bool, + } + let work: Vec = doc + .block_records + .iter() + .map(|record| Work { + name: record.name.clone(), + record_handle: record.handle, + base_point: record.base_point, + block_handle: record.block_entity_handle, + end_handle: record.block_end_handle, + need_block: !matches!( + doc.get_entity(record.block_entity_handle), + Some(EntityType::Block(_)) + ), + need_end: !matches!( + doc.get_entity(record.block_end_handle), + Some(EntityType::BlockEnd(_)) + ), + }) + .filter(|w| w.need_block || w.need_end) + .collect(); + + for w in work { + if w.need_block { + let mut marker = Block::new(&w.name, w.base_point); + // Reuse the file handle only if nothing else claims it; an + // explicit handle is respected by add_entity (bumping the counter + // past it), otherwise a NULL handle mints a fresh one below. + if !w.block_handle.is_null() && doc.get_entity(w.block_handle).is_none() { + marker.common.handle = w.block_handle; + } + marker.common.owner_handle = w.record_handle; + if let Ok(handle) = doc.add_entity(EntityType::Block(marker)) { + if handle != w.block_handle { + if let Some(record) = doc.block_records.get_mut(&w.name) { + record.block_entity_handle = handle; + } + } + } + } + if w.need_end { + let mut marker = BlockEnd::new(); + if !w.end_handle.is_null() && doc.get_entity(w.end_handle).is_none() { + marker.common.handle = w.end_handle; + } + marker.common.owner_handle = w.record_handle; + if let Ok(handle) = doc.add_entity(EntityType::BlockEnd(marker)) { + if handle != w.end_handle { + if let Some(record) = doc.block_records.get_mut(&w.name) { + record.block_end_handle = handle; + } + } + } + } + } +} + /// A RasterImage entity stores its file path on the linked ImageDefinition, /// often as the original author's absolute path (e.g. a Windows / VMware share) /// that doesn't exist on this machine. Resolve each image to a usable path: diff --git a/src/io/pdf_export.rs b/src/io/pdf_export.rs index 8609fe4e0..27ecc88c1 100644 --- a/src/io/pdf_export.rs +++ b/src/io/pdf_export.rs @@ -621,6 +621,23 @@ fn append_pdf_page( draw_items.push((image.image.draw_depth, 1u8, sequence, DrawItem::Image(image))); sequence += 1; } + // Everything outside the export window would only be clipped away, yet + // still written: a window over a corner of a large drawing came out as a + // blank page of several megabytes. Drop items whose ink misses the window. + if let Some((cx, cy, cw, ch)) = clip { + let marker_h = paper_h as f64 / scale.max(1e-6) as f64; + let window = [cx as f64, cy as f64, (cx + cw) as f64, (cy + ch) as f64]; + draw_items.retain(|(_, _, _, item)| { + let bounds = match item { + DrawItem::WireFill(wire) | DrawItem::Wire(wire) | DrawItem::Text(wire) => { + wire_sheet_bounds(&wire.wire, ox, oy, marker_h) + } + DrawItem::Hatch(hatch) => hatch_sheet_bounds(hatch, ox, oy), + DrawItem::Image(_) => None, + }; + bounds.is_none_or(|b| sheet_overlaps(b, window)) + }); + } draw_items.sort_by(|a, b| { a.0.total_cmp(&b.0) .then_with(|| a.1.cmp(&b.1)) @@ -928,6 +945,70 @@ fn append_pdf_page( Ok(()) } +#[cfg(not(target_arch = "wasm32"))] +/// Slack around the export window, in sheet mm, so a stroke whose centreline +/// sits just outside still contributes its width. +const CULL_MARGIN_MM: f64 = 10.0; + +#[cfg(not(target_arch = "wasm32"))] +fn sheet_overlaps(b: [f64; 4], window: [f64; 4]) -> bool { + b[0] <= window[2] + CULL_MARGIN_MM + && b[2] >= window[0] - CULL_MARGIN_MM + && b[1] <= window[3] + CULL_MARGIN_MM + && b[3] >= window[1] - CULL_MARGIN_MM +} + +#[cfg(not(target_arch = "wasm32"))] +fn grow(bounds: &mut Option<[f64; 4]>, x: f64, y: f64) { + if !x.is_finite() || !y.is_finite() { + return; + } + let b = bounds.get_or_insert([x, y, x, y]); + b[0] = b[0].min(x); + b[1] = b[1].min(y); + b[2] = b[2].max(x); + b[3] = b[3].max(y); +} + +#[cfg(not(target_arch = "wasm32"))] +/// Sheet-mm bounds of everything a wire inks (strokes, glyphs, fills). +fn wire_sheet_bounds(wire: &WireModel, ox: f64, oy: f64, marker_h: f64) -> Option<[f64; 4]> { + let mut bounds = None; + for (index, point) in wire.points.iter().enumerate() { + if point[0].is_finite() && point[1].is_finite() { + let world = wire.point_world(index, marker_h); + grow(&mut bounds, world.x + ox, world.y + oy); + } + } + for vertex in &wire.text_verts { + let [x, y] = glyph_world_xy(vertex); + grow(&mut bounds, x + ox, y + oy); + } + for (index, point) in wire.fill_tris.iter().enumerate() { + let low = wire.fill_tris_low.get(index).copied().unwrap_or([0.0; 3]); + grow( + &mut bounds, + point[0] as f64 + low[0] as f64 + ox, + point[1] as f64 + low[1] as f64 + oy, + ); + } + bounds +} + +#[cfg(not(target_arch = "wasm32"))] +/// Sheet-mm bounds of a hatch, resolved the way `emit_hatch` places it. +fn hatch_sheet_bounds(hatch: &HatchModel, ox: f64, oy: f64) -> Option<[f64; 4]> { + let mut bounds = None; + for &[x, y] in hatch.boundary.iter() { + grow( + &mut bounds, + x as f64 + hatch.world_origin[0] + ox, + y as f64 + hatch.world_origin[1] + oy, + ); + } + bounds +} + /// Build a PDF dash array (in points) from a WireModel linetype pattern. /// /// `pattern` holds the linetype run lengths in paper-mm: positive = dash, diff --git a/src/io/thumbnail.rs b/src/io/thumbnail.rs index bdadfe6c1..be8facc71 100644 --- a/src/io/thumbnail.rs +++ b/src/io/thumbnail.rs @@ -178,7 +178,6 @@ pub fn extract_to_png(input: &std::path::Path, output: &std::path::Path, size: u /// Read a DWG's embedded preview and decode it to an iced image handle for the /// Start page's recent-file thumbnails. `None` for DXF/other files, a missing /// preview, or an undecodable format (WMF). -#[cfg(not(target_arch = "wasm32"))] pub fn read_handle(path: &std::path::Path) -> Option { let img = dwg_thumbnailer::extract(path, MAX_DIM)?; let (w, h) = (img.width(), img.height()); diff --git a/src/io/xref.rs b/src/io/xref.rs index f335f4251..52027741e 100644 --- a/src/io/xref.rs +++ b/src/io/xref.rs @@ -513,8 +513,8 @@ pub(crate) fn ensure_block_entities(doc: &mut CadDocument, block_name: &str) { /// - Kept verbatim (audited, benign): linetype handles (the name carries the /// lookup), Leader `annotation_handle`, Hatch `boundary_handles`, /// `attdef_handle`, reactors, xdata, extension dictionaries, graphic data, -/// Dimension anonymous-block names (`*D…` — geometry regenerates from the -/// style), Hatch pattern names (no pattern table exists), Shape style +/// Dimension anonymous-block names on BIND (`*D…` — geometry regenerates +/// from the style), Hatch pattern names (no pattern table exists), Shape style /// names, MultiLeader line-type/arrowhead handles, Table cell internals /// (rows/cells keep xref text-style names), Viewport visual-style handles, /// SectionSymbol style handles, `Extended` entities. @@ -717,8 +717,21 @@ fn import_xref_symbols( // Nested block records: prefixed clones with cleared membership, detached // layout pointer, fresh handles (same shape as the load path always made). + // The load path also takes the anonymous pictures (`*D` dimensions, `*U` + // block representations): a dimension draws from its block by name, and + // without the copy it falls back to a host block of the same name or a + // regeneration that misses the file's own overrides. BIND keeps skipping + // them, as a `$N$` prefix would make an invalid anonymous name. + let take_anonymous = matches!(naming, SymbolNaming::MergePipe { .. }); for br in xref_doc.block_records.iter() { - if br.name.starts_with('*') || br.flags.is_xref || br.flags.is_xref_overlay { + let anonymous = br.name.starts_with('*'); + let layout_block = br.is_layout() + || br.name.eq_ignore_ascii_case("*Model_Space") + || br.name.to_ascii_lowercase().starts_with("*paper_space"); + if (anonymous && (!take_anonymous || layout_block)) + || br.flags.is_xref + || br.flags.is_xref_overlay + { continue; } let old = br.name.clone(); @@ -831,8 +844,11 @@ fn remap_xref_entity( remap_mtext_style(m, maps); } } - // ── Dim-style names (+ anonymous-block refs stay verbatim) ── - EntityType::Dimension(d) => rename(&maps.dim_styles, &mut d.base_mut().style_name), + // ── Dim-style names + the dimension's picture block ── + EntityType::Dimension(d) => { + rename(&maps.dim_styles, &mut d.base_mut().style_name); + rename(&maps.blocks, &mut d.base_mut().block_name); + } EntityType::Leader(l) => rename(&maps.dim_styles, &mut l.dimension_style), EntityType::Tolerance(t) => { rename(&maps.dim_styles, &mut t.dimension_style_name); diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index abf76c35e..78d01a199 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -235,6 +235,12 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Select object" => Some(("insert", "field-select-object")), "Field expression" => Some(("insert", "field-expression")), "Inserts a field: text that updates from the drawing, the date or another source." => Some(("insert", "field-help")), + "Average" => Some(("insert", "field-average")), + "Sum" => Some(("insert", "field-sum")), + "Object name" => Some(("insert", "field-object-name")), + "Block placeholder" => Some(("insert", "field-block-placeholder")), + "Only accessible in the block editor" => Some(("insert", "field-block-editor-only")), + "Block reference property" => Some(("insert", "field-block-reference-property")), "Linked" => Some(("insert", "field-cat-linked")), "Current precision" => Some(("insert", "field-current-precision")), "Evaluate" => Some(("insert", "field-evaluate")), @@ -2043,6 +2049,8 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "OFFSET%{n} Click through point or [Multiple]:" => Some(("modify", "offset-n-click-through-point-or-multiple")), "OFFSET%{n} Multiple Click next side [distance %{d}]:" => Some(("modify", "offset-n-multiple-click-next-side-distance-d")), "OFFSET%{n} Multiple Click next through point:" => Some(("modify", "offset-n-multiple-click-next-through-point")), + "OFFSET%{n} Click through point or type distance, negative for the other side [Multiple/Erase/Layer] <%{d}>:" => Some(("modify", "offset-n-click-through-point-or-type-distance")), + "OFFSET%{n} Multiple Click through point or type distance, negative for the other side [Erase/Layer] <%{d}>:" => Some(("modify", "offset-n-multiple-click-through-point-or-type-distance")), "OK" => Some(("common", "ok")), "OLE Frame" => Some(("properties", "ole-frame")), "OOPS: nothing to restore." => Some(("file", "oops-nothing-to-restore")), diff --git a/src/mcp.rs b/src/mcp.rs index e60c86629..dd88a88dc 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -11,20 +11,33 @@ use std::{ collections::{HashMap, VecDeque}, fs::{File, OpenOptions}, io::{self, BufReader, Read, Write}, - net::TcpStream, + net::{SocketAddr, TcpStream}, path::{Path, PathBuf}, process::{Child, Command, Stdio}, + sync::mpsc::{Receiver, TryRecvError}, thread, - time::{Duration, Instant}, + time::{Duration, Instant, SystemTime}, }; +#[cfg(any(windows, target_os = "macos"))] +use std::collections::HashSet; const PROTOCOL_VERSION: &str = "2025-11-25"; const MODERN_PROTOCOL_VERSION: &str = "2026-07-28"; +/// Pinned content digest of [`tool_definitions`]. +/// +/// The `tool_schema_digest_is_pinned` test fails whenever the MCP tool surface +/// changes, so schema drift (new params, renamed tools) is always a conscious, +/// reviewed edit — and clients can detect a stale bridge by comparing digests. +#[cfg(test)] +const TOOL_SCHEMA_DIGEST: &str = "bb2640277721f23e08cf17085bd9204753f810ece27abf31402be876e2c4683d"; const MAX_REQUEST: usize = 1_048_576; const MAX_RESPONSE: u64 = 16 * 1024 * 1024; const CACHE_TTL_MS: u64 = 3_600_000; +/// Freshness for resources/*, which vary as sessions open and snapshots +/// land. Tools and discovery are static by comparison, so they keep the hour. +const RESOURCE_TTL_MS: u64 = 60_000; const TASK_TTL_MS: u64 = 3_600_000; -pub(crate) const INSTRUCTIONS: &str = "Call ocs_sessions, then pass its session_id as ocs_session_id to ocs_read, ocs_execute and ocs_capture. Read capabilities to discover the complete CAD automation surface. Call record_schema to discover every record type, property path, JSON type, enum, unit, constraint and write rule before editing unfamiliar data. Use records to inspect every serializable entity, object, table, header and document record; filter with RFC 6901 JSON Pointer paths. Use set_properties for atomic, type-checked record edits and preserve document_id, revision and request_id. Use commands with parameters.name for a command manifest. Use batch when several steps are known, and request changed_entities when resulting geometry is needed. For interactive work, call start and follow state.command.accepts, options and input_example. To have the person at the screen pick entities for you, call user_select and keep polling until it completes; running means they are still picking. A run.cmd contains the command name followed by prompt answers separated by spaces; points use x,y or x,y,z. After a timeout, query the existing operation and never replay a mutation with a new request_id. waiting_input and running are not completion. Let OCS and its geometry kernel calculate geometry; use query near, contains_point and intersections for exact relationships. ARCHITECTURAL VECTORIZATION DIRECTIVE: When converting or vectorizing a floorplan from an image or sketch: 1. Attach reference images as Xref underlays via embed_image on layer _XREF and lock it. 2. NEVER draw loose lines or arcs for doors or windows; always query records (collection: 'block_records') and insert Block References (type: 'INSERT') on A-DOOR and A-GLAZ. If a block is missing, draft standard geometry at origin (0,0) and register it with block_define before inserting. 3. Categorize layers cleanly: A-WALL-EXTR, A-WALL-INTR, A-WALL-HATCH, A-DOOR, A-GLAZ, A-ANNO-TEXT, A-ANNO-DIMS. 4. Always verify drafted geometry using ocs_capture with annotate: true (Set-of-Marks entity IDs) and diff: true (visual dirty streaming). ocs_capture operates quietly in background and overlapped window states without stealing user focus. Viewports and captured snapshots are available as MCP resources under cad://session/{session_id}/viewport.png and cad://session/{session_id}/snapshot/{hash}.png; ocs_capture accepts delivery: 'resource' to avoid large inline base64 payloads, supplies standardized spatial grounding in _spatial, supports diff: true for streaming dirty visual regions, and provides multiscale DeepZoom pyramidal tiling via tile: {level, x, y} or cad://session/{session_id}/pyramid/manifest.json and cad://session/{session_id}/tile/{level}/{x}/{y}.png. Before delivery call audit with the intended target_format and target_version; use save_verified with an explicit absolute path to save, reopen, hash and compare the semantic manifest."; +pub(crate) const INSTRUCTIONS: &str = "Call ocs_sessions, then pass its session_id as ocs_session_id to ocs_read, ocs_execute and ocs_capture. Read capabilities to discover the complete CAD automation surface. Call record_schema to discover every record type, property path, JSON type, enum, unit, constraint and write rule before editing unfamiliar data. Use records to inspect every serializable entity, object, table, header and document record; filter with RFC 6901 JSON Pointer paths. Use set_properties for atomic, type-checked record edits and preserve document_id, revision and request_id. Use commands with parameters.name for a command manifest. Use batch when several steps are known, and request changed_entities when resulting geometry is needed. For interactive work, call start and follow state.command.accepts, options and input_example. To have the person at the screen pick entities for you, call user_select and keep polling until it completes; running means they are still picking. A run.cmd contains the command name followed by prompt answers separated by spaces; points use x,y or x,y,z. After a timeout, query the existing operation and never replay a mutation with a new request_id. waiting_input and running are not completion. Let OCS and its geometry kernel calculate geometry; use query near, contains_point and intersections for exact relationships. ARCHITECTURAL VECTORIZATION DIRECTIVE: When converting or vectorizing a floorplan from an image or sketch: 1. Attach reference images as Xref underlays via embed_image on layer _XREF and lock it. 2. NEVER draw loose lines or arcs for doors or windows; always query records (collection: 'block_records') and insert Block References (type: 'INSERT') on A-DOOR and A-GLAZ. If a block is missing, draft standard geometry at origin (0,0) and register it with block_define before inserting. 3. Categorize layers cleanly: A-WALL-EXTR, A-WALL-INTR, A-WALL-HATCH, A-DOOR, A-GLAZ, A-ANNO-TEXT, A-ANNO-DIMS. 4. Always verify drafted geometry using ocs_capture with annotate: true (Set-of-Marks entity IDs) and diff: true (visual dirty streaming). ocs_capture operates quietly in background and overlapped window states without stealing user focus. Viewports and captured snapshots are available as MCP resources under cad://session/{session_id}/viewport.png and cad://session/{session_id}/snapshot/{hash}.png; ocs_capture accepts delivery: 'resource' to avoid large inline base64 payloads, supplies standardized spatial grounding in _spatial, supports diff: true for streaming dirty visual regions, and provides multiscale DeepZoom pyramidal tiling via tile: {level, x, y} or cad://session/{session_id}/pyramid/manifest.json and cad://session/{session_id}/tile/{level}/{x}/{y}.png. Before delivery call audit with the intended target_format and target_version; use save_verified with an explicit absolute path to save, reopen, hash and compare the semantic manifest. When you first connect, announce the build you are working with to the user from the `bridge` object on ocs_sessions states and hello/capabilities responses (OpenCADStudio version, build_rev, tool_schema digest); repeat the announcement if a later handshake reports a different build."; const READ_OPS: &[&str] = &[ "state", "hello", @@ -138,6 +151,118 @@ struct Descriptor { session_id: String, port: u16, token: String, + /// GUI process id when the descriptor writer knows it. Used to skip dead + /// sessions without a (possibly hanging) TCP probe; absent on legacy + /// files, which keep the old probe path. + #[serde(default)] + pid: Option, +} + +/// True when `pid` currently exists. Linux checks /proc (no spawn, no new +/// dependencies); other platforms go through one shared snapshot per +/// [`descriptors`] pass (see below) instead of per-pid spawns. +#[cfg(target_os = "linux")] +fn pid_alive(pid: u64) -> bool { + // Fail open where /proc is unavailable (containers, chroots): without it + // every pid would read "dead" and live sessions would be dropped en masse. + Path::new("/proc").exists() && Path::new(&format!("/proc/{pid}")).exists() +} + +/// One snapshot of all live PIDs. `None` on any failure so callers fail open +/// (treat every descriptor as alive) instead of dropping live sessions +/// because enumeration broke. +#[cfg(windows)] +fn live_pids_snapshot() -> Option> { + let output = Command::new("tasklist") + .args(["/FO", "CSV", "/NH"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let mut set = HashSet::new(); + for line in String::from_utf8_lossy(&output.stdout).lines() { + let mut fields = line.split("\",\""); + fields.next()?; + if let Some(pid) = fields + .next() + .and_then(|field| field.trim_matches('"').parse::().ok()) + { + set.insert(pid); + } + } + (!set.is_empty()).then_some(set) +} + +/// macOS has no /proc: same one-snapshot approach as Windows via `ps`. +#[cfg(target_os = "macos")] +fn live_pids_snapshot() -> Option> { + let output = Command::new("ps").args(["-Ao", "pid="]).output().ok()?; + if !output.status.success() { + return None; + } + let mut set = HashSet::new(); + for token in String::from_utf8_lossy(&output.stdout).split_whitespace() { + if let Ok(pid) = token.parse::() { + set.insert(pid); + } + } + (!set.is_empty()).then_some(set) +} + +/// Process snapshots cost a spawn (~100ms), but `ocs_sessions` can run every +/// agent turn — cache each snapshot briefly. Failures cache as `None`, which +/// keeps the fail-open behavior below. +#[cfg(any(windows, target_os = "macos"))] +const PID_SNAPSHOT_TTL: Duration = Duration::from_secs(10); + +#[cfg(any(windows, target_os = "macos"))] +std::thread_local! { + static PID_SNAPSHOT_CACHE: std::cell::RefCell<(Instant, Option>)> = + std::cell::RefCell::new((Instant::now() - PID_SNAPSHOT_TTL - Duration::from_secs(1), None)); +} + +#[cfg(any(windows, target_os = "macos"))] +fn live_pids_cached() -> Option> { + PID_SNAPSHOT_CACHE.with(|cache| { + { + let (stamp, cached) = &*cache.borrow(); + if stamp.elapsed() < PID_SNAPSHOT_TTL { + return cached.clone(); + } + } + let fresh = live_pids_snapshot(); + *cache.borrow_mut() = (Instant::now(), fresh.clone()); + fresh + }) +} + +/// Drop the cached PID snapshot so the next discovery pass enumerates +/// fresh. Called after spawning a GUI: without this, the pre-spawn snapshot +/// does not contain the newborn pid, and the corpse cleanup below would +/// delete its just-written descriptor as "dead on arrival". +#[cfg(any(windows, target_os = "macos"))] +fn live_pids_invalidate() { + PID_SNAPSHOT_CACHE.with(|cache| { + *cache.borrow_mut() = ( + Instant::now() - PID_SNAPSHOT_TTL - Duration::from_secs(1), + None, + ); + }); +} + +#[cfg(not(any(windows, target_os = "macos")))] +fn live_pids_invalidate() {} + +/// Descriptors younger than this are never deleted, even when their pid is +/// missing from the snapshot: the snapshot may predate the spawn, and slow +/// starters write late. Deletion stays for genuinely old corpses. +const DESCRIPTOR_GRACE: Duration = Duration::from_secs(120); + +/// Whether a pid missing from the snapshot authorizes deletion. Pure +/// predicate so the staleness rule is unit-testable: only old files go. +fn stale_snapshot_may_delete(missing_from_snapshot: bool, file_age: Duration) -> bool { + missing_from_snapshot && file_age >= DESCRIPTOR_GRACE } struct GuiClient { @@ -145,6 +270,105 @@ struct GuiClient { state: Value, client_id: String, batches: VecDeque, + /// MCP request id (as string) -> GUI request id for ops still pending + /// server-side. Lets an idle-arriving `notifications/cancelled` find + /// the GUI op to dismiss. Entries leave on terminal responses and + /// dismissals; abandoned entries mirror GUI sessions that outlive + /// interest (pre-existing behavior, two small strings each). + inflight: HashMap, + /// GUI request ids dismissed by cancel/timeout: late completions and + /// re-polls answer `cancelled` without touching the GUI. Capped. + dismissed: VecDeque, +} + +/// Reader-thread inbox for stdin lines. The reader never writes: the main +/// loop (and, during waits, the wait loop) is the only consumer, so stdout +/// stays single-writer. Non-cancel lines met during a wait are stowed in +/// `backlog` and handled in order once the wait ends. +struct CancelPump { + rx: Receiver>, + backlog: VecDeque, +} + +enum PumpEvent { + Cancelled, + Quiet, +} + +impl CancelPump { + fn next(&mut self) -> Option { + if let Some(line) = self.backlog.pop_front() { + return Some(line); + } + match self.rx.recv() { + Ok(Ok(line)) => Some(line), + Ok(Err(error)) => { + eprintln!("MCP input error: {error}"); + None + } + Err(_) => None, + } + } + + /// Drain newly arrived lines for up to `quantum`: consume a cancel naming + /// `mkey`, stow everything else in order. Never blocks past the quantum. + fn wait_line(&mut self, quantum: Duration, mkey: Option<&str>) -> PumpEvent { + let deadline = Instant::now() + quantum; + loop { + match self.rx.try_recv() { + Ok(Ok(line)) => { + if mkey.is_some_and(|key| is_cancel_for(&line, key)) { + return PumpEvent::Cancelled; + } + self.backlog.push_back(line); + } + // Reader gone or input broken: end the wait quietly; the + // main loop observes the closed channel right after. + Ok(Err(_)) | Err(TryRecvError::Disconnected) => return PumpEvent::Quiet, + Err(TryRecvError::Empty) => { + if Instant::now() >= deadline { + return PumpEvent::Quiet; + } + thread::sleep(Duration::from_millis(5)); + } + } + } + } +} + +/// MCP request-id key for cancel matching. String and number ids stay +/// distinct (`7` vs `"7"`): both sides stringify the same JSON value. +fn mcp_key(id: &Value) -> String { + id.to_string() +} + +fn is_cancel_for(line: &str, mkey: &str) -> bool { + let Ok(message) = serde_json::from_str::(line) else { + return false; + }; + if message.get("method").and_then(Value::as_str) != Some("notifications/cancelled") { + return false; + } + message + .get("params") + .and_then(|params| params.get("requestId")) + .is_some_and(|id| id.to_string() == mkey) +} + +fn wait_deadline(op: &str, wait_seconds: f64) -> Duration { + let capped = if matches!(op, "user_select" | "getpoint") { + wait_seconds.clamp(0.0, INTERACTIVE_MAX_WAIT.as_secs_f64()) + } else { + wait_seconds.clamp(0.0, 60.0) + }; + Duration::from_secs_f64(capped) +} + +/// Only a wait that actually reaches the interactive ceiling dismisses the +/// prompt. Shorter waits return `running` so the agent can re-poll; without +/// this distinction the first short poll would kill every interactive op. +fn interactive_timeout(wait_seconds: f64) -> bool { + wait_seconds >= INTERACTIVE_MAX_WAIT.as_secs_f64() } struct BatchExecution { @@ -167,6 +391,9 @@ struct McpTask { last_updated_at: String, result: Option, error: Option, + /// Cooperative cancel acknowledged: polls report `cancelled` and late + /// completions are discarded (checked before result/error). + cancelled: bool, } struct StoredTask { @@ -376,6 +603,23 @@ impl ResourceStore { } } +/// (mtime, byte length) of this executable. A rebuild changes at least one of +/// the two, so a mismatch means this bridge serves a stale tool schema. +fn exe_fingerprint() -> Option<(SystemTime, u64)> { + let exe = std::env::current_exe().ok()?; + let meta = std::fs::metadata(exe).ok()?; + Some((meta.modified().ok()?, meta.len())) +} + +/// True when both fingerprints are known and differ (unknown counts as same +/// to avoid false-positive exits on locked-down filesystems). +fn exe_superseded(baseline: &Option<(SystemTime, u64)>) -> bool { + match (baseline, &exe_fingerprint()) { + (Some(before), Some(now)) => before != now, + _ => false, + } +} + fn random_id() -> Result { let mut bytes = [0u8; 16]; getrandom::fill(&mut bytes).map_err(|error| error.to_string())?; @@ -427,42 +671,101 @@ fn private_descriptor(_: &Path) -> bool { true } -fn exchange(descriptor: &Descriptor, request: Value, timeout: Duration) -> Result { +/// Tool-call failure split for error routing: Tool-domain failures carry +/// model-actionable guidance and stay `isError` results (SEP-1303); bridge +/// and GUI infrastructure failures (nothing the model can fix) become +/// JSON-RPC errors. `From` defaults to Tool so existing sites keep +/// working unchanged; infrastructure sites opt in explicitly. +#[derive(Debug)] +enum CallError { + Tool(String), + Infra(String), + /// Client cancelled the in-flight request: send nothing back. + Cancelled, +} + +/// Interactive picks may legitimately take a human minutes; everything else +/// keeps the 60 s clamp. This bounds a single call; agents re-poll with +/// small waits by design. +const INTERACTIVE_MAX_WAIT: Duration = Duration::from_secs(600); + +impl From for CallError { + fn from(message: String) -> Self { + CallError::Tool(message) + } +} + +impl From<&str> for CallError { + fn from(message: &str) -> Self { + CallError::Tool(message.to_string()) + } +} + +// Collapses back to String where the caller maps everything to one code +// anyway (resources/read not-found codes); the routing decision is made +// by the caller, not the classification. +impl From for String { + fn from(error: CallError) -> Self { + error.message().to_string() + } +} + +impl CallError { + fn message(&self) -> &str { + match self { + CallError::Tool(message) | CallError::Infra(message) => message, + CallError::Cancelled => "cancelled by client", + } + } + + fn infra(message: impl ToString) -> Self { + CallError::Infra(message.to_string()) + } +} + +fn exchange(descriptor: &Descriptor, request: Value, timeout: Duration) -> Result { let mut object = request .as_object() .cloned() - .ok_or_else(|| "GUI request must be an object".to_string())?; + .ok_or_else(|| CallError::infra("GUI request must be an object"))?; object.insert("token".into(), Value::String(descriptor.token.clone())); object.insert( "session_id".into(), Value::String(descriptor.session_id.clone()), ); object.insert("protocol".into(), Value::from(1)); - let mut wire = serde_json::to_vec(&Value::Object(object)).map_err(|e| e.to_string())?; + let mut wire = serde_json::to_vec(&Value::Object(object)) + .map_err(CallError::infra)?; wire.push(b'\n'); if wire.len() > MAX_REQUEST { - return Err("Request exceeds 1 MiB".into()); + // Caller-caused (batch too big): model-actionable, stays Tool. + return Err("Request exceeds 1 MiB".to_string().into()); } - let mut stream = - TcpStream::connect(("127.0.0.1", descriptor.port)).map_err(|error| error.to_string())?; + let mut stream = TcpStream::connect_timeout( + &SocketAddr::from(([127, 0, 0, 1], descriptor.port)), + timeout, + ) + .map_err(CallError::infra)?; stream .set_read_timeout(Some(timeout)) - .map_err(|error| error.to_string())?; + .map_err(CallError::infra)?; stream .set_write_timeout(Some(timeout)) - .map_err(|error| error.to_string())?; - stream.write_all(&wire).map_err(|error| error.to_string())?; + .map_err(CallError::infra)?; + stream.write_all(&wire).map_err(CallError::infra)?; let mut response = String::new(); BufReader::new(stream) .take(MAX_RESPONSE + 1) .read_to_string(&mut response) - .map_err(|error| error.to_string())?; + .map_err(CallError::infra)?; if response.is_empty() || response.len() as u64 > MAX_RESPONSE { - return Err("No valid OCS response; query request_id before retrying a mutation".into()); + return Err(CallError::infra( + "No valid OCS response; query request_id before retrying a mutation", + )); } - serde_json::from_str(response.trim_end()).map_err(|error| error.to_string()) + serde_json::from_str(response.trim_end()).map_err(CallError::infra) } fn descriptors() -> Result, String> { @@ -479,6 +782,8 @@ fn descriptors() -> Result, String> { .collect(); paths.sort(); + #[cfg(any(windows, target_os = "macos"))] + let live_pids = live_pids_cached(); let mut found = Vec::new(); for path in paths { if !private_descriptor(&path) { @@ -490,6 +795,48 @@ fn descriptors() -> Result, String> { let Ok(descriptor) = serde_json::from_str::(&text) else { continue; }; + // A dead GUI leaves its descriptor file behind; its TCP port may hang + // instead of refusing, which used to stall discovery past client + // timeouts. Skip (and delete) pid-verified corpses before probing. + // Deletion waits out DESCRIPTOR_GRACE: the PID snapshot may predate + // a spawn, and a newborn pid missing from it must never read "dead". + // Unknown file age counts as newborn (fail open, probe instead). + let file_age = std::fs::metadata(&path) + .and_then(|meta| meta.modified()) + .ok() + .and_then(|modified| SystemTime::now().duration_since(modified).ok()) + .unwrap_or(Duration::ZERO); + let pid_dead = match descriptor.pid { + Some(pid) => { + #[cfg(target_os = "linux")] + { + !pid_alive(pid) + } + #[cfg(any(windows, target_os = "macos"))] + { + match &live_pids { + None => false, + Some(set) => { + stale_snapshot_may_delete(!set.contains(&pid), file_age) + } + } + } + #[cfg(not(any( + target_os = "linux", + windows, + target_os = "macos" + )))] + { + let _ = pid; + false + } + } + None => false, + }; + if pid_dead { + let _ = std::fs::remove_file(&path); + continue; + } let Ok(state) = exchange(&descriptor, json!({"op":"hello"}), Duration::from_secs(1)) else { continue; }; @@ -514,6 +861,76 @@ fn log_file() -> Result { .map_err(|error| error.to_string()) } +/// A launch already in flight keeps its claim here so concurrent +/// `ocs_sessions(launch_if_none: true)` calls wait for the same GUI instead +/// of spawning one window each. Content is `{"pid":..,"started":unix_secs}`. +const STARTUP_LOCK_FILE: &str = "starting.lock"; +/// Claims older than this are abandoned (crashed starter, previous boot). +const STARTUP_LOCK_TTL_SECS: u64 = 60; + +fn startup_lock_path_for(directory: &Path) -> PathBuf { + directory.join(STARTUP_LOCK_FILE) +} + +fn now_unix_secs() -> u64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn write_startup_lock_at(directory: &Path, pid: u64, started: u64) -> Result<(), String> { + let text = serde_json::to_string(&json!({"pid": pid, "started": started})) + .map_err(|error| error.to_string())?; + std::fs::write(startup_lock_path_for(directory), text).map_err(|error| error.to_string()) +} + +fn read_startup_lock_at(directory: &Path) -> Option<(u64, u64)> { + let text = std::fs::read_to_string(startup_lock_path_for(directory)).ok()?; + let value: Value = serde_json::from_str(&text).ok()?; + Some(( + value.get("pid")?.as_u64()?, + value.get("started")?.as_u64()?, + )) +} + +fn claim_pid_alive(pid: u64) -> bool { + #[cfg(target_os = "linux")] + { + pid_alive(pid) + } + #[cfg(any(windows, target_os = "macos"))] + { + live_pids_cached().is_none_or(|set| set.contains(&pid)) + } + #[cfg(not(any(target_os = "linux", windows, target_os = "macos")))] + { + let _ = pid; + true + } +} + +/// True when this caller owns the startup claim and may spawn the GUI. +/// False means a live starter holds a fresh claim: wait for its descriptor +/// instead of spawning another window. Stale claims and claims from dead +/// pids are reclaimed. Fail open (claim granted) when the directory is +/// unusable so a broken lock can never wedge launching. +fn try_claim_startup_lock(directory: &Path, pid: u64) -> bool { + let now = now_unix_secs(); + if let Some((owner, started)) = read_startup_lock_at(directory) { + let fresh = now.saturating_sub(started) < STARTUP_LOCK_TTL_SECS; + if fresh && claim_pid_alive(owner) { + return false; + } + } + write_startup_lock_at(directory, pid, now).is_ok() +} + +fn release_startup_lock(directory: &Path) { + let _ = std::fs::remove_file(startup_lock_path_for(directory)); +} + fn start_gui() -> Result { let executable = std::env::current_exe().map_err(|error| error.to_string())?; let log = log_file()?; @@ -527,14 +944,38 @@ fn start_gui() -> Result { .map_err(|error| error.to_string()) } +fn automation_dir() -> Result { + let base = crate::config::config_dir() + .ok_or_else(|| "No user configuration directory".to_string())?; + Ok(base.join("automation")) +} + fn sessions(launch_if_none: bool) -> Result, String> { let mut available = descriptors()?; if available.is_empty() && launch_if_none { - let mut child = start_gui()?; + let directory = automation_dir()?; + // A concurrent caller may already be starting the GUI: wait for its + // descriptor instead of spawning another window. + let claimed = try_claim_startup_lock(&directory, std::process::id() as u64); + let mut child = if claimed { + let child = start_gui()?; + // The pre-spawn PID snapshot cannot contain the newborn GUI: + // drop it so the next discovery pass enumerates fresh instead + // of deleting the just-written descriptor as a corpse. + live_pids_invalidate(); + Some(child) + } else { + None + }; let deadline = Instant::now() + Duration::from_secs(30); while Instant::now() < deadline { - if let Some(status) = child.try_wait().map_err(|error| error.to_string())? { - return Err(format!("OpenCADStudio exited while starting ({status})")); + if let Some(child) = child.as_mut() { + if let Some(status) = child.try_wait().map_err(|error| error.to_string())? { + if claimed { + release_startup_lock(&directory); + } + return Err(format!("OpenCADStudio exited while starting ({status})")); + } } thread::sleep(Duration::from_millis(200)); available = descriptors()?; @@ -542,11 +983,17 @@ fn sessions(launch_if_none: bool) -> Result, String> { break; } } + if claimed { + release_startup_lock(&directory); + } if available.is_empty() { return Err("OpenCADStudio is still starting; call ocs_sessions again".into()); } } - Ok(available.into_iter().map(|(_, state)| state).collect()) + Ok(available + .into_iter() + .map(|(_, state)| with_bridge_identity(state)) + .collect()) } fn insert_default(object: &mut Map, key: &str, value: Value) { @@ -556,27 +1003,59 @@ fn insert_default(object: &mut Map, key: &str, value: Value) { } impl GuiClient { - fn connect(session_id: &str) -> Result { - let mut matching: Vec<_> = descriptors()? + fn connect(session_id: &str) -> Result { + let mut matching: Vec<_> = descriptors() + .map_err(CallError::Infra)? .into_iter() .filter(|(descriptor, _)| descriptor.session_id == session_id) .collect(); if matching.len() != 1 { - return Err(format!( + return Err(CallError::infra(format!( "Choose session_id from ocs_sessions; found {} matching sessions", matching.len() - )); + ))); } let (descriptor, state) = matching.remove(0); Ok(Self { descriptor, state, - client_id: random_id()?, + client_id: random_id().map_err(CallError::Infra)?, batches: VecDeque::new(), + inflight: HashMap::new(), + dismissed: VecDeque::new(), }) } - fn request(&mut self, request: Value, wait_seconds: f64) -> Result { + /// Best-effort dismiss of a GUI-pending interactive op: the GUI resolves + /// a pending `user_select`/`getpoint` as cancelled on `op == "cancel"` + /// (see `app::control`), and late completions are dropped bridge-side + /// via `dismissed`. Failures are ignored: dismissal races a GUI that may + /// already be gone, and the outcome (Cancelled/timeout) stands either way. + fn dismiss(&mut self, gui_id: Option<&str>) { + let Some(gui_id) = gui_id else { return }; + let cancel = json!({ + "op": "cancel", + "request_id": random_id().unwrap_or_else(|_| format!("cancel-{}", iso8601_now())), + "client_id": self.client_id.clone(), + "document_id": self.state["document_id"].clone(), + "revision": self.state["revision"].clone(), + }); + let _ = exchange(&self.descriptor, cancel, Duration::from_secs(5)); + if !self.dismissed.iter().any(|id| id == gui_id) { + if self.dismissed.len() >= 128 { + self.dismissed.pop_front(); + } + self.dismissed.push_back(gui_id.to_string()); + } + } + + fn request( + &mut self, + request: Value, + wait_seconds: f64, + pump: &mut CancelPump, + mcp_id: Option<&Value>, + ) -> Result { let mut object = request .as_object() .cloned() @@ -586,8 +1065,23 @@ impl GuiClient { .and_then(Value::as_str) .ok_or_else(|| "request must contain op".to_string())? .to_string(); - if !READ_OPS.contains(&op.as_str()) { + // The GUI requires request_id on every non-query op, capture + // included: without one the GUI rejects the request outright, so + // the bridge always mints one (a client-supplied id wins). + if !READ_OPS.contains(&op.as_str()) || op == "capture" { insert_default(&mut object, "request_id", Value::String(random_id()?)); + } + // Capture reads the active tab, so it needs the document the + // GUI requires on every non-query op (revision stays absent: + // the GUI only checks revisions callers supply). + if op == "capture" { + insert_default( + &mut object, + "document_id", + self.state["document_id"].clone(), + ); + } + if !READ_OPS.contains(&op.as_str()) { insert_default( &mut object, "client_id", @@ -608,26 +1102,80 @@ impl GuiClient { } let request_id = object.get("request_id").cloned(); + // Re-polls for dismissed interactions answer `cancelled` without + // touching the GUI: the prompt is already gone. + if op == "operation" { + if let Some(polled) = request_id.as_ref().and_then(Value::as_str) { + if self.dismissed.iter().any(|id| id == polled) { + return Ok(json!({"ok":false,"status":"cancelled","request_id":polled})); + } + } + } let mut response = exchange( &self.descriptor, Value::Object(object), Duration::from_secs(15), )?; - let wait = wait_seconds.clamp(0.0, 60.0); - let deadline = Instant::now() + Duration::from_secs_f64(wait); + let gui_id = request_id.as_ref().and_then(Value::as_str).map(str::to_string); + let mkey = mcp_id.map(mcp_key); + // Track the GUI op while it stays pending so an idle-arriving + // cancel can find and dismiss it. Terminal outcomes remove the + // entry; deadline exits keep it (the agent re-polls and re-tracks). + if let (Some(key), Some(gui)) = (mkey.as_ref(), gui_id.as_ref()) { + self.inflight.insert(key.clone(), gui.clone()); + } + let interactive = matches!(op.as_str(), "user_select" | "getpoint"); + let deadline = Instant::now() + wait_deadline(&op, wait_seconds); while matches!(response["status"].as_str(), Some("accepted" | "running")) && Instant::now() < deadline { let Some(request_id) = request_id.clone() else { break; }; - thread::sleep(Duration::from_millis(50)); + match pump.wait_line(Duration::from_millis(50), mkey.as_deref()) { + PumpEvent::Cancelled => { + self.dismiss(gui_id.as_deref()); + if let Some(key) = mkey.as_ref() { + self.inflight.remove(key); + } + return Err(CallError::Cancelled); + } + PumpEvent::Quiet => {} + } response = exchange( &self.descriptor, json!({"op":"operation","request_id":request_id}), Duration::from_secs(15), )?; } + let terminal = !matches!(response["status"].as_str(), Some("accepted" | "running")); + if terminal { + if let Some(key) = mkey.as_ref() { + self.inflight.remove(key); + } + if interactive && response["status"].as_str() == Some("cancelled") { + if let Some(gui) = gui_id.as_ref() { + if !self.dismissed.iter().any(|id| id == gui) { + if self.dismissed.len() >= 128 { + self.dismissed.pop_front(); + } + self.dismissed.push_back(gui.clone()); + } + } + } + } else if interactive && interactive_timeout(wait_seconds) { + // Only the ceiling itself dismisses: short waits keep the + // classic running response so agents can re-poll. Hitting the + // ten-minute ceiling means nobody is coming: dismiss the prompt + // and say so plainly instead of parking it forever. + self.dismiss(gui_id.as_deref()); + if let Some(key) = mkey.as_ref() { + self.inflight.remove(key); + } + return Err(CallError::Tool( + "timed out waiting for user (10 min); the prompt was dismissed".into(), + )); + } if response.get("state").is_some() { self.state = response["state"].clone(); } else if matches!(op.as_str(), "hello" | "state") && response["ok"].as_bool() == Some(true) @@ -637,7 +1185,13 @@ impl GuiClient { Ok(response) } - fn execute_batch(&mut self, request: Value, wait_seconds: f64) -> Result { + fn execute_batch( + &mut self, + request: Value, + wait_seconds: f64, + pump: &mut CancelPump, + mcp_id: Option<&Value>, + ) -> Result { let id = required_string(&request, "request_id")?.to_owned(); let mut batch = if let Some(position) = self.batches.iter().position(|batch| batch.id == id) { @@ -703,6 +1257,14 @@ impl GuiClient { } attempted = true; + // Cancel between steps: dismiss a pending interactive step so + // no late answer fires, then abandon the batch (cancel = stop). + if let PumpEvent::Cancelled = + pump.wait_line(Duration::ZERO, mcp_id.map(mcp_key).as_deref()) + { + self.dismiss(batch.active.as_deref()); + return Err(CallError::Cancelled); + } let step_id = batch .active .clone() @@ -713,6 +1275,8 @@ impl GuiClient { deadline .saturating_duration_since(Instant::now()) .as_secs_f64(), + pump, + mcp_id, ) } else { let mut step = batch.steps[batch.next] @@ -734,10 +1298,15 @@ impl GuiClient { deadline .saturating_duration_since(Instant::now()) .as_secs_f64(), + pump, + mcp_id, ) }; let response = match response { Ok(response) => response, + // Cancelled abandons the batch outright: resuming would + // re-poll a dismissed step. + Err(CallError::Cancelled) => return Err(CallError::Cancelled), Err(error) => { batch.active = Some(step_id); self.batches.push_back(batch); @@ -841,7 +1410,7 @@ fn trim_batches(batches: &mut VecDeque) { fn client<'a>( clients: &'a mut HashMap, session_id: &str, -) -> Result<&'a mut GuiClient, String> { +) -> Result<&'a mut GuiClient, CallError> { if !clients.contains_key(session_id) { clients.insert(session_id.into(), GuiClient::connect(session_id)?); } @@ -936,6 +1505,7 @@ fn shape_execute_response( mut response: Value, detail: &str, gui: &mut GuiClient, + pump: &mut CancelPump, ) -> Result { if detail == "changed_entities" { let handles = response_handles(&response); @@ -943,6 +1513,8 @@ fn shape_execute_response( let entities = gui.request( json!({"op":"query","handles":handles,"detail":"geometry","limit":MAX_BATCH_STEPS * 100}), 30.0, + pump, + None, )?; if let Some(object) = response.as_object_mut() { object.insert("changed_entities".into(), entities["entities"].clone()); @@ -963,6 +1535,8 @@ fn read_resource( uri: &str, clients: &mut HashMap, resources: &mut ResourceStore, + pump: &mut CancelPump, + mcp_id: Option<&Value>, ) -> Result { if !uri.starts_with("cad://") { return Err(format!("Unsupported resource URI scheme: {uri}")); @@ -1128,11 +1702,12 @@ fn read_resource( "bounds": tile_bounds, "max_dimension": 512, }); - let result = client(clients, session_id)?.request(req, 20.0)?; + let result = + client(clients, session_id)?.request(req, 20.0, pump, mcp_id)?; if result["ok"].as_bool() != Some(true) || result["status"].as_str() != Some("completed") { - return Err(result.to_string()); + return Err(result.to_string().into()); } let bytes = std::fs::read(&path).map_err(|error| error.to_string())?; let _ = std::fs::remove_file(path); @@ -1196,7 +1771,7 @@ fn read_resource( "max_dimension": 1600, }); let result = client(clients, session_id)? - .request(req, 15.0)?; + .request(req, 15.0, pump, mcp_id)?; if result["ok"].as_bool() != Some(true) || result["status"].as_str() != Some("completed") { @@ -1248,17 +1823,30 @@ fn call_tool( arguments: &Value, clients: &mut HashMap, resources: &mut ResourceStore, -) -> Result { + pump: &mut CancelPump, + mcp_id: Option<&Value>, +) -> Result { match name { "ocs_sessions" => { let launch = arguments["launch_if_none"].as_bool().unwrap_or(true); - Ok(Value::Array(sessions(launch)?)) + Ok(Value::Array(sessions(launch).map_err(CallError::Infra)?)) } "ocs_read" => { let session_id = required_string(arguments, "ocs_session_id")?; let op = arguments["op"].as_str().unwrap_or("state"); if !READ_OPS.contains(&op) { - return Err("Use ocs_execute for mutations".into()); + return Err("Use ocs_execute for mutations".to_string().into()); + } + if op == "capture" { + // The GUI capture op needs a file path and lives behind the + // ocs_capture tool; agents hitting the bare read op get + // guidance instead of a cryptic missing-path failure. + return Ok(json!({ + "ok": false, + "status": "failed", + "code": "use_capture_tool", + "error": "Viewport captures run through the ocs_capture tool (scope, annotate, tiles, diff). ocs_read exposes capture products already stored as resources, not new captures." + })); } if op == "tools" { return Ok(json!({ @@ -1273,7 +1861,13 @@ fn call_tool( .cloned() .unwrap_or_default(); request.insert("op".into(), Value::String(op.into())); - client(clients, session_id)?.request(Value::Object(request), 30.0) + let response = + client(clients, session_id)?.request(Value::Object(request), 30.0, pump, mcp_id)?; + if matches!(op, "hello" | "capabilities") { + Ok(with_bridge_identity(response)) + } else { + Ok(response) + } } "ocs_execute" => { let session_id = required_string(arguments, "ocs_session_id")?; @@ -1284,20 +1878,20 @@ fn call_tool( .ok_or_else(|| "Missing request object".to_string())?; let op = required_string(&request, "op")?; if !EXECUTE_OPS.contains(&op) { - return Err(format!("Unknown mutation operation: {op}")); + return Err(CallError::Tool(format!("Unknown mutation operation: {op}"))); } let request_id = required_string(&request, "request_id")?; if request_id.len() > 128 { - return Err("request_id must not exceed 128 bytes".into()); + return Err("request_id must not exceed 128 bytes".to_string().into()); } let val_res = validate_execute_request(&request, op)?; let wait = arguments["wait_seconds"].as_f64().unwrap_or(30.0); let detail = arguments["response_detail"].as_str().unwrap_or("compact"); let gui = client(clients, session_id)?; let mut response = if op == "batch" { - gui.execute_batch(request, wait)? + gui.execute_batch(request, wait, pump, mcp_id)? } else { - gui.request(request, wait)? + gui.request(request, wait, pump, mcp_id)? }; if !val_res.warnings.is_empty() { if let Some(object) = response.as_object_mut() { @@ -1307,7 +1901,7 @@ fn call_tool( ); } } - shape_execute_response(response, detail, gui) + shape_execute_response(response, detail, gui, pump).map_err(CallError::from) } "ocs_capture" => { let session_id = required_string(arguments, "ocs_session_id")?; @@ -1396,6 +1990,11 @@ fn call_tool( "scope": scope, "max_dimension": if tile_info.is_some() && arguments.get("max_dimension").is_none() { 512 } else { max_dimension }, }); + // The client's id wins when supplied (and is echoed back in + // metadata); otherwise request() mints one for the GUI below. + if let Some(request_id) = arguments.get("request_id").and_then(Value::as_str) { + req["request_id"] = json!(request_id); + } if let Some((_, _, _, tb)) = tile_info { req["view"] = json!("region"); req["bounds"] = json!(tb); @@ -1417,17 +2016,21 @@ fn call_tool( req["annotate"] = json!(annotate); } let result = client(clients, session_id)? - .request(req, 30.0)?; + .request(req, 30.0, pump, mcp_id)?; if result["ok"].as_bool() != Some(true) || result["status"].as_str() != Some("completed") { - return Err(result.to_string()); + // Failed capture response from the GUI: retryable, stays Tool. + return Err(result.to_string().into()); } let bytes = std::fs::read(&path).map_err(|error| error.to_string())?; let _ = std::fs::remove_file(path); let mut meta = result.get("result").cloned().unwrap_or_else(|| json!({})); if let Some(obj) = meta.as_object_mut() { obj.remove("path"); + if let Some(request_id) = arguments["request_id"].as_str() { + obj.insert("request_id".into(), Value::String(request_id.into())); + } } let mut final_bytes = bytes; @@ -1608,7 +2211,8 @@ fn call_tool( })), } } - _ => Err(format!("Unknown tool: {name}")), + // Backstop: the dispatcher rejects unknown names first with -32602. + _ => Err(CallError::Tool(format!("Unknown tool: {name}"))), } } @@ -1651,24 +2255,24 @@ pub(crate) fn tool_definitions() -> Value { json!([ { "name":"ocs_sessions", - "description":"List real OpenCADStudio GUI sessions and documents. Launch the installed editor if none is running.", + "description":"List real OpenCADStudio GUI sessions and documents. Launch the installed editor if none is running. On first use, announce the build to the user from the `bridge` object in each result (OpenCADStudio version, build_rev, tool_schema digest); announce again if a later call reports a different build.", "inputSchema":{"type":"object","properties":{"launch_if_none":{"type":"boolean","default":true,"description":"Launch OpenCADStudio when no live session exists."}},"additionalProperties":false}, "outputSchema":{"type":"object","properties":{"result":{"type":"array","items":{"type":"object","properties":{"ok":{"const":true},"session_id":{"type":"string"},"document_id":{"type":"integer"},"revision":{"type":"integer"},"selection":{"type":"array","items":{"type":"string"}},"documents":{"type":"array"}},"required":["ok","session_id","document_id","revision","selection","documents"],"additionalProperties":true}}},"required":["result"],"additionalProperties":false}, - "annotations":{"title":"List OCS sessions","readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false} + "title":"List OCS sessions","annotations":{"readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false} }, { "name":"ocs_read", "description":"Discover capabilities and record schemas, or read state, complete database records, command manifests, entities, properties, kernel measurements and spatial relationships, history, events or operation status from a live OCS session.", "inputSchema":{"type":"object","properties":{"ocs_session_id":{"type":"string","minLength":1,"description":"Value of session_id returned by ocs_sessions."},"op":{"type":"string","enum":READ_OPS,"default":"state"},"parameters":{"type":"object","description":"Operation-specific filters.","properties":{"name":{"type":"string","description":"Command name or record name."},"search":{"type":"string","description":"Case-insensitive command or record-type search."},"find":{"type":"string","description":"Text query string for text_search."},"match_case":{"type":"boolean","default":false,"description":"Case-sensitive text search."},"whole_word":{"type":"boolean","default":false,"description":"Match only whole words."},"ignore_accents":{"type":"boolean","description":"Ignore accents/diacritics in text (defaults to true when match_case is false)."},"scope":{"type":"string","enum":["all","active_space","blocks"],"default":"all","description":"Text search scope."},"system_spellcheck":{"type":"boolean","default":false,"description":"Use native OS spell-checker (Windows, macOS, Linux)."},"language":{"type":"string","description":"Language tag for spell-checker (e.g. 'fr-FR', 'en-US', 'de-DE', 'es-ES')."},"suggest":{"type":"boolean","default":true,"description":"Include suggested corrections for misspelled words."},"check_terms":{"type":"array","items":{"type":"string"},"description":"List of terms or suspect misspellings to flag in text_audit."},"dictionary":{"type":"array","items":{"type":"string"},"description":"Known valid words for text_audit dictionary check."},"pairs":{"type":"array","description":"Find/replace pairs for dry-run simulation in text_audit.","items":{"type":"object","properties":{"find":{"type":"string"},"replace":{"type":"string"}},"required":["find","replace"]}},"dry_run_pairs":{"type":"array","description":"Alias for pairs in text_audit.","items":{"type":"object","properties":{"find":{"type":"string"},"replace":{"type":"string"}},"required":["find","replace"]}},"document_id":{"type":"integer","minimum":0},"path":{"type":"string","description":"Optional intended output path for audit; extension determines target format."},"target_format":{"type":"string","enum":["dwg","dxf"],"description":"Intended output format for audit."},"target_version":{"type":"string","enum":["R14","2000","2004","2007","2010","2013","2018","AC1014","AC1015","AC1018","AC1021","AC1024","AC1027","AC1032"],"description":"Intended CAD output version for audit."},"collection":{"type":"string","description":"Record collection, all for records, or omit to discover collections and schema types."},"handle":{"type":"string"},"handles":{"type":"array","items":{"type":"string"},"description":"Exact entity or record handles."},"type":{"type":"string","description":"Entity or record type filter; for record_schema, returns its complete type graph and writable field paths."},"layer":{"type":"string","description":"Layer name filter for query."},"detail":{"type":"string","enum":["summary","geometry","full"],"default":"geometry","description":"Entity detail returned by query."},"fields":{"type":"array","items":{"type":"string"},"description":"Return only these entity fields plus handle."},"paths":{"type":"array","items":{"type":"string"},"description":"Project RFC 6901 JSON Pointer paths relative to record.properties."},"where":{"type":"array","description":"All property filters must match.","items":{"type":"object","properties":{"path":{"type":"string"},"op":{"type":"string","enum":["eq","ne","lt","lte","gt","gte","contains","starts_with","ends_with","in","exists","not_exists"],"default":"eq"},"value":{}},"required":["path"],"additionalProperties":false}},"near":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Rank planar curves by exact kernel distance to this world XY point."},"point":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"World point whose object snap the snap op reports."},"from":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Base point for perpendicular and tangent snaps (snap op)."},"contains_point":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Return closed planar curves containing this world XY point."},"bounds":{"type":"array","items":{"type":"number"},"minItems":4,"maxItems":4,"description":"Filter entities whose world XY bounds overlap [min_x,min_y,max_x,max_y]."},"intersections":{"type":"array","items":{"type":"string"},"minItems":2,"maxItems":2,"description":"Return exact kernel intersections between two planar curve handles."},"after":{"type":"integer","minimum":0,"description":"Event cursor."},"request_id":{"type":"string","description":"Operation id to query."},"offset":{"type":"integer","minimum":0},"limit":{"type":"integer","minimum":1,"maximum":10000}},"additionalProperties":false}},"required":["ocs_session_id"],"additionalProperties":false}, "outputSchema":read_output_schema(), - "annotations":{"title":"Read OCS state","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false} + "title":"Read OCS state","annotations":{"readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false} }, { "name":"ocs_execute", "description":"Execute semantic OCS actions, batch drafting, and mutations. Supports batch creation (entities_create), reference image underlays (embed_image), block definitions (block_define), and block reference insertions (INSERT). MANDATORY: For architectural drafting, all doors and windows must be inserted as CAD blocks (type: INSERT), never loose lines.", "inputSchema":{"type":"object","properties":{"ocs_session_id":{"type":"string","minLength":1,"description":"Value of session_id returned by ocs_sessions."},"request":execute_request_schema(),"wait_seconds":{"type":"number","minimum":0,"maximum":60,"default":30,"description":"Total time to wait for completion before returning."},"response_detail":{"type":"string","enum":["compact","changed_entities","full"],"default":"compact","description":"compact returns only state needed for the next edit; changed_entities also returns current geometry for changed handles; full preserves the complete editor state."}},"required":["ocs_session_id","request"],"additionalProperties":false}, "outputSchema":execute_output_schema(), - "annotations":{"title":"Execute OCS action","readOnlyHint":false,"destructiveHint":true,"idempotentHint":true,"openWorldHint":false} + "title":"Execute OCS action","annotations":{"readOnlyHint":false,"destructiveHint":true,"idempotentHint":true,"openWorldHint":false} }, { "name":"ocs_capture", @@ -1677,6 +2281,7 @@ pub(crate) fn tool_definitions() -> Value { "type":"object", "properties":{ "ocs_session_id":{"type":"string","minLength":1,"description":"Value of session_id returned by ocs_sessions."}, + "request_id":{"type":"string","description":"Optional client request id, accepted for schema compatibility and echoed in the result metadata. Captures are not idempotency-keyed."}, "scope":{"type":"string","enum":["viewport","window"],"default":"viewport","description":"Capture only the drawing viewport by default, or the complete application window."}, "max_dimension":{"type":"integer","minimum":256,"maximum":4096,"default":1600,"description":"Resize the longest image edge to at most this many pixels."}, "delivery":{"type":"string","enum":["inline","resource","both"],"default":"inline","description":"Image delivery method: 'inline' embeds base64 in tool content, 'resource' returns an MCP cad:// URI reference without inlining image bytes, 'both' returns both inline image and cad:// URI."}, @@ -1694,7 +2299,7 @@ pub(crate) fn tool_definitions() -> Value { "required":["ocs_session_id"], "additionalProperties":true }, - "annotations":{"title":"Capture OCS window","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false} + "title":"Capture OCS window","annotations":{"readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false} } ]) } @@ -1730,23 +2335,28 @@ fn tool_result(value: Value) -> Value { "isError": false }); } - let structured = if value.is_object() { - value.clone() - } else { - json!({"result":value.clone()}) - }; - json!({ + let is_err = value["ok"].as_bool() == Some(false); + let mut result = json!({ "content":[{"type":"text","text":value.to_string()}], - "structuredContent":structured, - "isError":value["ok"].as_bool() == Some(false) - }) + "isError": is_err, + }); + // Strict clients validate structuredContent against outputSchema, which + // only describes successful results. Omitting it on errors keeps the real + // message visible instead of masked by a schema complaint. + if !is_err { + result["structuredContent"] = if value.is_object() { + value.clone() + } else { + json!({"result":value.clone()}) + }; + } + result } fn error_result(message: impl ToString) -> Value { let message = message.to_string(); json!({ - "content":[{"type":"text","text":message.clone()}], - "structuredContent":{"ok":false,"status":"failed","code":"invalid_arguments","error":message,"retryable":false}, + "content":[{"type":"text","text":message}], "isError":true }) } @@ -1755,8 +2365,52 @@ fn response(id: Value, result: Value) -> Value { json!({"jsonrpc":"2.0","id":id,"result":result}) } +/// Spec-pure implementation block: exactly name/title/version, so strict +/// clients (deny_unknown_fields, strict Zod schemas) never reject the +/// handshake. Build extras live canonically in [`bridge_identity`], +/// surfaced via `capabilities.experimental` and the in-band `bridge` stamp. fn server_info() -> Value { - json!({"name":"OpenCADStudio","title":"Open CAD Studio","version":env!("OCS_APP_VERSION")}) + json!({ + "name":"OpenCADStudio", + "title":"Open CAD Studio", + "version":env!("OCS_APP_VERSION"), + }) +} + +/// Build identity agents CAN see. MCP `serverInfo` never reaches tool +/// callers, so the same fields ride on handshake payloads (`ocs_sessions` +/// states, `hello`/`capabilities` responses) under the `bridge` key. +fn bridge_identity() -> Value { + json!({ + "name":"OpenCADStudio", + "version":env!("OCS_APP_VERSION"), + "build_rev":env!("OCS_GIT_REV"), + "build_profile":env!("OCS_BUILD_PROFILE"), + "tool_schema":tool_schema_digest(), + }) +} + +/// Stamp a `bridge` identity onto a handshake payload. Non-objects pass +/// through untouched; an existing `bridge` key (GUI-provided) is kept. +fn with_bridge_identity(mut value: Value) -> Value { + if let Some(object) = value.as_object_mut() { + object + .entry("bridge") + .or_insert_with(bridge_identity); + } + value +} + +/// Sha256 hex digest of the canonical [`tool_definitions`] JSON. +/// +/// Published via `capabilities.experimental` and the in-band [`bridge_identity`] +/// stamp so MCP clients can detect a stale bridge (running an older binary +/// than the one on disk) without guessing. +fn tool_schema_digest() -> String { + let canonical = serde_json::to_string(&tool_definitions()).unwrap_or_default(); + let mut hasher = Sha256::new(); + hasher.update(canonical.as_bytes()); + format!("{:x}", hasher.finalize()) } fn modern_request(params: &Value) -> bool { @@ -1793,7 +2447,18 @@ fn poll_task( task: &mut McpTask, clients: &mut HashMap, resources: &mut ResourceStore, + pump: &mut CancelPump, ) -> Value { + if task.cancelled { + // Cancelled carries neither result nor error, even if a late + // completion landed after the cancel was acknowledged. + let mut value = task_value(task, "cancelled"); + if let Some(object) = value.as_object_mut() { + object.remove("result"); + object.remove("error"); + } + return value; + } if task.result.is_some() { return task_value(task, "completed"); } @@ -1803,7 +2468,9 @@ fn poll_task( task.last_updated_at = iso8601_now(); let mut arguments = task.arguments.clone(); arguments["wait_seconds"] = Value::from(0); - match call_tool(&task.name, &arguments, clients, resources) { + // Task re-issues are server-driven with no client wait: no MCP id to + // match cancels against, and a cancel here only means "stop polling". + match call_tool(&task.name, &arguments, clients, resources, pump, None) { Ok(value) if matches!(value["status"].as_str(), Some("accepted" | "running")) => { task_value(task, "working") } @@ -1811,14 +2478,28 @@ fn poll_task( task.result = Some(tool_result(value)); task_value(task, "completed") } + // Don't cache a cancel as failure: the client moved on, and the + // next poll simply re-issues. + Err(CallError::Cancelled) => task_value(task, "working"), Err(error) => { - task.error = Some(json!({"code":-32000,"message":error})); + // Public code: the legacy -32000 range is grandfathered, and + // new emissions stay out of it. + task.error = Some(json!({"code":-32603,"message":error.message()})); task_value(task, "failed") } } } -fn protocol_result(mut result: Value, modern: bool, cacheable: bool) -> Value { +fn protocol_result(mut result: Value, modern: bool, ttl_ms: Option) -> Value { + // SEP-2549 freshness hints are version-independent: legacy clients + // ignore unknown fields, so list results always carry them. The modern + // envelope (resultType/_meta) stays negotiated. + if let Some(ttl_ms) = ttl_ms { + if let Some(object) = result.as_object_mut() { + object.insert("ttlMs".into(), Value::from(ttl_ms)); + object.insert("cacheScope".into(), Value::String("public".into())); + } + } if modern { let object = result .as_object_mut() @@ -1830,10 +2511,6 @@ fn protocol_result(mut result: Value, modern: bool, cacheable: bool) -> Value { "_meta".into(), json!({"io.modelcontextprotocol/serverInfo":server_info()}), ); - if cacheable { - object.insert("ttlMs".into(), Value::from(CACHE_TTL_MS)); - object.insert("cacheScope".into(), Value::String("public".into())); - } } result } @@ -1859,7 +2536,15 @@ fn handle_message( clients: &mut HashMap, tasks: &mut TaskStore, resources: &mut ResourceStore, + pump: &mut CancelPump, ) -> Option { + // Non-object input (batches included: the spec defines no batch + // semantics for us) is Invalid Request, never silence: a sender must + // not hang waiting for a response that will never come. Id-less + // *objects* stay silent (notifications). + if !message.is_object() { + return Some(rpc_error(Value::Null, -32600, "Invalid request: expected a JSON-RPC object")); + } let id = message.get("id").cloned(); let method = message.get("method").and_then(Value::as_str)?; if id.is_none() { @@ -1891,7 +2576,8 @@ fn handle_message( "protocolVersion":protocol, "capabilities":{ "tools":{"listChanged":false}, - "resources":{"subscribe":false,"listChanged":false} + "resources":{"subscribe":false,"listChanged":false}, + "experimental":{"opencadstudio.build":bridge_identity()} }, "serverInfo":server_info(), "instructions":INSTRUCTIONS @@ -1911,10 +2597,10 @@ fn handle_message( "instructions":INSTRUCTIONS }), true, - true, + Some(CACHE_TTL_MS), ), ), - "ping" => response(id, protocol_result(json!({}), modern, false)), + "ping" => response(id, protocol_result(json!({}), modern, None)), "resources/list" => { let mut session_ids = Vec::new(); if let Ok(available) = descriptors() { @@ -1937,34 +2623,71 @@ fn handle_message( session_ids.push(k.clone()); } } + // Deterministic order: HashMap iteration is random, and stable + // ordering keeps client caches and prompt caches hitting. + session_ids.sort(); let list = resources.list_resources(&session_ids); response( id, - protocol_result(json!({ "resources": list }), modern, true), + protocol_result(json!({ "resources": list }), modern, Some(RESOURCE_TTL_MS)), ) } "resources/read" => { let Some(uri) = params["uri"].as_str() else { return Some(rpc_error(id, -32602, "Missing resource uri")); }; - match read_resource(uri, clients, resources) { - Ok(contents) => response(id, protocol_result(contents, modern, true)), - Err(err) => rpc_error(id, -32002, err), + match read_resource(uri, clients, resources, pump, Some(&id)) { + Ok(contents) => response(id, protocol_result(contents, modern, Some(RESOURCE_TTL_MS))), + // -32002 was THE not-found code in 2025-11-25 and earlier; + // 2026-07-28 says MUST NOT emit it, so modern gets -32602. + Err(err) => rpc_error(id, if modern { -32602 } else { -32002 }, err), } } + "resources/templates/list" => response( + id, + protocol_result( + json!({"resourceTemplates": [ + { + "uriTemplate": "cad://session/{session_id}/tile/{level}/{x}/{y}.png", + "name": "Pyramid tile", + "description": "DeepZoom viewport tile at zoom level, column x, row y.", + "mimeType": "image/png" + }, + { + "uriTemplate": "cad://session/{session_id}/snapshot/{hash}.png", + "name": "Viewport snapshot", + "description": "Captured viewport frame addressed by content hash.", + "mimeType": "image/png" + } + ]}), + modern, + Some(RESOURCE_TTL_MS), + ), + ), "tools/list" => response( id, - protocol_result(json!({"tools":tool_definitions()}), modern, true), + protocol_result(json!({"tools":tool_definitions()}), modern, Some(CACHE_TTL_MS)), ), "tools/call" => { let Some(name) = params["name"].as_str() else { return Some(rpc_error(id, -32602, "Missing tool name")); }; + // Unknown tools are Protocol Errors (-32602), never isError + // results; the valid names ride along so the model can recover. + if !["ocs_sessions", "ocs_read", "ocs_execute", "ocs_capture"].contains(&name) { + return Some(rpc_error( + id, + -32602, + format!( + "Unknown tool: {name}. Available tools: ocs_sessions, ocs_read, ocs_execute, ocs_capture" + ), + )); + } let arguments = params .get("arguments") .cloned() .unwrap_or_else(|| json!({})); - let called = call_tool(name, &arguments, clients, resources); + let called = call_tool(name, &arguments, clients, resources, pump, Some(&id)); if modern && supports_tasks(¶ms) { if let Ok(value) = &called { if matches!(value["status"].as_str(), Some("accepted" | "running")) { @@ -1979,6 +2702,7 @@ fn handle_message( last_updated_at: now, result: None, error: None, + cancelled: false, }); return Some(response( id, @@ -1994,14 +2718,22 @@ fn handle_message( "pollIntervalMs":250 }), true, - false, + None, ), )); } } } - let result = called.map(tool_result).unwrap_or_else(error_result); - response(id, protocol_result(result, modern, false)) + // Tool-domain failures stay isError results (model-actionable); + // infrastructure failures become -32603 (nothing to fix in-band); + // a cancelled request gets no response at all (spec SHOULD). + let result = match called { + Ok(value) => tool_result(value), + Err(CallError::Tool(message)) => error_result(message), + Err(CallError::Infra(message)) => return Some(rpc_error(id, -32603, message)), + Err(CallError::Cancelled) => return None, + }; + response(id, protocol_result(result, modern, None)) } "tasks/get" if modern && supports_tasks(¶ms) => { let Some(task_id) = params["taskId"].as_str() else { @@ -2010,7 +2742,7 @@ fn handle_message( let Some(task) = tasks.get_mut(task_id) else { return Some(rpc_error(id, -32602, "Unknown or expired taskId")); }; - response(id, protocol_result(poll_task(task, clients, resources), true, false)) + response(id, protocol_result(poll_task(task, clients, resources, pump), true, None)) } "tasks/update" if modern && supports_tasks(¶ms) => { let Some(task_id) = params["taskId"].as_str() else { @@ -2019,16 +2751,23 @@ fn handle_message( if tasks.get_mut(task_id).is_none() { return Some(rpc_error(id, -32602, "Unknown or expired taskId")); } - response(id, protocol_result(json!({}), true, false)) + response(id, protocol_result(json!({}), true, None)) } "tasks/cancel" if modern && supports_tasks(¶ms) => { let Some(task_id) = params["taskId"].as_str() else { return Some(rpc_error(id, -32602, "Missing taskId")); }; - if tasks.get_mut(task_id).is_none() { + let Some(task) = tasks.get_mut(task_id) else { return Some(rpc_error(id, -32602, "Unknown or expired taskId")); - } - response(id, protocol_result(json!({}), true, false)) + }; + // Cooperative cancel, honored: the op is dropped (nothing + // re-issues once cancelled) and polls report the terminal + // `cancelled` state with neither result nor error. + task.cancelled = true; + task.result = None; + task.error = None; + task.last_updated_at = iso8601_now(); + response(id, protocol_result(json!({}), true, None)) } _ => rpc_error(id, -32601, format!("Method not found: {method}")), }) @@ -2078,29 +2817,92 @@ pub fn sync_agent_tool_schemas() -> bool { true } +/// A cancel arriving while nothing waits targets an op that is pending +/// server-side between polls: find its GUI request via the inflight map and +/// dismiss it now, so the next poll answers `cancelled`. Unknown ids are +/// ignored (spec: fire-and-forget, races expected). +fn idle_cancel(clients: &mut HashMap, params: &Value) { + let Some(key) = params + .get("requestId") + .map(|id| id.to_string()) + else { + return; + }; + for gui in clients.values_mut() { + if let Some(gui_id) = gui.inflight.remove(&key) { + gui.dismiss(Some(&gui_id)); + } + } +} + +/// Agent schema sync is a local convenience, not protocol: operators +/// disable it with `OCS_SKIP_SCHEMA_SYNC` set to any value. +fn schema_sync_enabled() -> bool { + std::env::var_os("OCS_SKIP_SCHEMA_SYNC").is_none() +} + /// Run the MCP stdio loop until the client closes stdin. +/// +/// A reader thread feeds lines through a channel so wait loops can observe +/// `notifications/cancelled` mid-wait (see [`CancelPump`]). The reader never +/// writes: this loop is the only stdout writer. pub fn run() { - let _ = sync_agent_tool_schemas(); - let stdin = io::stdin(); + if schema_sync_enabled() && sync_agent_tool_schemas() { + eprintln!("MCP agent schemas synchronized"); + } + let exe_stamp = exe_fingerprint(); let stdout = io::stdout(); let mut output = stdout.lock(); let mut clients = HashMap::new(); let mut tasks = TaskStore::default(); let mut resources = ResourceStore::default(); - for line in crate::io::line_read::lines_capped( - stdin.lock(), - crate::io::line_read::MAX_LINE_BYTES, - ) { - let response = match line { - Ok(line) if !line.trim().is_empty() => match serde_json::from_str::(&line) { - Ok(message) => handle_message(message, &mut clients, &mut tasks, &mut resources), - Err(error) => Some(rpc_error(Value::Null, -32700, error)), - }, - Ok(_) => None, - Err(error) => { - eprintln!("MCP input error: {error}"); + let (tx, rx) = std::sync::mpsc::channel::>(); + let stdin = io::stdin(); + thread::spawn(move || { + for line in crate::io::line_read::lines_capped( + stdin.lock(), + crate::io::line_read::MAX_LINE_BYTES, + ) { + if tx.send(line.map_err(|error| error.to_string())).is_err() { break; } + } + }); + let mut pump = CancelPump { + rx, + backlog: VecDeque::new(), + }; + loop { + let Some(line) = pump.next() else { + break; + }; + // Serve the in-flight request with the old schema first (it was made + // against it), then exit so the client respawns a fresh bridge. This + // ordering means a rebuild never fails a call that is already running. + let superseded = exe_superseded(&exe_stamp); + let response = if line.trim().is_empty() { + None + } else { + match serde_json::from_str::(&line) { + Ok(message) => { + if message.get("id").is_none() + && message.get("method").and_then(Value::as_str) + == Some("notifications/cancelled") + { + idle_cancel(&mut clients, &message["params"]); + None + } else { + handle_message( + message, + &mut clients, + &mut tasks, + &mut resources, + &mut pump, + ) + } + } + Err(error) => Some(rpc_error(Value::Null, -32700, error)), + } }; if let Some(response) = response { if writeln!(output, "{response}") @@ -2110,6 +2912,10 @@ pub fn run() { break; } } + if superseded { + eprintln!("MCP bridge superseded by rebuild; exiting for respawn"); + break; + } } } @@ -2117,6 +2923,16 @@ pub fn run() { mod tests { use super::*; + /// A pump with no reader: waits behave exactly as before (quiet + /// immediately), so tests that never cancel need no other changes. + fn detached_pump() -> CancelPump { + let (_tx, rx) = std::sync::mpsc::channel(); + CancelPump { + rx, + backlog: VecDeque::new(), + } + } + #[test] fn advertises_the_shared_tools() { let tools = tool_definitions(); @@ -2130,6 +2946,15 @@ mod tests { names, ["ocs_sessions", "ocs_read", "ocs_execute", "ocs_capture"] ); + // The build announcement must live in the entry-point tool + // description: global instructions don't reliably reach agents. + assert!( + tools[0]["description"] + .as_str() + .unwrap() + .contains("`bridge`"), + "ocs_sessions description must point at the bridge build identity" + ); assert_eq!(tools[0]["annotations"]["readOnlyHint"], false); for tool in [&tools[1], &tools[2], &tools[3]] { assert!( @@ -2228,6 +3053,7 @@ mod tests { &mut clients, &mut TaskStore::default(), &mut ResourceStore::default(), + &mut detached_pump(), ) .unwrap(); assert_eq!(initialized["result"]["protocolVersion"], "2025-11-25"); @@ -2247,10 +3073,15 @@ mod tests { &mut clients, &mut TaskStore::default(), &mut ResourceStore::default(), + &mut detached_pump(), ) .unwrap(); assert_eq!(listed["result"]["tools"].as_array().unwrap().len(), 4); - assert!(listed["result"].get("ttlMs").is_none()); + // SEP-2549: freshness hints ship on every protocol version; only + // the modern envelope stays negotiated (see + // legacy_list_results_carry_sep2549_ttl). + assert_eq!(listed["result"]["ttlMs"], CACHE_TTL_MS); + assert_eq!(listed["result"]["cacheScope"], "public"); assert!(listed["result"].get("resultType").is_none()); } @@ -2262,6 +3093,7 @@ mod tests { &mut clients, &mut TaskStore::default(), &mut ResourceStore::default(), + &mut detached_pump(), ) .unwrap(); assert_eq!(discovered["result"]["resultType"], "complete"); @@ -2283,6 +3115,7 @@ mod tests { &mut clients, &mut TaskStore::default(), &mut ResourceStore::default(), + &mut detached_pump(), ) .unwrap(); assert_eq!(listed["result"]["resultType"], "complete"); @@ -2290,6 +3123,451 @@ mod tests { assert_eq!(listed["result"]["cacheScope"], "public"); } + #[test] + fn resource_lists_use_the_short_dynamic_ttl() { + // resources/* vary as sessions open and snapshots land: 60 s. + // Static tools/discover keep the hour. + let listed = handle_message( + json!({"jsonrpc":"2.0","id":"res","method":"resources/list"}), + &mut HashMap::new(), + &mut TaskStore::default(), + &mut ResourceStore::default(), + &mut detached_pump(), + ) + .unwrap(); + assert_eq!(listed["result"]["ttlMs"], RESOURCE_TTL_MS); + assert_eq!(listed["result"]["ttlMs"], 60_000); + } + + #[test] + fn legacy_list_results_carry_sep2549_ttl() { + // SEP-2549 makes ttlMs mandatory on list results; legacy clients + // ignore unknown fields, so the stamp is version-independent while + // the modern envelope (resultType/_meta) stays negotiated. + for (method, ttl) in [ + ("tools/list", CACHE_TTL_MS), + ("resources/list", RESOURCE_TTL_MS), + ] { + let listed = handle_message( + json!({"jsonrpc":"2.0","id":method,"method":method}), + &mut HashMap::new(), + &mut TaskStore::default(), + &mut ResourceStore::default(), + &mut detached_pump(), + ) + .unwrap(); + assert_eq!(listed["result"]["ttlMs"], ttl, "{method}"); + assert_eq!(listed["result"]["cacheScope"], "public", "{method}"); + assert!(listed["result"].get("resultType").is_none(), "{method}"); + assert!(listed["result"].get("_meta").is_none(), "{method}"); + } + } + + #[test] + fn initialize_is_spec_pure_with_experimental_build() { + // serverInfo carries only the spec'd name/title/version; build + // extras live canonically in capabilities.experimental (single + // source: bridge_identity), never copied. + let initialized = handle_message( + json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25"}}), + &mut HashMap::new(), + &mut TaskStore::default(), + &mut ResourceStore::default(), + &mut detached_pump(), + ) + .unwrap(); + let info = &initialized["result"]["serverInfo"]; + assert_eq!( + info.as_object().unwrap().keys().collect::>(), + ["name", "title", "version"] + ); + assert_eq!(info["name"], "OpenCADStudio"); + let build = &initialized["result"]["capabilities"]["experimental"]["opencadstudio.build"]; + assert_eq!(build["tool_schema"], Value::String(tool_schema_digest())); + assert!(build["build_rev"].as_str().is_some()); + assert!(build["build_profile"].as_str().is_some()); + // Unknown versions fall back to our newest instead of erroring. + let fallback = handle_message( + json!({"jsonrpc":"2.0","id":2,"method":"initialize","params":{"protocolVersion":"2099-01-01"}}), + &mut HashMap::new(), + &mut TaskStore::default(), + &mut ResourceStore::default(), + &mut detached_pump(), + ) + .unwrap(); + assert_eq!(fallback["result"]["protocolVersion"], PROTOCOL_VERSION); + assert!(fallback.get("error").is_none()); + } + + #[test] + fn resource_not_found_code_follows_era() { + // Modern path MUST NOT emit -32002 (2026-07-28); legacy path keeps + // it (it was the code in 2025-11-25 and earlier). + let modern = handle_message( + json!({"jsonrpc":"2.0","id":1,"method":"resources/read","params":{"uri":"cad://session/nope/snapshot/nope.png","_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}), + &mut HashMap::new(), + &mut TaskStore::default(), + &mut ResourceStore::default(), + &mut detached_pump(), + ) + .unwrap(); + assert_eq!(modern["error"]["code"], -32602); + let legacy = handle_message( + json!({"jsonrpc":"2.0","id":2,"method":"resources/read","params":{"uri":"cad://session/nope/snapshot/nope.png"}}), + &mut HashMap::new(), + &mut TaskStore::default(), + &mut ResourceStore::default(), + &mut detached_pump(), + ) + .unwrap(); + assert_eq!(legacy["error"]["code"], -32002); + } + + #[test] + fn non_object_input_is_invalid_request() { + // Batches and other non-objects get -32600 (so senders never hang) + // while id-less objects stay silent (notifications). + for bad in [json!([1, 2]), json!("tools/list"), json!(42), json!(null)] { + let rejected = handle_message( + bad, + &mut HashMap::new(), + &mut TaskStore::default(), + &mut ResourceStore::default(), + &mut detached_pump(), + ); + assert_eq!(rejected.unwrap()["error"]["code"], -32600); + } + let silent = handle_message( + json!({"jsonrpc":"2.0","method":"ping"}), + &mut HashMap::new(), + &mut TaskStore::default(), + &mut ResourceStore::default(), + &mut detached_pump(), + ); + assert!(silent.is_none()); + } + + #[test] + fn unknown_tool_is_a_protocol_error_with_names() { + // Spec lists unknown tools under Protocol Errors (-32602). The + // message carries the valid names so the model can still recover. + let unknown = handle_message( + json!({"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"ocs_frobnicate","arguments":{}}}), + &mut HashMap::new(), + &mut TaskStore::default(), + &mut ResourceStore::default(), + &mut detached_pump(), + ) + .unwrap(); + assert_eq!(unknown["error"]["code"], -32602); + let message = unknown["error"]["message"].as_str().unwrap(); + for name in ["ocs_sessions", "ocs_read", "ocs_execute", "ocs_capture"] { + assert!(message.contains(name), "{message}"); + } + } + + #[test] + fn infrastructure_failures_are_marked_infra() { + // A session id matching no live GUI is bridge/GUI infrastructure, + // not a tool-domain error: must route to -32603, not isError text. + let mut clients = HashMap::new(); + let mut resources = ResourceStore::default(); + let err = call_tool( + "ocs_read", + &json!({"ocs_session_id":"00000000","op":"state"}), + &mut clients, + &mut resources, + &mut detached_pump(), + None, + ) + .unwrap_err(); + assert!(matches!(err, CallError::Infra(_)), "got {err:?}"); + } + + #[test] + fn cancelled_notifications_are_accepted_silently() { + // No response to notifications, even for cancel (nothing in flight + // in a unit test; live waits honor it — see wait_deadline below). + let silent = handle_message( + json!({"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":7}}), + &mut HashMap::new(), + &mut TaskStore::default(), + &mut ResourceStore::default(), + &mut detached_pump(), + ); + assert!(silent.is_none()); + } + + #[test] + fn cancel_matching_and_wait_deadlines() { + // Only a cancel naming our in-flight MCP id counts; anything else + // (other ids, pings, results) is ignored by the wait, not eaten. + assert!(is_cancel_for( + r#"{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":7}}"#, + "7" + )); + assert!(!is_cancel_for( + r#"{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":8}}"#, + "7" + )); + assert!(!is_cancel_for( + r#"{"jsonrpc":"2.0","id":7,"method":"ping"}"#, + "7" + )); + assert!(!is_cancel_for("not json at all", "7")); + // String ids compare as strings: 7 != "7". + assert!(!is_cancel_for( + r#"{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":7}}"#, + "\"7\"" + )); + // Interactive picks may legitimately take minutes; everything else + // keeps the 60 s clamp. + assert_eq!( + wait_deadline("user_select", 3600.0), + Duration::from_secs(600) + ); + assert_eq!(wait_deadline("getpoint", 5.0), Duration::from_secs(5)); + assert_eq!(wait_deadline("run", 3600.0), Duration::from_secs(60)); + assert_eq!(wait_deadline("run", 5.0), Duration::from_secs(5)); + // The ceiling dismisses; anything below returns running for re-poll. + assert!(!interactive_timeout(5.0)); + assert!(!interactive_timeout(599.0)); + assert!(interactive_timeout(600.0)); + assert!(interactive_timeout(3600.0)); + } + + #[test] + fn templates_list_serves_tile_and_snapshot_templates() { + for params in [ + json!({}), + json!({"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}), + ] { + let listed = handle_message( + json!({"jsonrpc":"2.0","id":"tpl","method":"resources/templates/list","params":params}), + &mut HashMap::new(), + &mut TaskStore::default(), + &mut ResourceStore::default(), + &mut detached_pump(), + ) + .unwrap(); + let templates = listed["result"]["resourceTemplates"].as_array().unwrap(); + assert!(templates.iter().any(|t| t["uriTemplate"] + .as_str() + .unwrap() + .contains("{level}"))); + assert!(templates.iter().any(|t| t["uriTemplate"] + .as_str() + .unwrap() + .contains("{hash}"))); + assert_eq!(listed["result"]["ttlMs"], RESOURCE_TTL_MS); + assert_eq!(listed["result"]["cacheScope"], "public"); + } + } + + #[test] + fn tool_titles_are_top_level_and_annotations_allowlisted() { + // Title lives top-level (2026-07-28 Tool shape), not nested. + let tools = tool_definitions(); + for tool in tools.as_array().unwrap() { + assert!(tool["title"].as_str().is_some()); + assert!(tool["annotations"].get("title").is_none()); + let annotations = tool["annotations"].as_object().unwrap(); + for key in annotations.keys() { + assert!( + ["readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint"] + .contains(&key.as_str()), + "unexpected annotation key: {key}" + ); + } + } + } + + #[test] + fn structured_results_conform_to_their_output_schemas() { + // Spot-check the shape every schema'd result must have: an object + // carrying ok, reachable through tool_result unchanged. + let sample = json!({ + "ok": true, + "status": "completed", + "document_id": 1, + "revision": 2, + "geometry_revision": 3, + "camera_revision": 4 + }); + let result = tool_result(sample); + assert_eq!(result["isError"], false); + assert_eq!(result["structuredContent"]["ok"], true); + assert_eq!(result["structuredContent"]["revision"], 2); + } + + fn modern_params() -> Value { + json!({"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{"extensions":{"io.modelcontextprotocol/tasks":{}}}}}) + } + + fn stub_task(id: &str) -> McpTask { + McpTask { + id: id.into(), + name: "ocs_read".into(), + arguments: json!({}), + created_at: iso8601_now(), + last_updated_at: iso8601_now(), + result: None, + error: None, + cancelled: false, + } + } + + #[test] + fn tasks_cancel_marks_cancelled_without_result() { + let mut tasks = TaskStore::default(); + tasks.insert(stub_task("t-cancel")); + let cancelled = handle_message( + json!({"jsonrpc":"2.0","id":"c","method":"tasks/cancel","params":{"taskId":"t-cancel","_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{"extensions":{"io.modelcontextprotocol/tasks":{}}}}}}), + &mut HashMap::new(), + &mut tasks, + &mut ResourceStore::default(), + &mut detached_pump(), + ) + .unwrap(); + assert!(cancelled.get("error").is_none()); + let mut params = modern_params(); + params["taskId"] = Value::String("t-cancel".into()); + let status = handle_message( + json!({"jsonrpc":"2.0","id":"g","method":"tasks/get","params":params}), + &mut HashMap::new(), + &mut tasks, + &mut ResourceStore::default(), + &mut detached_pump(), + ) + .unwrap(); + assert_eq!(status["result"]["status"], "cancelled"); + assert!(status["result"].get("result").is_none()); + assert!(status["result"].get("error").is_none()); + // A late completion landing after the cancel is discarded. + tasks.get_mut("t-cancel").unwrap().result = Some(json!({"ok": true})); + let mut params = modern_params(); + params["taskId"] = Value::String("t-cancel".into()); + let again = handle_message( + json!({"jsonrpc":"2.0","id":"g2","method":"tasks/get","params":params}), + &mut HashMap::new(), + &mut tasks, + &mut ResourceStore::default(), + &mut detached_pump(), + ) + .unwrap(); + assert_eq!(again["result"]["status"], "cancelled"); + assert!(again["result"].get("result").is_none()); + } + + #[test] + fn task_envelope_errors_use_public_codes() { + // The legacy -32000 range is grandfathered, not for new use. + let mut task = stub_task("t-fail"); + task.arguments = + json!({"ocs_session_id":"00000000","op":"state","wait_seconds":0}); + let failed = poll_task( + &mut task, + &mut HashMap::new(), + &mut ResourceStore::default(), + &mut detached_pump(), + ); + assert_eq!(failed["status"], "failed"); + assert_eq!(failed["error"]["code"], -32603); + } + + #[test] + fn schema_sync_respects_opt_out() { + std::env::set_var("OCS_SKIP_SCHEMA_SYNC", "1"); + assert!(!schema_sync_enabled()); + std::env::remove_var("OCS_SKIP_SCHEMA_SYNC"); + assert!(schema_sync_enabled()); + } + + #[test] + fn stale_snapshots_never_delete_fresh_descriptors() { + // Regression: a cached PID snapshot predates a GUI spawn, so the + // newborn pid is "missing" from it. Deletion must still wait until + // the file is older than the grace period (slow starters write + // late); unknown snapshot state never deletes either. + assert!(!stale_snapshot_may_delete(true, Duration::from_secs(0))); + assert!(!stale_snapshot_may_delete(true, Duration::from_secs(59))); + assert!(stale_snapshot_may_delete(true, Duration::from_secs(120))); + assert!(stale_snapshot_may_delete(true, Duration::from_secs(3600))); + assert!(!stale_snapshot_may_delete(false, Duration::from_secs(3600))); + } + + #[test] + fn capture_accepts_optional_request_id() { + // Strict clients insist on sending request_id even though capture + // is not idempotency-keyed: declare it optional so they can. + let tools = tool_definitions(); + let capture = tools + .as_array() + .unwrap() + .iter() + .find(|t| t["name"] == "ocs_capture") + .unwrap(); + let prop = &capture["inputSchema"]["properties"]["request_id"]; + assert_eq!(prop["type"], "string"); + assert!( + !capture["inputSchema"]["required"] + .as_array() + .unwrap() + .contains(&json!("request_id")) + ); + } + + #[test] + fn read_op_capture_points_at_the_capture_tool() { + // ocs_read advertises "capture" but the GUI op needs a file path; + // agents hitting it bare get guidance, not a cryptic failure. + let guided = call_tool( + "ocs_read", + &json!({"ocs_session_id":"s","op":"capture"}), + &mut HashMap::new(), + &mut ResourceStore::default(), + &mut detached_pump(), + None, + ) + .unwrap(); + assert_eq!(guided["ok"], false); + assert!(guided["error"] + .as_str() + .unwrap() + .contains("ocs_capture")); + } + + #[test] + fn malformed_requests_get_jsonrpc_errors() { + let unknown = handle_message( + json!({"jsonrpc":"2.0","id":1,"method":"frobnicate"}), + &mut HashMap::new(), + &mut TaskStore::default(), + &mut ResourceStore::default(), + &mut detached_pump(), + ) + .unwrap(); + assert_eq!(unknown["error"]["code"], -32601); + let nameless = handle_message( + json!({"jsonrpc":"2.0","id":2,"method":"tools/call","params":{}}), + &mut HashMap::new(), + &mut TaskStore::default(), + &mut ResourceStore::default(), + &mut detached_pump(), + ) + .unwrap(); + assert_eq!(nameless["error"]["code"], -32602); + let uriless = handle_message( + json!({"jsonrpc":"2.0","id":3,"method":"resources/read","params":{}}), + &mut HashMap::new(), + &mut TaskStore::default(), + &mut ResourceStore::default(), + &mut detached_pump(), + ) + .unwrap(); + assert_eq!(uriless["error"]["code"], -32602); + } + #[test] fn read_tool_rejects_mutations_before_connecting() { let mut clients = HashMap::new(); @@ -2298,6 +3576,7 @@ mod tests { &mut clients, &mut TaskStore::default(), &mut ResourceStore::default(), + &mut detached_pump(), ) .unwrap(); assert_eq!(called["result"]["isError"], true); @@ -2311,6 +3590,7 @@ mod tests { &mut clients, &mut TaskStore::default(), &mut ResourceStore::default(), + &mut detached_pump(), ) .unwrap(); assert_eq!(rejected["error"]["code"], -32022); @@ -2329,11 +3609,13 @@ mod tests { &mut clients, &mut TaskStore::default(), &mut ResourceStore::default(), + &mut detached_pump(), ) .unwrap(); assert_eq!(called["result"]["isError"], true); + assert!(called["result"].get("structuredContent").is_none()); assert!( - called["result"]["structuredContent"]["error"] + called["result"]["content"][0]["text"] .as_str() .unwrap() .contains("request_id") @@ -2348,16 +3630,12 @@ mod tests { &mut clients, &mut TaskStore::default(), &mut ResourceStore::default(), + &mut detached_pump(), ) .unwrap(); assert_eq!(called["result"]["isError"], true); - assert_eq!( - called["result"]["structuredContent"]["code"], - "invalid_arguments" - ); - let error = called["result"]["structuredContent"]["error"] - .as_str() - .unwrap(); + assert!(called["result"].get("structuredContent").is_none()); + let error = called["result"]["content"][0]["text"].as_str().unwrap(); assert!(error.contains("Missing cmd"), "{error}"); assert!(error.contains("LINE 0,0 10,10"), "{error}"); } @@ -2385,6 +3663,7 @@ mod tests { &mut clients, &mut tasks, &mut resources, + &mut detached_pump(), ) .unwrap(); let res_arr = listed["result"]["resources"].as_array().unwrap(); @@ -2397,6 +3676,7 @@ mod tests { &mut clients, &mut tasks, &mut resources, + &mut detached_pump(), ) .unwrap(); let contents = read_png["result"]["contents"].as_array().unwrap(); @@ -2409,6 +3689,7 @@ mod tests { &mut clients, &mut tasks, &mut resources, + &mut detached_pump(), ) .unwrap(); assert_eq!(read_latest["result"]["contents"][0]["blob"], dummy_data); @@ -2419,17 +3700,20 @@ mod tests { &mut clients, &mut tasks, &mut resources, + &mut detached_pump(), ) .unwrap(); let text = read_meta["result"]["contents"][0]["text"].as_str().unwrap(); assert!(text.contains("\"width\": 100")); - // Test resources/read on non-existent resource returns -32002 error + // Legacy resources/read on non-existent resource keeps -32002 + // (modern gets -32602; see resource_not_found_code_follows_era) let read_err = handle_message( json!({"jsonrpc":"2.0","id":"r-err","method":"resources/read","params":{"uri":"cad://session/sess1/snapshot/nonexistent.png"}}), &mut clients, &mut tasks, &mut resources, + &mut detached_pump(), ) .unwrap(); assert_eq!(read_err["error"]["code"], -32002); @@ -2626,6 +3910,7 @@ mod tests { &mut clients, &mut tasks, &mut resources, + &mut detached_pump(), ) .unwrap(); let res_arr = listed["result"]["resources"].as_array().unwrap(); @@ -2638,6 +3923,7 @@ mod tests { &mut clients, &mut tasks, &mut resources, + &mut detached_pump(), ) .unwrap(); let man_contents = read_man["result"]["contents"].as_array().unwrap(); @@ -2650,6 +3936,7 @@ mod tests { &mut clients, &mut tasks, &mut resources, + &mut detached_pump(), ) .unwrap(); let tile_contents = read_tile["result"]["contents"].as_array().unwrap(); @@ -2727,6 +4014,7 @@ mod tests { last_updated_at: now, result: None, error: None, + cancelled: false, }; let value = task_value(&task, "working"); assert_eq!(value["resultType"], "complete"); @@ -2744,6 +4032,7 @@ mod tests { last_updated_at: iso8601_now(), result: None, error: None, + cancelled: false, }; let t0 = Instant::now(); let mut store = TaskStore::default(); @@ -2770,7 +4059,11 @@ mod tests { "error":"Refresh state before editing" })); assert_eq!(result["isError"], true); - assert_eq!(result["structuredContent"]["code"], "stale_state"); + assert!(result.get("structuredContent").is_none()); + assert!(result["content"][0]["text"] + .as_str() + .unwrap() + .contains("stale_state")); } #[test] @@ -2897,8 +4190,7 @@ mod tests { } #[test] - fn read_op_tools_returns_current_schemas_and_instructions() { - let mut clients = HashMap::new(); + fn read_op_tools_returns_current_schemas_and_instructions() { let mut clients = HashMap::new(); let mut resources = ResourceStore::default(); let req = json!({ "name": "ocs_read", @@ -2912,9 +4204,67 @@ mod tests { &req["arguments"], &mut clients, &mut resources, + &mut detached_pump(), + None, ).expect("ocs_read op: tools must succeed"); assert_eq!(result["ok"], true); assert!(result["tools"].is_array()); assert!(result["instructions"].is_string()); } + + #[test] + fn tool_schema_digest_is_pinned() { + // If this fails, the MCP tool surface changed: review the diff, then + // update TOOL_SCHEMA_DIGEST deliberately (never blindly). + assert_eq!(tool_schema_digest(), TOOL_SCHEMA_DIGEST); + } + + #[test] + fn bridge_identity_is_exposed_on_handshake_payloads() { + // Agents never see MCP serverInfo, so the build announcement must + // ride on payloads they do see: session states and hello/capabilities. + let identity = bridge_identity(); + assert_eq!(identity["name"], "OpenCADStudio"); + assert!(identity["version"].as_str().is_some()); + assert!(identity["build_rev"].as_str().is_some()); + assert!(identity["build_profile"].as_str().is_some()); + assert_eq!(identity["tool_schema"], Value::String(tool_schema_digest())); + + let stamped = with_bridge_identity(json!({"ok": true, "session_id": "s"})); + assert_eq!(stamped["bridge"]["name"], "OpenCADStudio"); + assert_eq!(stamped["ok"], true); + // Non-objects pass through untouched. + assert_eq!(with_bridge_identity(json!([1, 2])), json!([1, 2])); + } + + #[test] + fn startup_lock_serializes_concurrent_launches() { + // A fresh lock held by a live pid means another GUI is already + // starting: the second caller must wait, never spawn. + let dir = std::env::temp_dir().join(format!( + "ocs-startup-lock-test-{}-{}", + std::process::id(), + random_id().unwrap() + )); + std::fs::create_dir_all(&dir).unwrap(); + + // No lock yet: first caller claims it and may spawn. + assert!(try_claim_startup_lock(&dir, std::process::id() as u64)); + // Second caller sees the fresh live claim and must wait. + assert!(!try_claim_startup_lock(&dir, u64::MAX - 1)); + + // A stale lock (previous boot left it behind) is reclaimable. + let stale = now_unix_secs().saturating_sub(STARTUP_LOCK_TTL_SECS + 60); + write_startup_lock_at(&dir, 12345, stale).unwrap(); + assert!(try_claim_startup_lock(&dir, std::process::id() as u64)); + + // A fresh lock from a dead pid is reclaimable (crashed starter). + let fresh = now_unix_secs(); + write_startup_lock_at(&dir, u64::MAX - 2, fresh).unwrap(); + assert!(try_claim_startup_lock(&dir, std::process::id() as u64)); + + release_startup_lock(&dir); + assert!(!startup_lock_path_for(&dir).exists()); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/src/mcp_ops.rs b/src/mcp_ops.rs index 69237e423..c7ef661d8 100644 --- a/src/mcp_ops.rs +++ b/src/mcp_ops.rs @@ -249,18 +249,21 @@ pub static ACTION: OpDef = OpDef { pub static EMBED_IMAGE: OpDef = OpDef { name: "embed_image", - doc: "Embed or link a raster image into the drawing.", + doc: "Embed or link a raster image into the drawing with optional reference scaling and calibration.", batchable: true, params: &[ req("path", Ty::Str, "Path to the image file."), opt("at", Ty::Point, "Lower-left corner [x, y] or [x, y, z]."), opt("width", Ty::Num, "World width of the image."), opt("linked", Ty::Bool, "Store as path-linked RasterImage instead of embedded OLE2FRAME."), + opt("calibrate", Ty::Raw(r#"{"type":"object","description":"Automatic 2-point reference calibration: {point_a:[px1,py1], point_b:[px2,py2], distance:650.0, align_to:[0,0]}"}"#), "Automatic 2-point reference calibration: {point_a:[px1,py1], point_b:[px2,py2], distance:650.0, align_to:[0,0]}."), + opt("layer", Ty::Str, "Layer name to place the image on (auto-created if missing, e.g. '_XREF')."), + opt("lock_layer", Ty::Bool, "Lock the target layer after attaching."), opt("source_points", Ty::ArrayOf(&Ty::Point), "Pixel points in image [[px1,py1],[px2,py2]] for 2-point alignment."), opt("target_points", Ty::ArrayOf(&Ty::Point), "CAD world points [[x1,y1],[x2,y2]] for 2-point alignment."), ], rules: &[], - example: r#"{"op":"embed_image","path":"logo.png","at":[0,0]}"#, + example: r#"{"op":"embed_image","path":"plan.png","linked":true,"calibrate":{"point_a":[52,910],"point_b":[450,910],"distance":6500},"layer":"_XREF","lock_layer":true}"#, }; pub static WBLOCK: OpDef = OpDef { diff --git a/src/modules/annotate/field_cmd.rs b/src/modules/annotate/field_cmd.rs index f6cccfb9c..953b362d1 100644 --- a/src/modules/annotate/field_cmd.rs +++ b/src/modules/annotate/field_cmd.rs @@ -198,4 +198,71 @@ impl CadCommand for FieldObjectPickCommand { } } +/// The Field dialog's Average / Sum / Count / Cell buttons: a cell range (or +/// one cell) is picked in a table and handed back as +/// `_FIELD_CELL [
]`. +pub struct FieldTablePickCommand { + pub function: &'static str, + first: Option<(Handle, DVec3)>, +} + +impl FieldTablePickCommand { + pub fn new(function: &'static str) -> Self { + Self { function, first: None } + } +} + +impl CadCommand for FieldTablePickCommand { + fn name(&self) -> &'static str { + "FIELD" + } + + fn prompt(&self) -> String { + match (self.function, self.first) { + ("Cell", _) => "Select table cell:".into(), + (_, None) => "Select first corner of table cell range:".into(), + _ => "Select second corner of table cell range:".into(), + } + } + + fn needs_entity_pick(&self) -> bool { + true + } + + fn entity_pick_highlights_hover(&self) -> bool { + true + } + + fn on_entity_pick(&mut self, handle: Handle, pt: DVec3) -> CmdResult { + if handle.is_null() { + return CmdResult::NeedPoint; + } + let at = |h: Handle, p: DVec3| format!("{:X} {} {} {}", h.value(), p.x, p.y, p.z); + match (self.function, self.first) { + ("Cell", _) => CmdResult::Dispatch(format!("_FIELD_CELL Cell {}", at(handle, pt))), + (_, None) => { + self.first = Some((handle, pt)); + CmdResult::NeedPoint + } + (function, Some((first, first_pt))) => CmdResult::Dispatch(format!( + "_FIELD_CELL {function} {} {}", + at(first, first_pt), + at(handle, pt) + )), + } + } + + fn on_point(&mut self, _pt: DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Dispatch("_FIELD_CELL".into()) + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Dispatch("_FIELD_CELL".into()) + } +} + inventory::submit!(crate::command::CommandRegistration { names: &["FIELD"] }); diff --git a/src/modules/draw/modify/explode.rs b/src/modules/draw/modify/explode.rs index b8631c67c..dc21485aa 100644 --- a/src/modules/draw/modify/explode.rs +++ b/src/modules/draw/modify/explode.rs @@ -1970,4 +1970,77 @@ mod tests { assert!((line.start.x - 2.0).abs() < 1e-12); assert!((line.end.x - 2.0).abs() < 1e-12); } + + // RED (TDD): a real DIMLINEAR must survive save -> reload with its baked + // *D geometry intact and a clean handle graph (no duplicates, no dangling + // owners). Headless codec round-trip, no GUI needed. + #[test] + fn dimlinear_survives_save_reload_with_baked_geometry() { + let mut failures = Vec::new(); + for ext in ["dxf", "dwg"] { + let mut doc = CadDocument::new(); + let mut d = + DimensionLinear::new(Vector3::new(0.0, 0.0, 0.0), Vector3::new(10.0, 0.0, 0.0)); + d.definition_point = Vector3::new(0.0, 5.0, 0.0); + d.base.text_middle_point = Vector3::new(5.0, 5.0, 0.0); + doc.add_entity(EntityType::Dimension(Dimension::Linear(d))) + .unwrap(); + + let path = std::env::temp_dir().join(format!( + "ocs_dim_rt_{}.{ext}", + std::process::id() + )); + crate::io::save_as_version(&doc, &path, codec::DxfVersion::AC1032) + .expect("save"); + let loaded = crate::io::load_file(&path).expect("load"); + let _ = std::fs::remove_file(&path); + + let mut seen = std::collections::HashSet::new(); + for e in loaded.entities() { + if !seen.insert(e.common().handle) { + failures.push(format!( + "{ext}: duplicate handle {:?}", + e.common().handle + )); + } + } + let dims: Vec<_> = loaded + .entities() + .filter_map(|e| match e { + EntityType::Dimension(d) => Some(d.clone()), + _ => None, + }) + .collect(); + if dims.len() != 1 { + failures.push(format!("{ext}: dimension entity lost")); + continue; + } + let block_name = dims[0].base().block_name.clone(); + if block_name.trim().is_empty() { + failures.push(format!("{ext}: dimension came back blockless")); + continue; + } + let Some(rec) = loaded.block_records.get(&block_name) else { + failures.push(format!("{ext}: *D block {block_name} missing after reload")); + continue; + }; + let owned: Vec = { + let mut h = rec.entity_handles.clone(); + h.push(rec.block_entity_handle); + h.push(rec.block_end_handle); + h + }; + if rec.entity_handles.is_empty() { + failures.push(format!("{ext}: baked block has no sub-entities")); + } + for h in &owned { + if h.is_null() || loaded.get_entity(*h).is_none() { + failures.push(format!( + "{ext}: dangling handle {h:?} in baked block {block_name}" + )); + } + } + } + assert!(failures.is_empty(), "round-trip failures:\n{}", failures.join("\n")); + } } diff --git a/src/modules/draw/modify/offset.rs b/src/modules/draw/modify/offset.rs index be4a901b2..843c67a11 100644 --- a/src/modules/draw/modify/offset.rs +++ b/src/modules/draw/modify/offset.rs @@ -9,6 +9,10 @@ // 2. Pick object to offset (Line, Arc, Circle, LwPolyline) // 3. Pick a point on the side to offset toward, or choose Multiple // to keep offsetting the newly created result at the same distance +// +// With objects already selected, the command opens at step 3 instead, the +// preview following the cursor. At step 3 a typed distance places the +// offset at once on the cursor's side; a negative one, on the other side. use crate::entities::curve::{entity_curve, lwpolyline_world_xy}; use crate::modules::draw::modify::spline_ops::spline_sample_xy; @@ -331,13 +335,18 @@ fn compute_offsets(entity: &EntityType, dist: f64, side_pt: Vec3) -> Vec f64 { + closest_on_wire(entity, pt).map_or(0.0, |(_, d)| d) +} + +/// Nearest point of the entity outline to `pt`, and its distance. +fn closest_on_wire(entity: &EntityType, pt: Vec3) -> Option<([f64; 2], f64)> { let pts = entity_wire_pts(entity); if pts.len() < 2 { - return 0.0; + return None; } let px = pt.x as f64; let py = pt.y as f64; - let mut best = f64::INFINITY; + let mut best = ([px, py], f64::INFINITY); for w in pts.windows(2) { let ax = w[0][0] as f64; let ay = w[0][1] as f64; @@ -354,11 +363,35 @@ fn perp_distance(entity: &EntityType, pt: Vec3) -> f64 { let cx = ax + t * dx; let cy = ay + t * dy; let d = ((px - cx).powi(2) + (py - cy).powi(2)).sqrt(); - if d < best { - best = d; + if d < best.1 { + best = ([cx, cy], d); } } - best + Some(best) +} + +// ── Opposite side ───────────────────────────────────────────────────────── +// +// A negative typed distance offsets away from the cursor. Rather than teach +// every offset function a sign, the cursor is mirrored through its nearest +// point on the outline, which puts it on the other side for every type: the +// other half-plane of a line, inside instead of outside a circle or closed +// loop. The mirror stops at half the offset distance so that, in a narrow +// part of a shape, it cannot cross the far wall and come back to the +// cursor's own side. + +fn far_side(entity: &EntityType, pt: Vec3, dist: f64) -> Option { + let ([qx, qy], d) = closest_on_wire(entity, pt)?; + if d < 1e-9 { + // On the outline itself: there is no side to be opposite to. + return None; + } + let step = d.min(dist * 0.5) / d; + Some(Vec3::new( + (qx + (qx - pt.x as f64) * step) as f32, + (qy + (qy - pt.y as f64) * step) as f32, + pt.z, + )) } // ── Wire preview points ───────────────────────────────────────────────────── @@ -440,9 +473,13 @@ pub struct OffsetCommand { /// Unlike the command's opening snapshot, this also includes objects /// created by earlier offsets while the command remains active. picked: Option, - /// Pre-selected offsettable objects (pick-first, #422); consumed when the - /// distance step resolves. - preselected: Vec, + /// Last cursor position the preview saw. A distance typed at the side + /// step places the offset relative to it. + cursor: Option, + /// The command opened on pre-selected objects (#422) and has made nothing + /// yet: Enter takes the last distance, and Erase / Layer, which the + /// skipped distance step would have offered, are offered here. + drag_start: bool, /// A Yes/No or Current/Source answer is being collected for this option. awaiting: Option, /// Erase option: replace the source object with its offset. @@ -485,7 +522,8 @@ impl OffsetCommand { all_entities, entity_index, picked: None, - preselected: Vec::new(), + cursor: None, + drag_start: false, awaiting: None, erase_source: false, layer_current: false, @@ -494,38 +532,28 @@ impl OffsetCommand { } } - /// Pick-first flow (#422): the distance step still comes first, then the - /// pre-selected objects go straight to the side step. + /// Pick-first flow (#422): the objects are already chosen, so the command + /// opens at the side step with the preview following the cursor. Clicking + /// offsets through the cursor; typing a distance places it at that + /// distance; Enter repeats the last distance (#418). pub fn with_selection(all_entities: Vec, targets: Vec) -> Self { - let entity_index = ModifyEntityIndex::build(&all_entities); - Self { - step: Step::Distance, - all_entities, - entity_index, - picked: None, - preselected: targets, - awaiting: None, - erase_source: false, - layer_current: false, - targets_are_sources: false, - made: 0, + let mut cmd = Self::new(all_entities); + if !targets.is_empty() { + cmd.step = Step::PickSide { + targets, + locked: None, + multiple: false, + }; + cmd.targets_are_sources = true; + cmd.drag_start = true; } + cmd } /// Leave the distance step with the given mode (Some = locked distance, - /// None = through): pre-selected objects jump to the side step, otherwise - /// the object-pick loop starts. + /// None = through) for the object-pick loop. fn advance_from_distance(&mut self, locked: Option) -> CmdResult { - if self.preselected.is_empty() { - self.step = Step::SelectObject { locked }; - } else { - self.step = Step::PickSide { - targets: std::mem::take(&mut self.preselected), - locked, - multiple: false, - }; - self.targets_are_sources = true; - } + self.step = Step::SelectObject { locked }; CmdResult::NeedPoint } @@ -554,6 +582,73 @@ impl OffsetCommand { t!("OFFSET distance = %{d}", d = d).into_owned() ) } + + /// Offset the side step's targets with `pt` choosing the side: toward it, + /// or away from it when `flip` is set. Each target offsets by its own + /// through-distance, or by the locked magnitude. + fn place(&mut self, pt: DVec3, flip: bool) -> CmdResult { + let (locked, targets, multiple) = match &self.step { + Step::PickSide { + locked, + targets, + multiple, + } => (*locked, targets.clone(), *multiple), + + _ => return CmdResult::NeedPoint, + }; + let per_target: Vec<(Handle, Vec)> = targets + .iter() + .filter_map(|entity| { + let mag = locked.unwrap_or_else(|| perp_distance(entity, pt.as_vec3())); + if mag < 1e-9 { + return None; + } + let side = if flip { + far_side(entity, pt.as_vec3(), mag)? + } else { + pt.as_vec3() + }; + let offsets = compute_offsets(entity, mag, side); + (!offsets.is_empty()).then(|| (entity.common().handle, offsets)) + }) + .collect(); + let mut news: Vec = per_target + .iter() + .flat_map(|(_, offsets)| offsets.iter().cloned()) + .collect(); + if news.is_empty() { + return CmdResult::NeedPoint; + } + self.made += 1; + self.drag_start = false; + // Erase option: the offset replaces its source object. Only the + // objects picked from the drawing qualify — a Multiple chain's + // targets are the offsets just made, which are not replaced again. + let erase = self.erase_source && self.targets_are_sources; + self.targets_are_sources = false; + if multiple { + // Multiple mode chains from the result just created, matching + // OFFSET's repeated fixed-distance behavior (parallel/concentric + // series instead of duplicate copies at the first offset). + self.step = Step::PickSide { + targets: news.clone(), + locked, + multiple: true, + }; + } else { + // Classic loop (#418): commit this offset and go back to the object + // pick at the same distance, until Enter / Esc finishes. + self.step = Step::SelectObject { locked }; + } + if erase { + return CmdResult::ReplaceManyContinue(per_target); + } + if news.len() == 1 { + CmdResult::CommitEntity(news.pop().unwrap()) + } else { + CmdResult::CommitEntities(news) + } + } } impl CadCommand for OffsetCommand { @@ -602,6 +697,23 @@ impl CadCommand for OffsetCommand { } else { std::borrow::Cow::Borrowed("") }; + if self.drag_start { + let d = format!("{:.4}", defaults::get_offset_dist()); + return if *multiple { + t!( + "OFFSET%{n} Multiple Click through point or type distance, negative for the other side [Erase/Layer] <%{d}>:", + n = n, + d = d + ) + } else { + t!( + "OFFSET%{n} Click through point or type distance, negative for the other side [Multiple/Erase/Layer] <%{d}>:", + n = n, + d = d + ) + } + .into_owned(); + } match (locked, multiple) { (Some(d), false) => { let d = format!("{:.4}", d); @@ -658,6 +770,16 @@ impl CadCommand for OffsetCommand { opts.push(CmdOption::enter("Exit")); opts } + Step::PickSide { multiple, .. } if self.drag_start => { + let mut opts = Vec::new(); + if !multiple { + opts.push(CmdOption::new("Multiple", "M")); + } + opts.push(CmdOption::new("Erase", "E")); + opts.push(CmdOption::new("Layer", "L")); + opts.push(CmdOption::enter(&format!("{:.4}", defaults::get_offset_dist()))); + opts + } Step::PickSide { multiple, .. } => { let mut opts = Vec::new(); if !multiple { @@ -777,19 +899,23 @@ impl CadCommand for OffsetCommand { { return self.undo_last(); } + // Erase / Layer belong to the distance step, or to the side step when + // a pre-selection skipped the distance step. + if matches!(self.step, Step::Distance) || self.drag_start { + if upper == "E" || upper == "ERASE" { + self.awaiting = Some(Await::Erase); + return Some(CmdResult::NeedPoint); + } + if upper == "L" || upper == "LAYER" { + self.awaiting = Some(Await::Layer); + return Some(CmdResult::NeedPoint); + } + } match &mut self.step { Step::Distance => { if t.eq_ignore_ascii_case("t") || t.eq_ignore_ascii_case("through") { return Some(self.advance_from_distance(None)); } - if upper == "E" || upper == "ERASE" { - self.awaiting = Some(Await::Erase); - return Some(CmdResult::NeedPoint); - } - if upper == "L" || upper == "LAYER" { - self.awaiting = Some(Await::Layer); - return Some(CmdResult::NeedPoint); - } if let Some(d) = crate::entities::common::parse_typed_length(&t) { return Some(self.accept_distance(d)); } @@ -804,15 +930,20 @@ impl CadCommand for OffsetCommand { *multiple = true; return Some(CmdResult::NeedPoint); } - if !t.is_empty() { - if let Some(d) = crate::entities::common::parse_typed_length(&t) { - let d = d.abs().max(1e-9); - defaults::set_offset_dist(d); - *locked = Some(d); - } + let Some(d) = crate::entities::common::parse_typed_length(&t) else { + return Some(CmdResult::NeedPoint); + }; + let mag = d.abs().max(1e-9); + defaults::set_offset_dist(mag); + *locked = Some(mag); + // The cursor has already chosen the side, so the value places + // the offset now: on the cursor's side, or the other side when + // negative. Before the cursor has been seen, the click still + // chooses. + match self.cursor { + Some(cursor) => Some(self.place(cursor, d < 0.0)), + None => Some(CmdResult::NeedPoint), } - // Stay on the side step — the click chooses which side. - Some(CmdResult::NeedPoint) } _ => None, } @@ -863,66 +994,11 @@ impl CadCommand for OffsetCommand { _ => {} } - let (locked, targets, multiple) = match &self.step { - Step::PickSide { - locked, - targets, - multiple, - } => (*locked, targets.clone(), *multiple), - - _ => return CmdResult::NeedPoint, - }; - // Each target offsets by its own through-distance (or the locked - // magnitude), toward the clicked side. - let per_target: Vec<(Handle, Vec)> = targets - .iter() - .filter_map(|entity| { - let mag = locked.unwrap_or_else(|| perp_distance(entity, pt.as_vec3())); - if mag < 1e-9 { - return None; - } - let offsets = compute_offsets(entity, mag, pt.as_vec3()); - (!offsets.is_empty()).then(|| (entity.common().handle, offsets)) - }) - .collect(); - let mut news: Vec = per_target - .iter() - .flat_map(|(_, offsets)| offsets.iter().cloned()) - .collect(); - if news.is_empty() { - return CmdResult::NeedPoint; - } - self.made += 1; - // Erase option: the offset replaces its source object. Only the - // objects picked from the drawing qualify — a Multiple chain's - // targets are the offsets just made, which are not replaced again. - let erase = self.erase_source && self.targets_are_sources; - self.targets_are_sources = false; - if multiple { - // Multiple mode chains from the result just created, matching - // OFFSET's repeated fixed-distance behavior (parallel/concentric - // series instead of duplicate copies at the first offset). - self.step = Step::PickSide { - targets: news.clone(), - locked, - multiple: true, - }; - } else { - // Classic loop (#418): commit this offset and go back to the object - // pick at the same distance, until Enter / Esc finishes. - self.step = Step::SelectObject { locked }; - } - if erase { - return CmdResult::ReplaceManyContinue(per_target); - } - if news.len() == 1 { - CmdResult::CommitEntity(news.pop().unwrap()) - } else { - CmdResult::CommitEntities(news) - } + self.place(pt, false) } fn on_preview_wires(&mut self, pt: DVec3) -> Vec { + self.cursor = Some(pt); if let Step::ReferenceSecond { first } = &self.step { return vec![WireModel::solid( "offset_reference_distance".into(), @@ -979,6 +1055,17 @@ impl CadCommand for OffsetCommand { let d = defaults::get_offset_dist(); self.accept_distance(d) } + // Opened on a pre-selection, the same Enter offsets by the last + // distance on the cursor's side. + Step::PickSide { .. } if self.drag_start => { + let Some(cursor) = self.cursor else { + return CmdResult::NeedPoint; + }; + if let Step::PickSide { locked, .. } = &mut self.step { + *locked = Some(defaults::get_offset_dist()); + } + self.place(cursor, false) + } _ => CmdResult::Cancel, } } @@ -1100,3 +1187,127 @@ mod option_tests { assert!(matches!(cmd.on_point(DVec3::new(5.0, 5.0, 0.0)), CmdResult::CommitEntity(_))); } } + +#[cfg(test)] +mod drag_tests { + use super::*; + use codec::types::Vector2; + + fn line_y0() -> EntityType { + let mut line = codec::entities::Line::from_coords(0.0, 0.0, 0.0, 10.0, 0.0, 0.0); + line.common.handle = Handle::new(1); + EntityType::Line(line) + } + + /// OFFSET opened on `target`, with the cursor hovering at `cursor`. + fn dragging(target: EntityType, cursor: [f64; 2]) -> OffsetCommand { + let mut cmd = OffsetCommand::with_selection(vec![target.clone()], vec![target]); + cmd.on_preview_wires(DVec3::new(cursor[0], cursor[1], 0.0)); + cmd + } + + fn committed_line_y(result: Option) -> f64 { + match result { + Some(CmdResult::CommitEntity(EntityType::Line(l))) => l.start.y, + _ => panic!("expected one committed line"), + } + } + + #[test] + fn preselection_opens_on_the_live_preview() { + let mut cmd = OffsetCommand::with_selection(vec![line_y0()], vec![line_y0()]); + assert!(matches!(cmd.step, Step::PickSide { locked: None, .. })); + assert_eq!(cmd.options().into_iter().map(|o| o.keyword).collect::>(), ["M", "E", "L", ""]); + // The preview passes through the cursor before anything is typed. + assert_eq!(cmd.on_preview_wires(DVec3::new(5.0, 3.0, 0.0)).len(), 1); + assert_eq!(cmd.dyn_live_value(DVec3::new(5.0, 3.0, 0.0)), Some(3.0)); + // A click still offsets through the cursor. + assert!(matches!(cmd.on_point(DVec3::new(5.0, 3.0, 0.0)), CmdResult::CommitEntity(_))); + } + + #[test] + fn typed_distance_places_on_the_cursor_side() { + let mut cmd = dragging(line_y0(), [5.0, 3.0]); + assert!((committed_line_y(cmd.on_text_input("2")) - 2.0).abs() < 1e-9); + // The loop continues at the typed distance. + assert!(matches!(cmd.step, Step::SelectObject { locked: Some(d) } if d == 2.0)); + } + + #[test] + fn negative_distance_places_on_the_other_side() { + let mut cmd = dragging(line_y0(), [5.0, 3.0]); + assert!((committed_line_y(cmd.on_text_input("-2")) + 2.0).abs() < 1e-9); + assert_eq!(defaults::get_offset_dist(), 2.0); + } + + #[test] + fn negative_distance_inside_a_circle_grows_it() { + let circle = EntityType::Circle(CircleEnt { + center: codec::types::Vector3::new(0.0, 0.0, 0.0), + radius: 10.0, + ..Default::default() + }); + let mut cmd = dragging(circle.clone(), [3.0, 0.0]); + match cmd.on_text_input("-1") { + Some(CmdResult::CommitEntity(EntityType::Circle(c))) => assert!((c.radius - 11.0).abs() < 1e-9), + _ => panic!("expected one committed circle"), + } + let mut cmd = dragging(circle, [3.0, 0.0]); + match cmd.on_text_input("1") { + Some(CmdResult::CommitEntity(EntityType::Circle(c))) => assert!((c.radius - 9.0).abs() < 1e-9), + _ => panic!("expected one committed circle"), + } + } + + // A U whose arms sit 1 apart. From inside the left arm the nearest wall + // faces the gap; mirroring the cursor its full 1.9 across would land in + // the right arm, back inside the loop. -0.25 must still go outward. + #[test] + fn negative_distance_does_not_mirror_across_a_gap() { + let u = EntityType::LwPolyline(LwPolyline { + vertices: [ + [0.0, 0.0], [9.0, 0.0], [9.0, 10.0], [5.0, 10.0], + [5.0, 2.0], [4.0, 2.0], [4.0, 10.0], [0.0, 10.0], + ] + .iter() + .map(|&[x, y]| LwVertex::new(Vector2::new(x, y))) + .collect(), + is_closed: true, + ..Default::default() + }); + let mut cmd = dragging(u, [2.1, 6.0]); + match cmd.on_text_input("-0.25") { + Some(CmdResult::CommitEntity(EntityType::LwPolyline(p))) => { + let min_x = p.vertices.iter().map(|v| v.location.x).fold(f64::INFINITY, f64::min); + assert!((min_x + 0.25).abs() < 1e-6, "offset went inward: min x {min_x}"); + } + _ => panic!("expected one committed polyline"), + } + } + + #[test] + fn enter_repeats_the_last_distance_on_the_cursor_side() { + defaults::set_offset_dist(4.0); + let mut cmd = dragging(line_y0(), [5.0, -1.0]); + match cmd.on_enter() { + CmdResult::CommitEntity(EntityType::Line(l)) => assert!((l.start.y + 4.0).abs() < 1e-9), + _ => panic!("expected one committed line"), + } + // After that, Enter finishes as before. + assert!(matches!(cmd.on_enter(), CmdResult::Cancel)); + } + + #[test] + fn erase_is_offered_without_the_distance_step() { + let mut cmd = dragging(line_y0(), [5.0, 3.0]); + cmd.on_text_input("E"); + cmd.on_text_input("Y"); + assert!(matches!(cmd.on_text_input("2"), Some(CmdResult::ReplaceManyContinue(_)))); + } + + #[test] + fn without_preselection_the_distance_comes_first() { + let cmd = OffsetCommand::new(vec![line_y0()]); + assert!(matches!(cmd.step, Step::Distance)); + } +} diff --git a/src/modules/model/primitive_cmd.rs b/src/modules/model/primitive_cmd.rs index 016640de0..cca272072 100644 --- a/src/modules/model/primitive_cmd.rs +++ b/src/modules/model/primitive_cmd.rs @@ -1894,7 +1894,7 @@ impl PrimitiveCommand { Some(( placed, solid_history::box_op( - self.history_transform_axes(origin, x_axis, y_axis), + self.history_transform_axes(box_centre(origin, x_axis, y_axis, length, width, height), x_axis, y_axis), length, width, height, @@ -2024,7 +2024,7 @@ impl PrimitiveCommand { Some(( placed, solid_history::wedge_op( - self.history_transform_axes(origin, x_axis, y_axis), + self.history_transform_axes(box_centre(origin, x_axis, y_axis, length, width, height), x_axis, y_axis), length, width, height, @@ -3714,3 +3714,9 @@ fn wire(name: &str, points: Vec<[f32; 3]>) -> WireModel { ..Default::default() } } + +/// Box and wedge histories are framed at the centre of their bounding box, +/// as the reference stores them (lengths stay positive). +fn box_centre(origin: DVec3, x_axis: DVec3, y_axis: DVec3, length: f64, width: f64, height: f64) -> DVec3 { + origin + x_axis * (length * 0.5) + y_axis * (width * 0.5) + DVec3::Z * (height * 0.5) +} diff --git a/src/scene/boundary.rs b/src/scene/boundary.rs index da573722e..1c7aae65e 100644 --- a/src/scene/boundary.rs +++ b/src/scene/boundary.rs @@ -816,6 +816,48 @@ impl Scene { true } + /// A boundary source split into pieces (TRIM, BREAK): every associative + /// hatch bounded by `old` is bounded by all of `pieces` instead, so the + /// region survives `old` being erased. + pub(crate) fn split_hatch_source(&mut self, old: Handle, pieces: &[Handle]) { + if pieces.is_empty() { + return; + } + let dependents = self.associative_hatch_dependents(&std::iter::once(old).collect()); + if dependents.is_empty() { + return; + } + for &hatch_handle in &dependents { + if self.is_recording_undo() { + let before = self.document.get_entity_arc(hatch_handle); + self.record_undo_before(hatch_handle, before); + } + let Some(EntityType::Hatch(hatch)) = self.document.get_entity_mut(hatch_handle) else { + continue; + }; + for path in &mut hatch.paths { + if !path.boundary_handles.contains(&old) { + continue; + } + path.boundary_handles.retain(|source| *source != old); + for &piece in pieces { + if !path.boundary_handles.contains(&piece) { + path.boundary_handles.push(piece); + } + } + } + } + self.associative_hatch_source_cache.borrow_mut().take(); + let changes: Vec<_> = pieces + .iter() + .map(|&piece| (piece, ChangeKind::Modified)) + .collect(); + let refreshed = self.refresh_associative_hatches(&changes); + if !refreshed.is_empty() { + self.bump_entities(&refreshed); + } + } + fn associative_hatch_dependents( &self, changed: &rustc_hash::FxHashSet, diff --git a/src/scene/convert/acis_kernel.rs b/src/scene/convert/acis_kernel.rs index d4ede7699..d1affae13 100644 --- a/src/scene/convert/acis_kernel.rs +++ b/src/scene/convert/acis_kernel.rs @@ -31,23 +31,14 @@ pub fn tessellate_sat( if bodies.is_empty() { return None; } + // The kernel's lift already applies each body's transform record; its + // scale still sets the tessellation tolerance. let mut placed_bodies = Vec::with_capacity(bodies.len()); for body in bodies { let source = body.provenance.source()?; let transform = body_transform(document, source.index() as usize).ok()?; let placement_scale = transform.map_or(1.0, |(_, _, scale)| scale.abs()); - let placed = if let Some((matrix, translation, scale)) = transform { - let placement = brep::Placement { - x_axis: [scale * matrix[0], scale * matrix[1], scale * matrix[2]], - y_axis: [scale * matrix[3], scale * matrix[4], scale * matrix[5]], - z_axis: [scale * matrix[6], scale * matrix[7], scale * matrix[8]], - origin: translation, - }; - brep::transform(&body, &placement)? - } else { - body - }; - placed_bodies.push((placed, placement_scale)); + placed_bodies.push((body, placement_scale)); } let bodies = placed_bodies; let resolution = if facet_res.is_finite() && facet_res > 0.0 { diff --git a/src/scene/convert/solid3d_tess.rs b/src/scene/convert/solid3d_tess.rs index 987b920a7..1bda680d5 100644 --- a/src/scene/convert/solid3d_tess.rs +++ b/src/scene/convert/solid3d_tess.rs @@ -1,6 +1,6 @@ use codec::entities::acis::{SabReader, SatBody, SatDocument}; use codec::entities::{Body, Region, Solid3D, Surface}; -use kernel::brep::{self, Body as KernelBody}; +use kernel::brep::Body as KernelBody; use crate::scene::model::mesh_model::{MeshLodSet, MeshModel}; @@ -179,21 +179,8 @@ pub(crate) fn kernel_acis_body(acis: &codec::entities::AcisData) -> Option 1e-6)); - let taper_widths = if has_line_taper || straight_widths.iter().any(|&w| w > 1e-6) { + // NaN breaks carry a 0 width; only real points decide whether the + // band tapers, so a constant-width run keeps its mitered joints. + let mut real_widths = line_pts + .iter() + .zip(&straight_widths) + .filter(|(p, _)| p[0].is_finite()) + .map(|(_, &w)| w); + let has_line_taper = real_widths + .next() + .map_or(false, |w0| real_widths.any(|w| (w - w0).abs() > 1e-6)); + let taper_widths = if has_line_taper { straight_widths } else { Vec::new() diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 6108ac028..9c22246bb 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -143,6 +143,7 @@ impl Scene { /// layer 0. Auto-registering keeps it on its own layer (#252). Names are /// registered verbatim so the writer's (case-insensitive) lookup matches; /// the always-present default layer "0" and empty names are no-ops. + /// Flags `layer_table_dirty` so the app refreshes its layer lists. pub fn ensure_layer(&mut self, name: &str) { if name.trim().is_empty() || self.document.layers.contains(name) { return; @@ -150,6 +151,8 @@ impl Scene { let mut layer = codec::tables::Layer::new(name); layer.handle = self.document.allocate_handle(); let _ = self.document.layers.add(layer); + self.invalidate_layer_dependencies(&[name.to_string()]); + self.layer_table_dirty = true; } pub(crate) fn ensure_app_id(&mut self, name: &str) { diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 09a4dc793..59e26575b 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -2067,6 +2067,9 @@ pub struct Scene { pub preview_text: Vec, /// Committed-segment wire drawn during multi-point commands (normal colour). pub interim_wire: Option, + /// Set when an entity creation or update automatically registers a novel + /// layer into the document layer table, signalling the UI to refresh. + pub layer_table_dirty: bool, pub camera_generation: u64, /// Incremented whenever geometry-affecting state changes (entities, selection, /// preview wires, layer visibility, layout). The GPU pipeline uses this to @@ -2640,6 +2643,7 @@ impl Scene { preview_hatches: Arc::new(Vec::new()), preview_text: vec![], interim_wire: None, + layer_table_dirty: false, camera_generation: 0, geometry_epoch: GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed), projection_bounds_epoch: std::cell::Cell::new(0), diff --git a/src/scene/model/solid_history.rs b/src/scene/model/solid_history.rs index 569f40968..bca6763f6 100644 --- a/src/scene/model/solid_history.rs +++ b/src/scene/model/solid_history.rs @@ -278,7 +278,7 @@ pub fn reference_point(operation: &SolidHistoryOperation) -> Option match operation { SolidHistoryOperation::Box(value) | SolidHistoryOperation::Wedge(value) => world_point( value.base.transform, - [value.length * 0.5, value.width * 0.5, 0.0], + [0.0, 0.0, -value.height * 0.5], ), SolidHistoryOperation::Sphere(value) => { world_point(value.base.transform, [0.0, 0.0, 0.0]) @@ -1615,9 +1615,10 @@ pub fn primitive_properties( ) { return Vec::new(); } - let Some(operation) = primitive_property_operation(document, handle) else { + let Some(mut operation) = primitive_property_operation(document, handle) else { return brep_properties(document, handle); }; + corner_frame(&mut operation); match &operation { SolidHistoryOperation::Box(value) => { rectangular_properties(document, handle, value, "Box") @@ -2414,6 +2415,17 @@ pub fn apply_primitive_property( operation: &mut SolidHistoryOperation, field: &str, value: &str, +) -> bool { + corner_frame(operation); + let applied = apply_primitive_property_at_corner(operation, field, value); + centre_frame(operation); + applied +} + +fn apply_primitive_property_at_corner( + operation: &mut SolidHistoryOperation, + field: &str, + value: &str, ) -> bool { if let SolidHistoryOperation::Brep(brep_value) = operation { if let Some(applied) = apply_brep_position_property(brep_value, field, value) { @@ -2757,7 +2769,7 @@ fn embedded_entity(value: &EmbeddedEntity) -> Option { EmbeddedEntity::Region(value) => EntityType::Region(value.clone()), EmbeddedEntity::Ray(value) => EntityType::Ray(value.clone()), EmbeddedEntity::XLine(value) => EntityType::XLine(value.clone()), - EmbeddedEntity::Unknown { .. } => return None, + EmbeddedEntity::Body { .. } | EmbeddedEntity::Unknown { .. } => return None, }) } @@ -3460,6 +3472,9 @@ pub fn primitive_grips( let Some(operation) = document.solid_history_operation(handle) else { return Vec::new(); }; + let mut operation = operation.clone(); + corner_frame(&mut operation); + let operation = &operation; if let SolidHistoryOperation::Fillet(value) = operation { let Some(radius) = value.radii.first().copied() else { return Vec::new(); @@ -3743,6 +3758,17 @@ pub fn apply_primitive_grip( operation: &mut SolidHistoryOperation, grip_id: usize, apply: GripApply, +) -> bool { + corner_frame(operation); + let applied = apply_primitive_grip_at_corner(operation, grip_id, apply); + centre_frame(operation); + applied +} + +fn apply_primitive_grip_at_corner( + operation: &mut SolidHistoryOperation, + grip_id: usize, + apply: GripApply, ) -> bool { if let SolidHistoryOperation::Revolve(value) = operation { if let Some(index) = grip_id.checked_sub(GRIP_REVOLVE_PROFILE_FIRST) { @@ -4138,3 +4164,23 @@ pub fn chamfer_op( ..SolidHistoryChamfer::default() }) } + +/// Box and wedge histories are framed at the centre of their bounding box, +/// as the reference stores them; the editing code here works in the frame at +/// the base corner. These move a box or wedge between the two frames. +fn corner_frame(operation: &mut SolidHistoryOperation) { + shift_rectangular_frame(operation, -0.5); +} + +fn centre_frame(operation: &mut SolidHistoryOperation) { + shift_rectangular_frame(operation, 0.5); +} + +fn shift_rectangular_frame(operation: &mut SolidHistoryOperation, factor: f64) { + if let SolidHistoryOperation::Box(value) | SolidHistoryOperation::Wedge(value) = operation { + if let Some(current) = matrix(value.base.transform) { + let half = glam::DVec3::new(value.length, value.width, value.height) * factor; + value.base.transform = (current * glam::DMat4::from_translation(half)).to_cols_array(); + } + } +} diff --git a/src/scene/model/wire_model.rs b/src/scene/model/wire_model.rs index 4358d1e5c..233ee854d 100644 --- a/src/scene/model/wire_model.rs +++ b/src/scene/model/wire_model.rs @@ -744,6 +744,11 @@ impl WireModel { pub fn is_display_only(&self) -> bool { self.snap_only && self.display_visible } + /// A POINT's wire: the Node is its only snap; the glyph strokes are drawn + /// but offer no endpoints or segments to snap to. + pub fn is_node_marker(&self) -> bool { + self.key_vertices.is_empty() && matches!(self.snap_pts.as_slice(), [(_, SnapHint::Node)]) + } /// Sentinel AABB that never rejects any snap query. pub const UNBOUNDED_AABB: [f32; 4] = [ f32::NEG_INFINITY, diff --git a/src/scene/pick/interaction_index.rs b/src/scene/pick/interaction_index.rs index efc62a2cd..47f1bc15b 100644 --- a/src/scene/pick/interaction_index.rs +++ b/src/scene/pick/interaction_index.rs @@ -699,7 +699,7 @@ fn append_wire_index_entries(wire_idx: u32, wire: &WireModel, batch: &mut WireIn ); } - if wire.point_marker.is_none() { + if wire.point_marker.is_none() && !wire.is_node_marker() { for start in 0..wire.points.len().saturating_sub(1) { let Some(aabb) = points_aabb3([ wire_point(wire, start), diff --git a/src/scene/pipeline/wire_gpu.rs b/src/scene/pipeline/wire_gpu.rs index aac7f4a3b..ee360adc4 100644 --- a/src/scene/pipeline/wire_gpu.rs +++ b/src/scene/pipeline/wire_gpu.rs @@ -71,8 +71,9 @@ pub struct WireInstance { /// Endpoint width / the per-wire maximum width, normalized by the vertex /// fetch unit. `[0, 0]` means use the constant width. Ratios retain the /// full f32 world-width scale in `WireConst` while making every instance - /// exactly one 64-byte cache line. - pub taper_ratio: [u16; 2], + /// exactly one 64-byte cache line. A constant-width band carries its + /// joint miters (`band_miters`) here instead. + pub taper_ratio: [i16; 2], } impl WireInstance { @@ -86,7 +87,7 @@ impl WireInstance { wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, distance_a) as u64, shader_location: 4, format: wgpu::VertexFormat::Float32 }, wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, distance_b) as u64, shader_location: 5, format: wgpu::VertexFormat::Float32 }, wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, wire_id) as u64, shader_location: 6, format: wgpu::VertexFormat::Uint32 }, - wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, taper_ratio) as u64, shader_location: 7, format: wgpu::VertexFormat::Unorm16x2 }, + wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, taper_ratio) as u64, shader_location: 7, format: wgpu::VertexFormat::Snorm16x2 }, ]; wgpu::VertexBufferLayout { array_stride: std::mem::size_of::() as u64, @@ -288,7 +289,7 @@ pub struct BlockWireVertex { pub pos_b: [f32; 3], pub pos_b_low: [f32; 3], pub distances: [f32; 2], - pub taper_ratio: [u16; 2], + pub taper_ratio: [i16; 2], } impl BlockWireVertex { @@ -299,7 +300,7 @@ impl BlockWireVertex { wgpu::VertexAttribute { offset: std::mem::offset_of!(BlockWireVertex, pos_a_low) as u64, shader_location: 2, format: wgpu::VertexFormat::Float32x3 }, wgpu::VertexAttribute { offset: std::mem::offset_of!(BlockWireVertex, pos_b_low) as u64, shader_location: 3, format: wgpu::VertexFormat::Float32x3 }, wgpu::VertexAttribute { offset: std::mem::offset_of!(BlockWireVertex, distances) as u64, shader_location: 4, format: wgpu::VertexFormat::Float32x2 }, - wgpu::VertexAttribute { offset: std::mem::offset_of!(BlockWireVertex, taper_ratio) as u64, shader_location: 5, format: wgpu::VertexFormat::Unorm16x2 }, + wgpu::VertexAttribute { offset: std::mem::offset_of!(BlockWireVertex, taper_ratio) as u64, shader_location: 5, format: wgpu::VertexFormat::Snorm16x2 }, ]; wgpu::VertexBufferLayout { array_stride: std::mem::size_of::() as u64, @@ -577,6 +578,96 @@ fn marker_metadata(wire: &WireModel) -> ([f32; 4], [f32; 4], [f32; 4]) { ) } +/// Largest |tan(turn/2)| a band joint is mitered at; sharper turns stay butt +/// ends instead of growing a long spike. Must match `MITER_LIMIT` in the wire +/// shaders, which decode the normalized value back. +pub(crate) const MITER_LIMIT: f64 = 8.0; + +/// Per-segment miter factors `[k_a, k_b]` of a constant-width world band, +/// normalized by `MITER_LIMIT`. The shader slides each quad corner by +/// `k·side·half_width` along the segment, so neighbouring segments meet on +/// their joint's bisector instead of leaving a notch at the outer corner. +/// Empty for every other wire. +fn band_miters(wire: &WireModel, normal: [f32; 4]) -> Vec<[f32; 2]> { + let n = wire.points.len(); + if wire.world_width <= 0.0 || !wire.taper_widths.is_empty() || n < 3 { + return Vec::new(); + } + let at = |i: usize| -> Option { + let h = wire.points[i]; + let l = wire.points_low.get(i).copied().unwrap_or([0.0; 3]); + finite3(h).then(|| { + glam::DVec3::new( + h[0] as f64 + l[0] as f64, + h[1] as f64 + l[1] as f64, + h[2] as f64 + l[2] as f64, + ) + }) + }; + let same = |a: glam::DVec3, b: glam::DVec3| a.distance(b) <= 1e-9 * (1.0 + a.length()); + let first = (0..n).find_map(|i| at(i).map(|p| (i, p))); + let last = (0..n).rev().find_map(|i| at(i).map(|p| (i, p))); + let closed = matches!((first, last), (Some((i, a)), Some((j, b))) if i < j && same(a, b)); + // Direction leaving joint `from` (or arriving at it when `step` walks + // back), skipping duplicate points and NaN breaks whose next run starts + // at the same joint; a closed wire wraps around once. + let neighbour = |from: usize, step: isize| -> Option { + let joint = at(from)?; + let mut i = from as isize; + let mut wrapped = false; + let mut broken = false; + loop { + i += step; + if i < 0 || i >= n as isize { + if !closed || wrapped { + return None; + } + wrapped = true; + i = if step > 0 { first?.0 as isize } else { last?.0 as isize }; + continue; + } + let Some(q) = at(i as usize) else { + broken = true; + continue; + }; + if same(q, joint) { + broken = false; + continue; + } + if broken { + return None; + } + let d = if step > 0 { q - joint } else { joint - q }; + return Some(d.normalize()); + } + }; + let axis = glam::DVec3::new(normal[0] as f64, normal[1] as f64, normal[2] as f64); + let axis = if axis.length() > 1e-4 { axis.normalize() } else { glam::DVec3::Z }; + // tan of half the signed turn from `d1` to `d2` about the band normal. + let half_turn = |d1: glam::DVec3, d2: glam::DVec3| -> f32 { + let cos = d1.dot(d2); + if cos <= -1.0 + 1e-9 { + return 0.0; + } + let t = axis.dot(d1.cross(d2)) / (1.0 + cos); + if t.abs() > MITER_LIMIT { 0.0 } else { (t / MITER_LIMIT) as f32 } + }; + (0..n - 1) + .map(|i| { + let (Some(a), Some(b)) = (at(i), at(i + 1)) else { + return [0.0; 2]; + }; + if same(a, b) { + return [0.0; 2]; + } + let d = (b - a).normalize(); + let k_a = neighbour(i, -1).map_or(0.0, |prev| half_turn(prev, d)); + let k_b = neighbour(i + 1, 1).map_or(0.0, |next| -half_turn(d, next)); + [k_a, k_b] + }) + .collect() +} + /// Emit packed per-segment instances (each carries the wire's constants). pub(crate) fn emit_wire_packed( wire: &WireModel, @@ -599,6 +690,7 @@ pub(crate) fn emit_wire_packed( (Vec::new(), 0.0, 0.0) }; let (marker_origin_high, marker_origin_low, marker_normal_scale) = marker_metadata(wire); + let miters = band_miters(wire, marker_normal_scale); let low = |i: usize| -> [f32; 3] { wire.points_low.get(i).copied().unwrap_or([0.0; 3]) }; let is_tapered = !wire.taper_widths.is_empty(); let tw = |i: usize| -> f32 { @@ -638,8 +730,17 @@ pub(crate) fn emit_wire_packed( world_half_width: wire.world_width * 0.5, world_hw_a: tw(i), world_hw_b: tw(i + 1), - marker_origin_high, - marker_origin_low, + // The origin `w` slots are free: they carry the band miters. + marker_origin_high: { + let mut m = marker_origin_high; + m[3] = miters.get(i).map_or(0.0, |k| k[0]); + m + }, + marker_origin_low: { + let mut m = marker_origin_low; + m[3] = miters.get(i).map_or(0.0, |k| k[1]); + m + }, marker_normal_scale, }); } @@ -684,14 +785,17 @@ pub(crate) fn emit_wire_native( return (Vec::new(), cst); } let low = |i: usize| -> [f32; 3] { wire.points_low.get(i).copied().unwrap_or([0.0; 3]) }; - let taper_ratio = |i: usize| -> u16 { + let snorm = |v: f32| -> i16 { (v.clamp(-1.0, 1.0) * i16::MAX as f32).round() as i16 }; + let taper_ratio = |i: usize| -> i16 { if !is_tapered || wire.world_width <= 0.0 { 0 } else { let width = wire.taper_widths.get(i).copied().unwrap_or(0.0); - ((width / wire.world_width).clamp(0.0, 1.0) * u16::MAX as f32).round() as u16 + snorm(width / wire.world_width) } }; + // A constant-width band reuses the slot for its joint miters. + let miters = band_miters(wire, marker_normal_scale); let mut instances: Vec = Vec::with_capacity(seg_count); for i in 0..seg_count { let a = wire.points[i]; @@ -712,7 +816,10 @@ pub(crate) fn emit_wire_native( distance_a: dist_a, distance_b: dist_b, wire_id, - taper_ratio: [taper_ratio(i), taper_ratio(i + 1)], + taper_ratio: match miters.get(i) { + Some(k) => [snorm(k[0]), snorm(k[1])], + None => [taper_ratio(i), taper_ratio(i + 1)], + }, }); } (instances, cst) diff --git a/src/shaders/block_wire.wgsl b/src/shaders/block_wire.wgsl index 31683b58e..8399a2c2f 100644 --- a/src/shaders/block_wire.wgsl +++ b/src/shaders/block_wire.wgsl @@ -46,6 +46,8 @@ struct VertexIn { const MODEL_LINEWEIGHT_BOOST: f32 = 2.0; const MODEL_LINEWEIGHT_MAX_PX: f32 = 10.0; +// Must match `MITER_LIMIT` in wire_gpu.rs. +const MITER_LIMIT: f32 = 8.0; struct VertexOut { @builtin(position) clip_pos: vec4, @@ -161,13 +163,17 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32, in: VertexIn) -> VertexOut } perp_world = normalize(perp_world); + // A constant-width band slides each corner along the segment onto + // its joint's bisector (miter), so neighbours meet without a notch. + let miter = select(in.taper_ratio, vec2(0.0), is_tapered) * MITER_LIMIT; + let along = mix(miter.x, miter.y, which_end) * eff_hw * side; let pos_rel = mix(rel_a, rel_b, which_end); - let world_pos = pos_rel + perp_world * (eff_hw * side); + let world_pos = pos_rel + perp_world * (eff_hw * side) + world_dir * along; var clip_pos = u.view_rot * vec4(world_pos, 1.0); clip_pos = apply_draw_order(clip_pos, in.depth.x); final_clip = clip_pos; - out_dist = mix(in.distances.x, in.distances.y, which_end); + out_dist = mix(in.distances.x, in.distances.y, which_end) + along * (in.distances.y - in.distances.x) / max(world_len, 1e-6); out_cap = vec2(which_end * world_len, eff_hw * side); out_cap_ends = vec3(world_len, world_hw_a, world_hw_b); } else { diff --git a/src/shaders/block_wire_storage.wgsl b/src/shaders/block_wire_storage.wgsl index 33ee2d636..ebb5a7ea6 100644 --- a/src/shaders/block_wire_storage.wgsl +++ b/src/shaders/block_wire_storage.wgsl @@ -70,6 +70,8 @@ struct VertexIn { const MODEL_LINEWEIGHT_BOOST: f32 = 2.0; const MODEL_LINEWEIGHT_MAX_PX: f32 = 10.0; +// Must match `MITER_LIMIT` in wire_gpu.rs. +const MITER_LIMIT: f32 = 8.0; struct VertexOut { @builtin(position) clip_pos: vec4, @@ -144,7 +146,7 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32, in: VertexIn) -> VertexOut let seg_pos_b = vec3(seg.pos_b_x, seg.pos_b_y, seg.pos_b_z); let seg_pos_b_low = vec3(seg.pos_b_low_x, seg.pos_b_low_y, seg.pos_b_low_z); let seg_distances = vec2(seg.distance_a, seg.distance_b); - let seg_taper_ratio = unpack2x16unorm(seg.taper_ratio); + let seg_taper_ratio = unpack2x16snorm(seg.taper_ratio); let which_end_arr = array(0.0, 1.0, 1.0, 0.0, 1.0, 0.0); let side_arr = array(-1.0, -1.0, 1.0, -1.0, 1.0, 1.0); let which_end = which_end_arr[corner]; @@ -192,13 +194,17 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32, in: VertexIn) -> VertexOut } perp_world = normalize(perp_world); + // A constant-width band slides each corner along the segment onto + // its joint's bisector (miter), so neighbours meet without a notch. + let miter = select(seg_taper_ratio, vec2(0.0), is_tapered) * MITER_LIMIT; + let along = mix(miter.x, miter.y, which_end) * eff_hw * side; let pos_rel = mix(rel_a, rel_b, which_end); - let world_pos = pos_rel + perp_world * (eff_hw * side); + let world_pos = pos_rel + perp_world * (eff_hw * side) + world_dir * along; var clip_pos = u.view_rot * vec4(world_pos, 1.0); clip_pos = apply_draw_order(clip_pos, in.depth.x); final_clip = clip_pos; - out_dist = mix(seg_distances.x, seg_distances.y, which_end); + out_dist = mix(seg_distances.x, seg_distances.y, which_end) + along * (seg_distances.y - seg_distances.x) / max(world_len, 1e-6); out_cap = vec2(which_end * world_len, eff_hw * side); out_cap_ends = vec3(world_len, world_hw_a, world_hw_b); } else { diff --git a/src/shaders/wire.wgsl b/src/shaders/wire.wgsl index 69eefa06b..4d312896f 100644 --- a/src/shaders/wire.wgsl +++ b/src/shaders/wire.wgsl @@ -69,6 +69,8 @@ struct InstanceIn { const MODEL_LINEWEIGHT_BOOST: f32 = 2.0; const MODEL_LINEWEIGHT_MAX_PX: f32 = 10.0; +// Must match `MITER_LIMIT` in wire_gpu.rs. +const MITER_LIMIT: f32 = 8.0; struct VertexOut { @builtin(position) clip_pos: vec4, @@ -191,13 +193,17 @@ fn marker_relative(position_high: vec3, position_low: vec3, instance: } perp_world = normalize(perp_world); + // A constant-width band slides each corner along the segment onto + // its joint's bisector (miter), so neighbours meet without a notch. + let miter = vec2(in.marker_origin_high.w, in.marker_origin_low.w) * MITER_LIMIT; + let along = mix(miter.x, miter.y, which_end) * eff_hw * side; let pos_rel = mix(rel_a, rel_b, which_end); - let world_pos = pos_rel + perp_world * (eff_hw * side); + let world_pos = pos_rel + perp_world * (eff_hw * side) + world_dir * along; var clip_pos = u.view_rot * vec4(world_pos, 1.0); clip_pos = apply_draw_order(clip_pos, in.misc.x); final_clip = clip_pos; - out_dist = mix(in.dists.x, in.dists.y, which_end); + out_dist = mix(in.dists.x, in.dists.y, which_end) + along * (in.dists.y - in.dists.x) / max(world_len, 1e-6); out_cap = vec2(which_end * world_len, eff_hw * side); out_cap_ends = vec3(world_len, world_hw_a, world_hw_b); } else { diff --git a/src/shaders/wire_indexed.wgsl b/src/shaders/wire_indexed.wgsl index 9344b199a..680e0bcd4 100644 --- a/src/shaders/wire_indexed.wgsl +++ b/src/shaders/wire_indexed.wgsl @@ -55,6 +55,8 @@ struct InstanceIn { const MODEL_LINEWEIGHT_BOOST: f32 = 2.0; const MODEL_LINEWEIGHT_MAX_PX: f32 = 10.0; +// Must match `MITER_LIMIT` in wire_gpu.rs. +const MITER_LIMIT: f32 = 8.0; struct VertexOut { @builtin(position) clip_pos: vec4, @@ -168,13 +170,17 @@ fn marker_relative(position_high: vec3, position_low: vec3, c: WireCon } perp_world = normalize(perp_world); + // A constant-width band slides each corner along the segment onto + // its joint's bisector (miter), so neighbours meet without a notch. + let miter = select(in.taper_ratio, vec2(0.0), is_tapered) * MITER_LIMIT; + let along = mix(miter.x, miter.y, which_end) * eff_hw * side; let pos_rel = mix(rel_a, rel_b, which_end); - let world_pos = pos_rel + perp_world * (eff_hw * side); + let world_pos = pos_rel + perp_world * (eff_hw * side) + world_dir * along; var clip_pos = u.view_rot * vec4(world_pos, 1.0); clip_pos = apply_draw_order(clip_pos, c.draw_depth); final_clip = clip_pos; - out_dist = mix(in.distance_a, in.distance_b, which_end); + out_dist = mix(in.distance_a, in.distance_b, which_end) + along * (in.distance_b - in.distance_a) / max(world_len, 1e-6); out_cap = vec2(which_end * world_len, eff_hw * side); out_cap_ends = vec3(world_len, world_hw_a, world_hw_b); } else { diff --git a/src/snap.rs b/src/snap.rs index 39917f1a6..94e10234a 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -1346,10 +1346,17 @@ impl Snapper { const MAX_PAIRWISE_POINTS: usize = 3_000; let mut in_range_pts = 0usize; let mut in_range_wires = InRangeWires::new(); + // POINT wires offer only their Node; their glyph strokes stay out of + // every geometric snap below. + let mut in_range_nodes: Vec<&WireModel> = Vec::new(); let unindexed = wires.segments().is_none(); if unindexed { for w in wires.iter() { - if !w.is_display_only() && wire_in_range(w) { + if w.is_node_marker() { + if wire_in_range(w) { + in_range_nodes.push(w); + } + } else if !w.is_display_only() && wire_in_range(w) { in_range_pts += w.points.len(); in_range_wires.push(w); } @@ -1361,7 +1368,11 @@ impl Snapper { // Early out: if no unindexed wires overlap the cursor aperture and // no persistent tracking points exist, discrete/continuous object snaps // cannot hit anything. Avoid running all subsequent pass setups and loops. - if unindexed && in_range_wires.is_empty() && self.tracking_points.is_empty() { + if unindexed + && in_range_wires.is_empty() + && in_range_nodes.is_empty() + && self.tracking_points.is_empty() + { return best; } @@ -1460,7 +1471,7 @@ impl Snapper { try_snap_hint(world, hint, wire_source(wire)); } } else { - for wire in in_range_wires.iter() { + for wire in in_range_wires.iter().chain(in_range_nodes.iter().copied()) { let src = wire_source(wire); for &(world, hint) in &wire.snap_pts { try_snap_hint(world, hint, src); @@ -1489,7 +1500,10 @@ impl Snapper { // Tessellated open curves have no key-vertex set. Their only // endpoints are first/last, so testing candidate wires remains // O(local wires), independent of tessellation density. - for wire in wires.iter().filter(|wire| wire.key_vertices.is_empty()) { + for wire in wires + .iter() + .filter(|wire| wire.key_vertices.is_empty() && !wire.is_node_marker()) + { let closed = wire .snap_pts .iter() @@ -1516,7 +1530,7 @@ impl Snapper { for &point in &wire.key_vertices { try_pt(DVec3::from_array(point), SnapType::Endpoint, src, None); } - } else { + } else if !wire.is_node_marker() { let closed = wire .snap_pts .iter() diff --git a/src/ui/ribbon/draw_panel.rs b/src/ui/ribbon/draw_panel.rs index fbebf55ee..d76bba909 100644 --- a/src/ui/ribbon/draw_panel.rs +++ b/src/ui/ribbon/draw_panel.rs @@ -207,22 +207,52 @@ pub(super) fn parent_panel(id: &str) -> Option<&'static str> { panel_for_dropdown(id).map(|panel| panel.id) } -pub(super) fn group_title<'a>( - title: &'static str, - group_ids: &[&'static str], - open: &Option, -) -> Element<'a, Message> { - let Some(panel) = PANELS.iter().copied().find(|panel| { +/// The slide-out under the title of the ribbon group `title`, whose items +/// are `group_ids`. +fn panel_for_group(title: &str, group_ids: &[&'static str]) -> Option { + PANELS.iter().copied().find(|panel| { panel.title == title && !panel.tools.is_empty() && panel.anchor.is_none_or(|anchor| group_ids.contains(&anchor)) }) - else { +} + +/// Every command the slide-out of the group `title` offers: each tool and +/// each of its submenu options. +pub(super) fn slide_out_commands( + title: &str, + group_ids: &[&'static str], +) -> impl Iterator { + panel_for_group(title, group_ids) + .into_iter() + .flat_map(|panel| panel.tools) + .flat_map(|tool| { + std::iter::once(tool.command).chain(tool.options.iter().map(|(cmd, _)| *cmd)) + }) +} + +/// A slide-out tool is lit while it or one of its submenu options runs. +fn tool_is_active(tool: &Tool, active_tool: Option<&str>) -> bool { + active_tool.is_some_and(|active| { + tool.command == active || tool.options.iter().any(|(cmd, _)| *cmd == active) + }) +} + +pub(super) fn group_title<'a>( + title: &'static str, + group_ids: &[&'static str], + open: &Option, + active_tool: Option<&str>, +) -> Element<'a, Message> { + let Some(panel) = panel_for_group(title, group_ids) else { return container(text(t!(title)).size(9).style(muted_text_style)) .padding([1, 4]) .into(); }; let expanded = open.as_deref().and_then(parent_panel) == Some(panel.id); + // A command running from the closed slide-out lights its title, the way a + // dropdown lights while one of its items runs. + let lit = expanded || panel.tools.iter().any(|tool| tool_is_active(tool, active_tool)); let arrow = if expanded { icons::themed_arrow_up(GROUP_TITLE_ARROW_SIZE) } else { @@ -239,7 +269,7 @@ pub(super) fn group_title<'a>( .align_y(iced::Center), ) .on_press(Message::ToggleRibbonDropdown(panel.id.to_string())) - .style(move |theme: &Theme, status| tool_btn_style(theme, expanded, status)) + .style(move |theme: &Theme, status| tool_btn_style(theme, lit, status)) .padding([1, 4]), ) .into(); @@ -421,7 +451,7 @@ pub(super) fn overlay<'a>(ribbon: &Ribbon, id: &str, win: (f32, f32)) -> Element .map(|tool| { tool_button( tool, - ribbon.active_tool.as_deref() == Some(tool.command), + tool_is_active(tool, ribbon.active_tool.as_deref()), panel.id, ) }) diff --git a/src/ui/ribbon/locate.rs b/src/ui/ribbon/locate.rs new file mode 100644 index 000000000..e4dd32c35 --- /dev/null +++ b/src/ui/ribbon/locate.rs @@ -0,0 +1,283 @@ +//! Where the ribbon offers a command, so a command started from the command +//! line, an alias, a shortcut or Repeat lights the same button a click on it +//! would. Seeing which button runs a typed command is how its place in the +//! ribbon is learned. + +use crate::modules::{ModuleEvent, RibbonGroup, RibbonItem, ToolDef}; + +use super::{draw_panel, item_id, Ribbon}; + +/// One button of a group: the id its highlight is keyed on, and the command +/// line it dispatches (`None` for a button that raises a non-command event). +type Entry<'a> = (&'static str, Option<&'a str>); + +fn tool_entry(t: &ToolDef) -> Entry<'_> { + match &t.event { + ModuleEvent::Command(cmd) => (t.id, Some(cmd.as_str())), + _ => (t.id, None), + } +} + +/// The buttons one ribbon item shows: itself, the items of a dropdown, or the +/// small tools under a combo. A dropdown item is keyed on the command it +/// dispatches, which is what the dropdown's highlight looks for. +fn item_entries(item: &RibbonItem) -> Vec> { + match item { + RibbonItem::Tool(t) | RibbonItem::LabeledTool(t) | RibbonItem::LargeTool(t) => { + vec![tool_entry(t)] + } + RibbonItem::PropertiesGroup { match_prop } => vec![tool_entry(match_prop)], + RibbonItem::Dropdown { items, .. } + | RibbonItem::LabeledDropdown { items, .. } + | RibbonItem::LargeDropdown { items, .. } => { + items.iter().map(|(cmd, _, _)| (*cmd, Some(*cmd))).collect() + } + RibbonItem::ToolGrid { columns } | RibbonItem::StyleComboGroup { rows: columns, .. } => { + columns.iter().flatten().map(tool_entry).collect() + } + RibbonItem::LayerComboGroup { row2, row3 } => { + row2.iter().chain(row3).map(tool_entry).collect() + } + } +} + +/// Every button `group` offers: its items' buttons and the tools of its title +/// slide-out, keyed like dropdown items on the command they dispatch. +pub(super) fn group_entries(group: &RibbonGroup) -> Vec> { + let mut out: Vec<_> = group.tools.iter().flat_map(item_entries).collect(); + let ids: Vec<_> = group.tools.iter().filter_map(item_id).collect(); + out.extend(draw_panel::slide_out_commands(group.title, &ids).map(|cmd| (cmd, Some(cmd)))); + out +} + +/// Whether `item` shows the button keyed `id`, lit or as the item a lit +/// dropdown holds. +pub(super) fn item_holds(item: &RibbonItem, id: &str) -> bool { + item_entries(item).iter().any(|(entry, _)| *entry == id) +} + +/// Whether `group` holds the button keyed `id`. +pub(super) fn group_holds(group: &RibbonGroup, id: &str) -> bool { + group_entries(group).iter().any(|(entry, _)| *entry == id) +} + +impl Ribbon { + /// Every button of every tab, with the index of the tab it is on. The + /// tab on show comes first, so a command offered on two tabs lights the + /// button the user can already see. + fn entries(&self) -> impl Iterator)> { + let order = std::iter::once(self.active) + .chain((0..self.modules.len()).filter(move |i| *i != self.active)); + order + .filter_map(|i| self.modules.get(i).map(|m| (i, m))) + .flat_map(|(i, module)| { + module + .ribbon_groups() + .iter() + .flat_map(group_entries) + .map(move |entry| (i, entry)) + }) + } + + /// The button id that runs the command line `cmd`: an exact match first, + /// then a button that runs its bare verb (`OFFSET 5` → Offset). + pub(super) fn tool_for_command(&self, cmd: &str) -> Option<&'static str> { + let cmd = cmd.trim(); + let verb = cmd.split_whitespace().next().unwrap_or(cmd); + [cmd, verb].into_iter().find_map(|want| { + self.entries() + .find(|(_, (_, runs))| runs.is_some_and(|runs| runs.eq_ignore_ascii_case(want))) + .map(|(_, (id, _))| id) + }) + } + + /// Light the ribbon button of the command line `cmd` that is starting, + /// however it was started. A button the user just clicked to run `cmd` + /// stays lit, so a command offered on two tabs keeps the clicked one; a + /// command with no button clears the previous command's highlight. + pub fn show_command(&mut self, cmd: &str) { + let clicked = self.active_tool.as_deref().is_some_and(|current| { + self.entries().any(|(_, (id, runs))| { + id == current && runs.is_some_and(|runs| runs.eq_ignore_ascii_case(cmd.trim())) + }) + }); + if !clicked { + self.active_tool = self.tool_for_command(cmd).map(str::to_string); + } + } + + /// The tab holding the lit button, when it is not the tab on show: its + /// header lights instead, pointing at where the button lives. The Layout + /// module has no tab (its tools are on the side toolbar). + pub(super) fn tab_holding_active_tool(&self) -> Option { + let id = self.active_tool.as_deref()?; + let holds = |i: usize| { + self.modules[i] + .ribbon_groups() + .iter() + .any(|group| group_holds(group, id)) + }; + if self + .modules + .get(self.active) + .is_some_and(|_| holds(self.active)) + { + return None; + } + (0..self.modules.len()).find(|i| self.modules[*i].id() != "layout" && holds(*i)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::modules::{CadModule, IconKind}; + + struct TestTab { + id: &'static str, + groups: Vec, + } + + impl CadModule for TestTab { + fn id(&self) -> &'static str { + self.id + } + fn title(&self) -> &'static str { + self.id + } + fn ribbon_groups(&self) -> &[RibbonGroup] { + &self.groups + } + } + + fn tool(id: &'static str, cmd: &str) -> ToolDef { + ToolDef { + id, + label: id, + icon: IconKind::Glyph("x"), + event: ModuleEvent::Command(cmd.to_string()), + } + } + + /// Home: OFFSET (button id differs from its command), a CIRCLE dropdown, + /// LAYERS (a non-command event). View: ZOOM EXTENTS and a second OFFSET. + fn ribbon() -> Ribbon { + let mut layers = tool("LAYERS", ""); + layers.event = ModuleEvent::ToggleLayers; + let home = TestTab { + id: "home", + groups: vec![RibbonGroup { + title: "Modify", + tools: vec![ + RibbonItem::LargeTool(tool("OFFSET_TOOL", "OFFSET")), + RibbonItem::Dropdown { + id: "circle_dd", + icon: IconKind::Glyph("o"), + items: vec![ + ("CIRCLE", "Center, Radius", IconKind::Glyph("o")), + ("CIRCLE_2P", "2-Point", IconKind::Glyph("o")), + ], + default: "CIRCLE", + }, + RibbonItem::Tool(layers), + ], + }], + }; + let view = TestTab { + id: "view", + groups: vec![RibbonGroup { + title: "Navigate", + tools: vec![ + RibbonItem::Tool(tool("ZOOM_EXTENTS", "ZOOM EXTENTS")), + RibbonItem::Tool(tool("OFFSET_VIEW", "OFFSET")), + ], + }], + }; + let mut ribbon = Ribbon::default(); + ribbon.set_modules(vec![Box::new(home), Box::new(view)]); + ribbon + } + + #[test] + fn a_command_lights_the_button_that_runs_it_not_one_named_like_it() { + let mut r = ribbon(); + r.show_command("OFFSET"); + assert_eq!(r.active_tool.as_deref(), Some("OFFSET_TOOL")); + assert_eq!(r.tab_holding_active_tool(), None); + } + + #[test] + fn a_dropdown_item_is_keyed_on_its_command_so_the_dropdown_lights() { + let mut r = ribbon(); + r.show_command("circle_2p"); + assert_eq!(r.active_tool.as_deref(), Some("CIRCLE_2P")); + } + + #[test] + fn a_button_on_another_tab_lights_that_tab() { + let mut r = ribbon(); + r.show_command("ZOOM EXTENTS"); + assert_eq!(r.active_tool.as_deref(), Some("ZOOM_EXTENTS")); + assert_eq!(r.tab_holding_active_tool(), Some(1)); + r.select(1); + assert_eq!(r.tab_holding_active_tool(), None); + } + + #[test] + fn a_command_on_two_tabs_prefers_the_tab_on_show_then_the_clicked_one() { + let mut r = ribbon(); + r.select(1); + r.show_command("OFFSET"); + assert_eq!(r.active_tool.as_deref(), Some("OFFSET_VIEW")); + // A clicked button keeps its highlight when its command dispatches, + // even though another button for the same command comes first. + r.select(0); + r.activate_tool("OFFSET_VIEW"); + r.show_command("OFFSET"); + assert_eq!(r.active_tool.as_deref(), Some("OFFSET_VIEW")); + } + + #[test] + fn arguments_fall_back_to_the_bare_verb() { + let mut r = ribbon(); + r.show_command("OFFSET 5"); + assert_eq!(r.active_tool.as_deref(), Some("OFFSET_TOOL")); + } + + #[test] + fn a_command_without_a_button_clears_the_previous_highlight() { + let mut r = ribbon(); + r.show_command("OFFSET"); + r.show_command("DIST"); + assert_eq!(r.active_tool, None); + assert_eq!(r.tab_holding_active_tool(), None); + // A non-command button has no command line to match. + r.show_command("LAYERS"); + assert_eq!(r.active_tool, None); + } + + #[test] + fn slide_out_tools_and_their_options_belong_to_their_group() { + // The Draw group's title slide-out holds SPLINE; the Modify one holds + // the draw-order submenu. + let draw = RibbonGroup { + title: "Draw", + tools: vec![], + }; + assert!(group_holds(&draw, "SPLINE")); + let modify = RibbonGroup { + title: "Modify", + tools: vec![], + }; + assert!(group_holds(&modify, "DRAWORDER_BACK")); + } + + #[test] + fn the_shipped_ribbon_resolves_its_own_commands() { + let mut r = Ribbon::default(); + for cmd in ["LINE", "OFFSET", "SPLINE", "ZOOM EXTENTS"] { + r.show_command(cmd); + assert!(r.active_tool.is_some(), "{cmd} has no ribbon button"); + } + } +} diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index d59bac1c6..f4f7d78f6 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -30,6 +30,7 @@ pub use context_tools::{pdf_underlay_tools, point_cloud_tools, xref_tools, Under use widgets::{StyleContext, *}; pub(crate) use widgets::{REDO_HISTORY_ID, UNDO_HISTORY_ID}; mod collapse; +mod locate; use collapse::{CollapsePanels, Panel}; pub use collapse::CollapseMode; use crate::ui::wrap_bar::{PosReport, WrapBar, WrapFlow}; @@ -306,6 +307,10 @@ impl Ribbon { pub fn activate_tool(&mut self, id: &str) { self.active_tool = Some(id.to_string()); } + #[cfg(test)] + pub(crate) fn active_tool(&self) -> Option<&str> { + self.active_tool.as_deref() + } pub fn deactivate_tool(&mut self) { self.active_tool = None; } @@ -523,6 +528,10 @@ impl Ribbon { // The quick-access flow and the tabs flow each flex-wrap; WrapBar stacks // them so a wrapped tab never shares a row with a quick-access button. + // A running command whose button is on another tab tints that tab's + // header with a faint wash of the button's blue, pointing at where the + // button lives without competing with the selected tab's outline. + let holding_tab = self.tab_holding_active_tool(); let tab_items = self.modules.iter().enumerate().fold( Vec::>::new(), |mut acc, (i, module)| { @@ -534,6 +543,7 @@ impl Ribbon { let is_active = i == self.active; let is_contextual = module.id() == "layout"; + let holds_tool = holding_tab == Some(i); let btn = container( button(text(crate::i18n::ribbon_module_title(module.id(), module.title())).size(12)) .on_press(Message::RibbonSelectTab(i)) @@ -546,6 +556,7 @@ impl Ribbon { }; let pair = match (is_active, status) { (true, _) => palette.background.weakest, + (false, _) if holds_tool => palette.primary.weak, (false, button::Status::Hovered) => { if is_contextual { palette.warning.weak @@ -557,10 +568,17 @@ impl Ribbon { }; button::Style { background: (is_active + || holds_tool || matches!(status, button::Status::Hovered)) - .then_some(Background::Color(pair.color)), + .then_some(Background::Color(if holds_tool && !is_active { + pair.color.scale_alpha(0.35) + } else { + pair.color + })), text_color: if is_active { pair.text + } else if holds_tool { + palette.background.base.text } else if is_contextual { accent.color } else { @@ -1421,6 +1439,7 @@ fn render_group<'a>( group.title, &group.tools.iter().filter_map(item_id).collect::>(), open_dd, + active_tool.as_deref(), ), ] .align_x(iced::Center) @@ -1502,6 +1521,19 @@ fn collapse_button<'a>( ) -> Element<'a, Message> { let title = group.title; let localized_title = t!(title).into_owned(); + // A collapsed panel lights its opener while one of its hidden tools runs, + // the way a dropdown lights while one of its items does. + let active = active_tool.as_deref(); + let holds = active.is_some_and(|id| locate::group_holds(group, id)); + let opener_style = move |lit: bool| { + move |theme: &Theme, status| { + if lit { + tool_btn_style(theme, true, status) + } else { + button::subtle(theme, status) + } + } + }; // Tightest form: one button = the panel's FIRST tool icon + its title + ▾. // Clicking opens the flyout listing every tool; no tool runs directly at this @@ -1532,7 +1564,7 @@ fn collapse_button<'a>( .width(Fill), ) .on_press(Message::ToggleRibbonPanel(title.to_string())) - .style(button::subtle) + .style(opener_style(holds)) .width(Fill) .padding([3, 5]); return automatic_large_button(localized_title, content.into()); @@ -1542,6 +1574,8 @@ fn collapse_button<'a>( // that runs the last-used tool — above a title + ▾ opener for the full flyout. // For a Properties panel the representative is its Match button. let rep = representative(group, last_used); + // The face lights itself when it is the running tool. + let face_lit = rep.zip(active).is_some_and(|(item, id)| locate::item_holds(item, id)); let face: Element<'_, Message> = match rep { Some(RibbonItem::PropertiesGroup { match_prop }) => { render_large( @@ -1597,7 +1631,7 @@ fn collapse_button<'a>( .align_y(iced::Center), ) .on_press(Message::ToggleRibbonPanel(title.to_string())) - .style(button::subtle) + .style(opener_style(holds && !face_lit)) .width(Fill) .padding([1, 4]); let opener = automatic_large_button(localized_title, opener.into()); diff --git a/src/ui/window/field_dialog.rs b/src/ui/window/field_dialog.rs index 54dbece83..17dce8304 100644 --- a/src/ui/window/field_dialog.rs +++ b/src/ui/window/field_dialog.rs @@ -32,7 +32,7 @@ pub const CATEGORIES: &[(&str, &[&str])] = &[ &["Author", "Comments", "Filename", "Filesize", "HyperlinkBase", "Keywords", "LastSavedBy", "Subject", "Title"], ), ("Linked", &["Hyperlink"]), - ("Objects", &["Count", "CountInArea", "Formula", "NamedObject", "Object"]), + ("Objects", &["BlockPlaceholder", "Count", "CountInArea", "Formula", "NamedObject", "Object"]), ("Other", &["DieselExpression", "SystemVariable"]), ( "Plot", @@ -79,6 +79,7 @@ pub enum FieldKind { PlotScale, /// Count / CountInArea: a JSON expression. Count, + BlockPlaceholder, } /// Formula precisions: the current one, then 0 to 8 decimals. @@ -98,6 +99,7 @@ pub fn kind_of(name: &str) -> FieldKind { "Hyperlink" => FieldKind::Hyperlink, "PlotScale" => FieldKind::PlotScale, "Count" | "CountInArea" => FieldKind::Count, + "BlockPlaceholder" => FieldKind::BlockPlaceholder, _ => FieldKind::Text, } } @@ -133,6 +135,11 @@ pub struct FieldDialogState { pub plot_scale: usize, /// The JSON expression of a Count / CountInArea field. pub count_expression: String, + /// The block being edited in the block editor; block placeholders are + /// only offered there. + pub placeholder_block: Option, + /// Index into codec BLOCK_PLACEHOLDER_PROPERTIES. + pub placeholder_property: usize, pub preview: String, /// Today in each of DATE_FORMATS, for the Examples list. pub examples: Vec, @@ -164,6 +171,8 @@ impl FieldDialogState { hyperlink_url: String::new(), plot_scale: 0, count_expression: String::new(), + placeholder_block: None, + placeholder_property: 7, preview: String::new(), examples: Vec::new(), } @@ -201,10 +210,11 @@ impl FieldDialogState { 0 => String::new(), n => format!("%pr{}", n - 1), }; - let body = format!("\\AcExpr ({})", self.formula.trim()); + let (formula, objects) = indexed_object_ids(self.formula.trim()); + let body = format!("\\AcExpr ({formula})"); match format!("{format}{precision}") { - f if f.is_empty() => (body, vec![]), - f => (format!("{body} \\f \"{f}\""), vec![]), + f if f.is_empty() => (body, objects), + f => (format!("{body} \\f \"{f}\""), objects), } } FieldKind::Hyperlink => ( @@ -224,6 +234,21 @@ impl FieldDialogState { json => (format!("\\{evaluator} {json}"), vec![]), } } + FieldKind::BlockPlaceholder => { + let (_, property, format) = codec::fields::BLOCK_PLACEHOLDER_PROPERTIES[self.placeholder_property]; + // Text properties take the chosen case; the others keep their own format. + let format = match (format, self.text_case) { + ("%tc4", 0) => String::new(), + ("%tc4", n) => format!("%tc{n}"), + (other, _) => other.to_string(), + }; + let code = format!("\\AcObjProp.16.2 Object(?BlockRefId,1).{property}"); + if format.is_empty() { + (code, vec![]) + } else { + (format!("{code} \\f \"{format}\""), vec![]) + } + } FieldKind::SystemVariable => (format!("\\AcVar {}", self.sysvar), vec![]), FieldKind::Diesel => (format!("\\AcDiesel {}", self.diesel), vec![]), FieldKind::NamedObject => match self.named.and_then(|i| self.named_names.get(i)) { @@ -248,11 +273,35 @@ impl FieldDialogState { FieldKind::Diesel => !self.diesel.trim().is_empty(), FieldKind::Formula => !self.formula.trim().is_empty(), FieldKind::Hyperlink => !self.hyperlink_url.trim().is_empty(), + FieldKind::BlockPlaceholder => self.placeholder_block.is_some(), _ => true, } } } +/// Table references in a formula are typed as `%<\_ObjId HANDLE>%`; the stored +/// code numbers them (`%<\_ObjIdx n>%`) and lists the objects. +fn indexed_object_ids(formula: &str) -> (String, Vec) { + let mut out = String::new(); + let mut objects: Vec = Vec::new(); + let mut rest = formula; + while let Some(start) = rest.find("%<\\_ObjId ") { + let after = &rest[start + "%<\\_ObjId ".len()..]; + let Some(end) = after.find(">%") else { break }; + let Ok(value) = u64::from_str_radix(after[..end].trim(), 16) else { break }; + let handle = codec::Handle::new(value); + let index = objects.iter().position(|h| *h == handle).unwrap_or_else(|| { + objects.push(handle); + objects.len() - 1 + }); + out.push_str(&rest[..start]); + out.push_str(&format!("%<\\_ObjIdx {index}>%")); + rest = &after[end + 2..]; + } + out.push_str(rest); + (out, objects) +} + /// The geometric properties a field can show for an object of this type. pub fn object_properties(entity: &codec::EntityType) -> Vec<&'static str> { use codec::EntityType as E; @@ -287,6 +336,9 @@ pub enum FieldDialogMsg { FormulaFormat(usize), FormulaPrecision(usize), Evaluate, + /// Sum / Average / Count of a cell range, or one Cell, picked in a table. + TableFunction(&'static str), + PlaceholderProperty(usize), HyperlinkText(String), HyperlinkUrl(String), BrowseHyperlink, @@ -349,6 +401,14 @@ impl IntoContainer for text_input::Style { } } +fn table_button<'a>(label: String, function: &'static str) -> Element<'a, Message> { + button(text(label).size(12)) + .on_press(msg(FieldDialogMsg::TableFunction(function))) + .style(button_style(false)) + .padding([5, 10]) + .into() +} + fn case_list<'a>(state: &FieldDialogState) -> Element<'a, Message> { list( TEXT_CASES @@ -544,6 +604,36 @@ fn format_panel<'a>(state: &'a FieldDialogState) -> Element<'a, Message> { ] .spacing(8) .into(), + FieldKind::BlockPlaceholder => match &state.placeholder_block { + None => column![text(t!("Only accessible in the block editor")).size(12).style(muted_style)].into(), + Some(block) => { + let (_, _, format) = codec::fields::BLOCK_PLACEHOLDER_PROPERTIES[state.placeholder_property]; + let mut panel = column![ + row![ + text(t!("Block name")).size(12).width(Length::Fixed(110.0)), + container(text(block.clone()).size(12).style(muted_style)).padding([5, 8]).width(Fill), + ] + .spacing(8) + .align_y(iced::Center), + text(t!("Block reference property")).size(12), + list( + codec::fields::BLOCK_PLACEHOLDER_PROPERTIES + .iter() + .enumerate() + .map(|(i, (label, _, _))| { + (t!(*label).into_owned(), state.placeholder_property == i, FieldDialogMsg::PlaceholderProperty(i)) + }) + .collect(), + 150.0, + ), + ] + .spacing(8); + if format == "%tc4" { + panel = panel.push(text(t!("Format")).size(12)).push(case_list(state)); + } + panel.into() + } + }, FieldKind::PlotScale => column![ text(t!("Format")).size(12), list( @@ -633,10 +723,18 @@ pub fn view<'a>( .font(iced::Font::MONOSPACE) .style(field_style) .on_input(|v| msg(FieldDialogMsg::Formula(v))), - button(text(t!("Evaluate")).size(12)) - .on_press(msg(FieldDialogMsg::Evaluate)) - .style(button_style(false)) - .padding([5, 14]), + row![ + table_button(t!("Average").into_owned(), "Average"), + table_button(t!("Sum").into_owned(), "Sum"), + table_button(t!("Count").into_owned(), "Count"), + table_button(t!("Cell").into_owned(), "Cell"), + Space::new().width(Fill), + button(text(t!("Evaluate")).size(12)) + .on_press(msg(FieldDialogMsg::Evaluate)) + .style(button_style(false)) + .padding([5, 14]), + ] + .spacing(6), ] .spacing(8), ), @@ -672,6 +770,7 @@ pub fn view<'a>( preview, ] .spacing(10), + FieldKind::BlockPlaceholder => column![card(t!("Block placeholder").into_owned(), format_panel(state)), preview].spacing(10), _ => column![card(t!("Format").into_owned(), format_panel(state)), preview].spacing(10), }; let (code, _) = state.code();