Merge remote-tracking branch 'origin/main' into codex/sheet-set-manager
# Conflicts: # Cargo.lock # src/app/control/actions.rs # src/ui/window/field_dialog.rs
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
]'
|
||||
@@ -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.
|
||||
|
||||
@@ -4,7 +4,7 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "OpenCADStudio"
|
||||
version = "2026.39.0"
|
||||
version = "2026.40.0"
|
||||
dependencies = [
|
||||
"allsorts",
|
||||
"ashpd",
|
||||
@@ -4228,7 +4228,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",
|
||||
@@ -4237,7 +4237,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",
|
||||
@@ -4248,7 +4248,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",
|
||||
]
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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"
|
||||
@@ -162,6 +162,10 @@ fn generate_type_registry(out_dir: &Path) {
|
||||
"AssocConstraintNodeData",
|
||||
trace_simple::<codec::objects::AssocConstraintNodeData>,
|
||||
),
|
||||
(
|
||||
"AssocCurveValue",
|
||||
trace_simple::<codec::objects::AssocCurveValue>,
|
||||
),
|
||||
(
|
||||
"AssocEvalValue",
|
||||
trace_simple::<codec::objects::AssocEvalValue>,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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).
|
||||
@@ -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
|
||||
|
||||
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 38 KiB |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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<codec::Handle> = 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!(
|
||||
|
||||
@@ -200,6 +200,7 @@ impl OpenCADStudio {
|
||||
pub(super) fn handle_commit_and_exit(
|
||||
&mut self,
|
||||
entity: codec::EntityType,
|
||||
preserve_commit_layer: bool,
|
||||
) -> Option<Task<Message>> {
|
||||
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<codec::entities::AttributeDefinition> = {
|
||||
let mut attdefs: Vec<codec::entities::AttributeDefinition> = {
|
||||
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]
|
||||
|
||||
@@ -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<Handle> = 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<String> {
|
||||
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}\""), &[]))
|
||||
@@ -69,7 +126,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;
|
||||
}
|
||||
@@ -170,6 +237,18 @@ impl OpenCADStudio {
|
||||
);
|
||||
}
|
||||
FieldDialogMsg::PlotScale(k) => state.plot_scale = k,
|
||||
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;
|
||||
@@ -241,3 +320,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::<String>()).unwrap_or_default()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
_ => value.to_string(),
|
||||
}
|
||||
}
|
||||
@@ -93,7 +93,26 @@ impl OpenCADStudio {
|
||||
}
|
||||
|
||||
pub(super) fn dispatch_command(&mut self, cmd: &str) -> Task<Message> {
|
||||
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<Message> {
|
||||
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<Message> {
|
||||
@@ -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
|
||||
@@ -1084,3 +1112,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);
|
||||
}
|
||||
}
|
||||
@@ -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::<i16>() {
|
||||
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)]
|
||||
|
||||
@@ -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<Task<Message>, 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();
|
||||
@@ -1023,6 +1137,13 @@ impl OpenCADStudio {
|
||||
"ss_custom" => F::SsCustom(v.into()),
|
||||
"ss_placeholder" => F::SsPlaceholder(index(fd::PLACEHOLDER_TYPES.len())?),
|
||||
"ss_scale" => F::SsScaleFormat(index(codec::fields::PLOT_SCALE_FORMATS.len())?),
|
||||
"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")),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -528,6 +528,7 @@ impl OpenCADStudio {
|
||||
Task::none(),
|
||||
);
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if op == "tools" {
|
||||
return (
|
||||
json!({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`).
|
||||
@@ -4146,6 +4148,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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -774,6 +775,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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
@@ -750,7 +751,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;
|
||||
@@ -3768,7 +3770,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<Vec<crate::io::pdf_export::PdfPageInput>, String> {
|
||||
self.stamp_plot_fields();
|
||||
let available = self.tabs[self.active_tab].scene.layout_names();
|
||||
let selected: Vec<String> = self
|
||||
.print_all_layouts
|
||||
@@ -5341,6 +5356,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.
|
||||
|
||||
@@ -4197,7 +4197,7 @@ impl OpenCADStudio {
|
||||
Task::none()
|
||||
}
|
||||
Message::ToggleDynInput => {
|
||||
self.dyn_input ^= true;
|
||||
self.set_dyn_input(!self.dyn_input);
|
||||
Task::none()
|
||||
}
|
||||
Message::ToggleViewCube => {
|
||||
|
||||
@@ -378,3 +378,68 @@ fn display_name() -> Option<String> {
|
||||
#[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<Handle> {
|
||||
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<Handle> {
|
||||
// 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<String> {
|
||||
self.0.login()
|
||||
}
|
||||
fn getvar(&self, name: &str) -> Option<String> {
|
||||
self.0.getvar(name)
|
||||
}
|
||||
fn file_size(&self) -> Option<u64> {
|
||||
self.0.file_size()
|
||||
}
|
||||
fn date_locale(&self) -> codec::fields::DateLocale {
|
||||
self.0.date_locale()
|
||||
}
|
||||
fn getenv(&self, name: &str) -> Option<String> {
|
||||
self.0.getenv(name)
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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![],
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1036,6 +1036,7 @@ fn finalize_loaded_outcome(
|
||||
) -> Result<codec::ReadOutcome, String> {
|
||||
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<Work> = 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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<iced::widget::image::Handle> {
|
||||
let img = dwg_thumbnailer::extract(path, MAX_DIM)?;
|
||||
let (w, h) = (img.width(), img.height());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -331,6 +331,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")),
|
||||
@@ -2139,6 +2145,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")),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -189,4 +189,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 <function> <table> <x> <y> <z> [<table> <x> <y> <z>]`.
|
||||
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"] });
|
||||
@@ -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<Handle> = {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -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<EntityT
|
||||
// distance for every supported entity type.
|
||||
|
||||
fn perp_distance(entity: &EntityType, pt: Vec3) -> 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<Vec3> {
|
||||
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<EntityType>,
|
||||
/// Pre-selected offsettable objects (pick-first, #422); consumed when the
|
||||
/// distance step resolves.
|
||||
preselected: Vec<EntityType>,
|
||||
/// Last cursor position the preview saw. A distance typed at the side
|
||||
/// step places the offset relative to it.
|
||||
cursor: Option<DVec3>,
|
||||
/// 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<Await>,
|
||||
/// 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<EntityType>, targets: Vec<EntityType>) -> 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<f64>) -> 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<EntityType>)> = 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<EntityType> = 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<EntityType>)> = 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<EntityType> = 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<WireModel> {
|
||||
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<CmdResult>) -> 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::<Vec<_>>(), ["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));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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<Handle>,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Kerne
|
||||
if !loss.is_empty() || bodies.len() != 1 {
|
||||
return None;
|
||||
}
|
||||
let body = bodies.pop()?;
|
||||
let source = body.provenance.source()?;
|
||||
let Some((matrix, translation, scale)) = body_transform(&sat, source.index() as usize).ok()?
|
||||
else {
|
||||
return Some(body);
|
||||
};
|
||||
brep::transform(
|
||||
&body,
|
||||
&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,
|
||||
},
|
||||
)
|
||||
// The kernel's lift already applies the body's transform record.
|
||||
bodies.pop()
|
||||
}
|
||||
|
||||
fn remap_acis_material_bindings(
|
||||
|
||||
@@ -495,10 +495,17 @@ fn split_mixed_polyline(
|
||||
.iter()
|
||||
.copied()
|
||||
.fold(0.0f32, f32::max);
|
||||
let has_line_taper = straight_widths
|
||||
.first()
|
||||
.map_or(false, |&w0| straight_widths.iter().any(|&w| (w - w0).abs() > 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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -2054,6 +2054,9 @@ pub struct Scene {
|
||||
pub preview_text: Vec<crate::scene::pipeline::text_gpu::TextVertex>,
|
||||
/// Committed-segment wire drawn during multi-point commands (normal colour).
|
||||
pub interim_wire: Option<WireModel>,
|
||||
/// 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
|
||||
@@ -2626,6 +2629,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),
|
||||
|
||||
@@ -278,7 +278,7 @@ pub fn reference_point(operation: &SolidHistoryOperation) -> Option<glam::DVec3>
|
||||
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<EntityType> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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::<WireInstance>() 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::<Self>() 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<glam::DVec3> {
|
||||
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<glam::DVec3> {
|
||||
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<WireInstance> = 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)
|
||||
|
||||
@@ -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<f32>,
|
||||
@@ -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<f32>(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<f32>(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<f32>(which_end * world_len, eff_hw * side);
|
||||
out_cap_ends = vec3<f32>(world_len, world_hw_a, world_hw_b);
|
||||
} else {
|
||||
|
||||
@@ -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<f32>,
|
||||
@@ -144,7 +146,7 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32, in: VertexIn) -> VertexOut
|
||||
let seg_pos_b = vec3<f32>(seg.pos_b_x, seg.pos_b_y, seg.pos_b_z);
|
||||
let seg_pos_b_low = vec3<f32>(seg.pos_b_low_x, seg.pos_b_low_y, seg.pos_b_low_z);
|
||||
let seg_distances = vec2<f32>(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<f32, 6>(0.0, 1.0, 1.0, 0.0, 1.0, 0.0);
|
||||
let side_arr = array<f32, 6>(-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<f32>(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<f32>(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<f32>(which_end * world_len, eff_hw * side);
|
||||
out_cap_ends = vec3<f32>(world_len, world_hw_a, world_hw_b);
|
||||
} else {
|
||||
|
||||
@@ -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<f32>,
|
||||
@@ -191,13 +193,17 @@ fn marker_relative(position_high: vec3<f32>, position_low: vec3<f32>, 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<f32>(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<f32>(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<f32>(which_end * world_len, eff_hw * side);
|
||||
out_cap_ends = vec3<f32>(world_len, world_hw_a, world_hw_b);
|
||||
} else {
|
||||
|
||||
@@ -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<f32>,
|
||||
@@ -168,13 +170,17 @@ fn marker_relative(position_high: vec3<f32>, position_low: vec3<f32>, 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<f32>(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<f32>(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<f32>(which_end * world_len, eff_hw * side);
|
||||
out_cap_ends = vec3<f32>(world_len, world_hw_a, world_hw_b);
|
||||
} else {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<String>,
|
||||
) -> 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<Panel> {
|
||||
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<Item = &'static str> {
|
||||
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<String>,
|
||||
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,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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<Entry<'_>> {
|
||||
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<Entry<'_>> {
|
||||
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<Item = (usize, Entry<'_>)> {
|
||||
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<usize> {
|
||||
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<RibbonGroup>,
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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::<Element<'_, Message>>::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::<Vec<_>>(),
|
||||
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());
|
||||
|
||||
@@ -32,7 +32,7 @@ pub const CATEGORIES: &[(&str, &[&str])] = &[
|
||||
&["Author", "Comments", "Filename", "Filesize", "HyperlinkBase", "Keywords", "LastSavedBy", "Subject", "Title"],
|
||||
),
|
||||
("Linked", &["Hyperlink"]),
|
||||
("Objects", &["Formula", "NamedObject", "Object"]),
|
||||
("Objects", &["BlockPlaceholder", "Formula", "NamedObject", "Object"]),
|
||||
("Other", &["DieselExpression", "SystemVariable"]),
|
||||
(
|
||||
"Plot",
|
||||
@@ -159,6 +159,7 @@ pub enum FieldKind {
|
||||
SheetSetPlaceholder,
|
||||
/// SheetSet / SheetView: a component picked in a sheet set.
|
||||
SheetSetNavigation,
|
||||
BlockPlaceholder,
|
||||
}
|
||||
|
||||
/// Formula precisions: the current one, then 0 to 8 decimals.
|
||||
@@ -181,6 +182,7 @@ pub fn kind_of(name: &str) -> FieldKind {
|
||||
"SheetSetPlaceholder" => FieldKind::SheetSetPlaceholder,
|
||||
"SheetSet" | "SheetView" => FieldKind::SheetSetNavigation,
|
||||
n if SHEET_SET_FIELDS.contains(&n) => FieldKind::SheetSet,
|
||||
"BlockPlaceholder" => FieldKind::BlockPlaceholder,
|
||||
_ => FieldKind::Text,
|
||||
}
|
||||
}
|
||||
@@ -214,6 +216,11 @@ pub struct FieldDialogState {
|
||||
pub hyperlink_url: String,
|
||||
/// Index into codec PLOT_SCALE_FORMATS.
|
||||
pub plot_scale: usize,
|
||||
/// The block being edited in the block editor; block placeholders are
|
||||
/// only offered there.
|
||||
pub placeholder_block: Option<String>,
|
||||
/// Index into codec BLOCK_PLACEHOLDER_PROPERTIES.
|
||||
pub placeholder_property: usize,
|
||||
pub preview: String,
|
||||
/// Sheet set fields: the open set's sheet (flags 2) and set (flags 1)
|
||||
/// custom property names, the chosen one, the placeholder type and the
|
||||
@@ -254,6 +261,8 @@ impl FieldDialogState {
|
||||
hyperlink_text: String::new(),
|
||||
hyperlink_url: String::new(),
|
||||
plot_scale: 0,
|
||||
placeholder_block: None,
|
||||
placeholder_property: 7,
|
||||
preview: String::new(),
|
||||
ss_sheet_custom: Vec::new(),
|
||||
ss_set_custom: Vec::new(),
|
||||
@@ -297,10 +306,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 => (
|
||||
@@ -328,6 +338,21 @@ impl FieldDialogState {
|
||||
(case(base), 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)) {
|
||||
@@ -352,11 +377,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<codec::Handle>) {
|
||||
let mut out = String::new();
|
||||
let mut objects: Vec<codec::Handle> = 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;
|
||||
@@ -391,6 +440,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,
|
||||
@@ -454,6 +506,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
|
||||
@@ -649,6 +709,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(
|
||||
@@ -803,10 +893,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),
|
||||
),
|
||||
@@ -815,6 +913,7 @@ pub fn view<'a>(
|
||||
]
|
||||
.spacing(10),
|
||||
FieldKind::Hyperlink => column![card(t!("Hyperlink").into_owned(), format_panel(state)), 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();
|
||||
|
||||