mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-04 09:30:41 -06:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d4b5d4507 |
@@ -1,6 +0,0 @@
|
||||
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
|
||||
reviews:
|
||||
auto_review:
|
||||
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
|
||||
ignore_usernames:
|
||||
- "dependabot[bot]"
|
||||
@@ -1,3 +0,0 @@
|
||||
# Minimum cargo-nextest for this repository: GHSA-vc25-qg58-w57c is fixed in 0.9.150 and CI
|
||||
# installs exactly that version, so an older nextest refuses to run (exit 92 REQUIRED_VERSION_NOT_MET).
|
||||
nextest-version = "0.9.150"
|
||||
@@ -1,41 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Maintain Rust / Cargo dependencies
|
||||
- package-ecosystem: "cargo"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
# Consolidate updates into a single PR rather than one PR per crate
|
||||
groups:
|
||||
cargo-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
# craft-ui is still on egui 0.36, and two eguis in the graph don't build (#1990). Remove this
|
||||
# once craft-ui moves to egui 0.37 (storytold/craft-libs#199).
|
||||
ignore:
|
||||
- dependency-name: "egui"
|
||||
versions: [">=0.37"]
|
||||
- dependency-name: "eframe"
|
||||
versions: [">=0.37"]
|
||||
- dependency-name: "egui-wgpu"
|
||||
versions: [">=0.37"]
|
||||
- dependency-name: "egui_kittest"
|
||||
versions: [">=0.37"]
|
||||
|
||||
# Maintain GitHub Actions workflows
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "ci(deps)"
|
||||
@@ -1,26 +0,0 @@
|
||||
# Structural rules for the command-line code (RFC-0012): lint/ast-grep's rule tests, then a scan.
|
||||
# Seconds, no Rust build.
|
||||
name: ast-grep
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ["lint/ast-grep/**", "apps/cadcraft-cli/src/**", "apps/cadcraft/src/launch_args.rs", "apps/cadcraft/src/main.rs", ".github/workflows/ast-grep.yml"]
|
||||
pull_request:
|
||||
paths: ["lint/ast-grep/**", "apps/cadcraft-cli/src/**", "apps/cadcraft/src/launch_args.rs", "apps/cadcraft/src/main.rs", ".github/workflows/ast-grep.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ast-grep-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
# Tracked at @main like the other shared workflows.
|
||||
uses: storytold/craft-common-actions/.github/workflows/ast-grep.yml@main
|
||||
with:
|
||||
config: lint/ast-grep/sgconfig.yml
|
||||
paths: apps/cadcraft-cli/src apps/cadcraft/src/launch_args.rs apps/cadcraft/src/main.rs
|
||||
@@ -1,30 +0,0 @@
|
||||
name: CI
|
||||
|
||||
# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the
|
||||
# suite's shared gate workflow in craft-common-actions.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# A newer push to a PR branch cancels its older run; every push to main runs to the end.
|
||||
concurrency:
|
||||
group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
|
||||
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
|
||||
with:
|
||||
# Same craft-fonts commit as release.yml.
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
timeout-minutes: 90
|
||||
packages: >-
|
||||
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev
|
||||
libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
|
||||
libgl1-mesa-dev
|
||||
@@ -1,32 +0,0 @@
|
||||
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
|
||||
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
|
||||
name: FreeBSD
|
||||
|
||||
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull
|
||||
# request: a drifted dependency still shows up within a day, and always before a release ships.
|
||||
# PRs that change the FreeBSD workflow or packaging itself still run it.
|
||||
on:
|
||||
push:
|
||||
branches: [release]
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/freebsd.yml
|
||||
- packaging/freebsd/**
|
||||
- packaging/env.sh
|
||||
schedule:
|
||||
- cron: "27 3 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: freebsd-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
freebsd:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
|
||||
with:
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
@@ -1,61 +0,0 @@
|
||||
# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation
|
||||
# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice.
|
||||
name: Nix
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
|
||||
pull_request:
|
||||
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: nix-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
nix:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
|
||||
|
||||
git-sources:
|
||||
name: Git dependencies come from storytold
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Git dependencies come from storytold
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; }
|
||||
# Source replacement, patches and path overrides in cargo config never show up
|
||||
# in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key.
|
||||
# Cargo still reads the legacy .cargo/config.
|
||||
python3 - <<'PY'
|
||||
import os, sys, tomllib
|
||||
for path in (".cargo/config", ".cargo/config.toml"):
|
||||
if not os.path.exists(path):
|
||||
continue
|
||||
config = tomllib.load(open(path, "rb"))
|
||||
replaced = [name for name, source in config.get("source", {}).items()
|
||||
if isinstance(source, dict) and "replace-with" in source]
|
||||
if replaced or "patch" in config or "paths" in config:
|
||||
print(f"{path} replaces, patches or overrides a source")
|
||||
sys.exit(1)
|
||||
PY
|
||||
# Fails when Cargo.lock is out of date with the manifests.
|
||||
cargo metadata --locked --format-version 1 > /dev/null
|
||||
python3 - <<'PY'
|
||||
import sys, tomllib
|
||||
lock = tomllib.load(open("Cargo.lock", "rb"))
|
||||
bad = sorted({p["source"] for p in lock.get("package", [])
|
||||
if p.get("source", "").startswith("git+")
|
||||
and not p["source"].startswith("git+https://github.com/storytold/")})
|
||||
if bad:
|
||||
print("Git dependencies outside github.com/storytold:", *bad, sep="\n")
|
||||
sys.exit(1)
|
||||
PY
|
||||
@@ -19,29 +19,48 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
|
||||
|
||||
windows-install-policy:
|
||||
timeout-minutes: 10
|
||||
name: Windows installation policy (no app build)
|
||||
runs-on: windows-latest
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install the pinned packaging toolchain
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml
|
||||
curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
|
||||
| tar -xz -C "$RUNNER_TEMP" actionlint
|
||||
- name: actionlint
|
||||
run: |
|
||||
"$RUNNER_TEMP/actionlint" -color
|
||||
- name: shellcheck
|
||||
run: shellcheck packaging/*.sh packaging/*/*.sh
|
||||
- name: PowerShell
|
||||
shell: pwsh
|
||||
run: |
|
||||
$tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix'
|
||||
dotnet tool install --tool-path $tools wix --version 5.0.2
|
||||
if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' }
|
||||
$tools >> $env:GITHUB_PATH
|
||||
$policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json
|
||||
foreach ($extension in @($policy.extensions)) {
|
||||
& (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2"
|
||||
if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." }
|
||||
foreach ($f in Get-ChildItem packaging/windows/*.ps1) {
|
||||
$errors = $null
|
||||
[System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null
|
||||
if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 }
|
||||
Write-Output "ok $($f.Name)"
|
||||
}
|
||||
- name: Build and check non-executable installer fixtures
|
||||
shell: pwsh
|
||||
run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests')
|
||||
- name: XML, desktop entry, AppStream, Flatpak manifest
|
||||
run: |
|
||||
xmllint --noout packaging/windows/cadcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml*
|
||||
# Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon.
|
||||
if grep -oE '<Icon Id="[^"]*"' packaging/windows/cadcraft.wxs | grep -vE '\.(ico|exe)"$'; then
|
||||
echo "::error::Icon Id must end in .ico or .exe"; exit 1
|
||||
fi
|
||||
desktop-file-validate packaging/linux/ai.storyteller.cadcraft.desktop
|
||||
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.cadcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.cadcraft.metainfo.xml"
|
||||
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.cadcraft.metainfo.xml"
|
||||
python3 - <<'EOF'
|
||||
import yaml
|
||||
src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.cadcraft.yml"))
|
||||
bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.cadcraft.bundle.yml"))
|
||||
for m in (src, bundle):
|
||||
assert m["id"] == "ai.storyteller.cadcraft" and m["command"] == "cadcraft", m["id"]
|
||||
for key in ("runtime", "runtime-version", "sdk", "finish-args"):
|
||||
assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}"
|
||||
print("Flatpak manifests ok")
|
||||
EOF
|
||||
|
||||
+415
-60
@@ -1,3 +1,13 @@
|
||||
# Release pipeline: every push to `release` builds signed installers for every platform and
|
||||
# creates or updates the draft GitHub Release "CADCraft v<version>". See docs/releasing.md.
|
||||
#
|
||||
# Pushes to `release` trigger the run; pushing there is limited by a repository ruleset to org
|
||||
# admins and the release-managers team. The jobs that sign (macOS, Windows) and the one that
|
||||
# drafts the GitHub Release run in the `release` environment (restricted to the `release` branch),
|
||||
# which holds the signing secrets. Every secret is optional: missing ones produce unsigned
|
||||
# artifacts and a warning, never a failed build. The other jobs (version, Linux, Flatpak, FreeBSD,
|
||||
# web) sign nothing and get no secrets, so a manual dispatch on any other branch is a dry run of
|
||||
# them: the environment refuses the signing jobs there, and the draft-release job is skipped.
|
||||
name: Release
|
||||
|
||||
on:
|
||||
@@ -15,73 +25,418 @@ permissions:
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
# Never cancel a run mid-notarization or mid-upload; a newer push waits its turn.
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
RUST_BACKTRACE: 1
|
||||
CADCRAFT_BUILD_SHA: ${{ github.sha }}
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
|
||||
jobs:
|
||||
git-sources:
|
||||
name: Git dependencies come from storytold
|
||||
version:
|
||||
name: version
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
date: ${{ steps.v.outputs.date }}
|
||||
prerelease: ${{ steps.v.outputs.prerelease }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Git dependencies come from storytold
|
||||
- uses: actions/checkout@v4
|
||||
- id: v
|
||||
name: Resolve version
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; }
|
||||
# Source replacement, patches and path overrides in cargo config never show up
|
||||
# in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key.
|
||||
# Cargo still reads the legacy .cargo/config.
|
||||
python3 - <<'PY'
|
||||
import os, sys, tomllib
|
||||
for path in (".cargo/config", ".cargo/config.toml"):
|
||||
if not os.path.exists(path):
|
||||
continue
|
||||
config = tomllib.load(open(path, "rb"))
|
||||
replaced = [name for name, source in config.get("source", {}).items()
|
||||
if isinstance(source, dict) and "replace-with" in source]
|
||||
if replaced or "patch" in config or "paths" in config:
|
||||
print(f"{path} replaces, patches or overrides a source")
|
||||
sys.exit(1)
|
||||
PY
|
||||
# Fails when Cargo.lock is out of date with the manifests.
|
||||
cargo metadata --locked --format-version 1 > /dev/null
|
||||
python3 - <<'PY'
|
||||
import sys, tomllib
|
||||
lock = tomllib.load(open("Cargo.lock", "rb"))
|
||||
bad = sorted({p["source"] for p in lock.get("package", [])
|
||||
if p.get("source", "").startswith("git+")
|
||||
and not p["source"].startswith("git+https://github.com/storytold/")})
|
||||
if bad:
|
||||
print("Git dependencies outside github.com/storytold:", *bad, sep="\n")
|
||||
sys.exit(1)
|
||||
PY
|
||||
. packaging/env.sh
|
||||
v="${INPUT_VERSION#v}"
|
||||
v="${v:-$VERSION}"
|
||||
if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then
|
||||
echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1"
|
||||
exit 1
|
||||
fi
|
||||
case "$v" in *-*) pre=true ;; *) pre=false ;; esac
|
||||
{
|
||||
echo "version=$v"
|
||||
echo "date=$(date -u +%Y-%m-%d)"
|
||||
echo "prerelease=$pre"
|
||||
} >>"$GITHUB_OUTPUT"
|
||||
echo "### CADCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
|
||||
|
||||
macos:
|
||||
name: macOS universal
|
||||
needs: version
|
||||
runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend)
|
||||
environment: release
|
||||
env:
|
||||
CADCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
CADCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin, x86_64-apple-darwin
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-macos-universal
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$CADCRAFT_VERSION"
|
||||
- name: Import signing certificate
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
run: packaging/macos/import-cert.sh
|
||||
- name: Build, sign, notarize
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: packaging/macos/package.sh --arch universal
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-macos
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
- name: Remove temporary keychain
|
||||
if: always()
|
||||
run: |
|
||||
if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi
|
||||
|
||||
windows:
|
||||
name: Windows ${{ matrix.arch }}
|
||||
needs: version
|
||||
runs-on: windows-latest
|
||||
environment: release
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x64, target: x86_64-pc-windows-msvc }
|
||||
- { arch: x86, target: i686-pc-windows-msvc }
|
||||
# Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above.
|
||||
- { arch: arm64, target: aarch64-pc-windows-msvc }
|
||||
env:
|
||||
CADCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
CADCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-windows-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
shell: bash
|
||||
run: cargo xtask version set "$CADCRAFT_VERSION"
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: |
|
||||
dotnet tool install --global wix --version 5.0.2
|
||||
wix extension add -g WixToolset.UI.wixext/5.0.2
|
||||
- name: Build, sign, package
|
||||
shell: pwsh
|
||||
env:
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-windows-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
linux:
|
||||
name: Linux ${{ matrix.arch }}
|
||||
needs: version
|
||||
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+,
|
||||
# Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md.
|
||||
runs-on: ${{ matrix.runner }}
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
|
||||
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
|
||||
env:
|
||||
CADCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
CADCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
NFPM_VERSION: 2.47.0
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install build and packaging tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \
|
||||
desktop-file-utils appstream python3-yaml zsync
|
||||
curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \
|
||||
"https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb"
|
||||
sudo dpkg -i "$RUNNER_TEMP/nfpm.deb"
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-linux-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$CADCRAFT_VERSION"
|
||||
- name: Build and package (AppImage, deb, rpm, tar.gz)
|
||||
run: packaging/linux/package.sh
|
||||
- name: Check the packages
|
||||
run: |
|
||||
dpkg-deb --info dist/release/*.deb
|
||||
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/cadcraft$|metainfo|applications'
|
||||
ldd target/release/cadcraft
|
||||
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.cadcraft.yml')); assert m['id'] == 'ai.storyteller.cadcraft'"
|
||||
for appimage in dist/release/*.AppImage; do
|
||||
chmod +x "$appimage"
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
|
||||
# Update information is embedded, and the .zsync for delta updates sits beside it.
|
||||
# Without APPIMAGE_EXTRACT_AND_RUN: with it, the runtime passes --appimage-* options
|
||||
# on to the app (which then tries to open a window) instead of answering them.
|
||||
"$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|'
|
||||
test -s "$appimage.zsync"
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
linux-riscv64:
|
||||
name: Linux riscv64
|
||||
needs: version
|
||||
# Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release
|
||||
# from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's
|
||||
# riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the
|
||||
# CLI is smoke-tested under QEMU.
|
||||
runs-on: ubuntu-24.04
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
env:
|
||||
CADCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
CADCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: riscv64gc-unknown-linux-gnu
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-linux-riscv64
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$CADCRAFT_VERSION"
|
||||
- name: Install cross-compilation dependencies
|
||||
run: |
|
||||
. /etc/os-release
|
||||
sudo dpkg --add-architecture riscv64
|
||||
# Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports.
|
||||
if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then
|
||||
sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources
|
||||
fi
|
||||
if [ -f /etc/apt/sources.list ]; then
|
||||
sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list
|
||||
sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list
|
||||
fi
|
||||
for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do
|
||||
echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse"
|
||||
done | sudo tee /etc/apt/sources.list.d/riscv64.list
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \
|
||||
libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \
|
||||
libxi-dev:riscv64 libgl1-mesa-dev:riscv64
|
||||
- name: Build and package (tar.gz)
|
||||
env:
|
||||
CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc
|
||||
PKG_CONFIG_SYSROOT_DIR: /
|
||||
PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig
|
||||
PKG_CONFIG_ALLOW_CROSS: "1"
|
||||
CROSS_ARCH: riscv64
|
||||
CROSS_TARGET: riscv64gc-unknown-linux-gnu
|
||||
CROSS_COMPILE: riscv64-linux-gnu-
|
||||
EMULATOR: qemu-riscv64
|
||||
QEMU_LD_PREFIX: /usr/riscv64-linux-gnu
|
||||
run: packaging/linux/package.sh --formats tar
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-linux-riscv64
|
||||
path: dist/release/*.tar.gz
|
||||
if-no-files-found: error
|
||||
|
||||
flatpak:
|
||||
name: Flatpak ${{ matrix.arch }}
|
||||
needs: [version, linux]
|
||||
# Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust
|
||||
# build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships.
|
||||
runs-on: ${{ matrix.runner }}
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: ubuntu-24.04 }
|
||||
- { arch: aarch64, runner: ubuntu-24.04-arm }
|
||||
env:
|
||||
CADCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
CADCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: release-linux-${{ matrix.arch }}
|
||||
path: dist/linux
|
||||
- name: Install flatpak-builder
|
||||
run: |
|
||||
sudo apt-get update
|
||||
# librsvg2-common: the SVG pixbuf loader that the host's `appstreamcli compose` (run by
|
||||
# flatpak-builder) needs for any scalable icon.
|
||||
sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common
|
||||
# Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
- name: Build the Flatpak bundle
|
||||
run: packaging/linux/flatpak-bundle.sh "dist/linux/cadcraft-$CADCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-flatpak-${{ matrix.arch }}
|
||||
path: dist/release/*.flatpak
|
||||
if-no-files-found: error
|
||||
|
||||
web:
|
||||
name: Web (wasm)
|
||||
needs: version
|
||||
runs-on: ubuntu-latest
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
env:
|
||||
CADCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
CADCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
TRUNK_VERSION: 0.21.14
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: release-web
|
||||
- name: Install trunk
|
||||
run: |
|
||||
curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \
|
||||
| tar -xz -C "$HOME/.cargo/bin" trunk
|
||||
trunk --version
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$CADCRAFT_VERSION"
|
||||
- name: Build and zip
|
||||
run: packaging/web/package.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-web
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
freebsd:
|
||||
name: FreeBSD x86_64
|
||||
needs: version
|
||||
# GitHub has no FreeBSD runners: build inside a FreeBSD VM on a Linux runner.
|
||||
runs-on: ubuntu-latest
|
||||
# No `environment: release`: this job signs nothing, so it gets no release secrets.
|
||||
timeout-minutes: 300
|
||||
env:
|
||||
CADCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
CADCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build and package in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
envs: "CADCRAFT_VERSION CADCRAFT_BUILD_DATE CADCRAFT_BUILD_SHA INPUT_VERSION"
|
||||
copyback: true
|
||||
prepare: |
|
||||
pkg install -y git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
|
||||
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
run: |
|
||||
set -e
|
||||
. "$HOME/.cargo/env"
|
||||
git config --global --add safe.directory "*"
|
||||
if [ -n "$INPUT_VERSION" ]; then cargo xtask version set "$CADCRAFT_VERSION"; fi
|
||||
cargo build --release --locked -p cadcraft -p cadcraft-cli
|
||||
name="cadcraft-$CADCRAFT_VERSION-freebsd-x86_64"
|
||||
mkdir -p "dist/release/$name"
|
||||
cp target/release/cadcraft target/release/cadcraft-cli README.md LICENSE-MIT LICENSE-APACHE NOTICE ATTRIBUTION.md "dist/release/$name/"
|
||||
mkdir -p "dist/release/$name/icons"
|
||||
cp -R assets/app-icon/hicolor "dist/release/$name/icons/" 2>/dev/null || true
|
||||
(cd dist/release && tar -czf "$name.tar.gz" "$name" && rm -rf "$name")
|
||||
# Keep the copy back to the runner small.
|
||||
rm -rf target
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-freebsd
|
||||
path: dist/release/*
|
||||
if-no-files-found: error
|
||||
|
||||
release:
|
||||
needs: git-sources
|
||||
name: Draft GitHub Release
|
||||
needs: [version, macos, windows, linux, linux-riscv64, flatpak, web, freebsd]
|
||||
# Only the `release` branch publishes; a dry run on another branch stops before this job.
|
||||
if: github.ref == 'refs/heads/release'
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
permissions:
|
||||
# The reusable release workflow tags and uploads the draft release.
|
||||
contents: write
|
||||
packages: write
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
|
||||
with:
|
||||
app-name: cadcraft
|
||||
app-title: CADCraft
|
||||
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
|
||||
version: ${{ inputs.version }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
env:
|
||||
VERSION: ${{ needs.version.outputs.version }}
|
||||
PRERELEASE: ${{ needs.version.outputs.prerelease }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: release-*
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
- name: Checksums
|
||||
working-directory: artifacts
|
||||
run: |
|
||||
sha256sum -- * | tee SHA256SUMS.txt
|
||||
- name: Create or update the draft release
|
||||
working-directory: artifacts
|
||||
run: |
|
||||
tag="v$VERSION"
|
||||
title="CADCraft v$VERSION"
|
||||
pre=()
|
||||
if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi
|
||||
if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then
|
||||
if [ "$draft" != "true" ]; then
|
||||
echo "::error::Release $tag is already published. Bump the version (cargo xtask version set) before pushing to release."
|
||||
exit 1
|
||||
fi
|
||||
echo "Updating draft $tag: replacing its assets"
|
||||
gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do
|
||||
gh release delete-asset "$tag" "$asset" --yes
|
||||
done
|
||||
gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE"
|
||||
gh release upload "$tag" --clobber -- *
|
||||
else
|
||||
echo "Creating draft $tag"
|
||||
gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- *
|
||||
fi
|
||||
{
|
||||
echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:"
|
||||
echo
|
||||
sed 's/^/ /' SHA256SUMS.txt
|
||||
} >>"$GITHUB_STEP_SUMMARY"
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
name: SonarCloud
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: sonarcloud-${{ github.ref }}
|
||||
# A push to main is the run that publishes coverage; superseded runs there would never finish.
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
sonarcloud:
|
||||
# Tracked at @main so org-wide CI improvements propagate without PR churn.
|
||||
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
|
||||
secrets: inherit
|
||||
@@ -1,34 +0,0 @@
|
||||
# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64
|
||||
# runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed
|
||||
# binaries are ARM64, run the CLI natively, and uninstall.
|
||||
name: Windows ARM64
|
||||
|
||||
# Nightly on main and on pushes to `release` (dependency and runner-image drift shows up within a
|
||||
# day, and always before a release ships), and on PRs that change the Windows packaging itself.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "3 5 * * *"
|
||||
push:
|
||||
branches: [release]
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/windows-arm64.yml
|
||||
- packaging/windows/**
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: windows-arm64-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
windows-arm64:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
|
||||
with:
|
||||
app-name: cadcraft
|
||||
app-dir: CADCraft
|
||||
install-relative-path: Programs/ArtCraft/CADCraft
|
||||
install-scope: perUser
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
CADCraft is a clean-room, open-source, pure-Rust computer-aided design and drafting application targeting Autodesk AutoCAD parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows, Linux and FreeBSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop-class), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../pdfcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign).
|
||||
|
||||
Standards and learnings shared across the crafting apps live in `../../craftrules` (or `storytold/craftrules`). Read its `AGENTS.md` at the start of a session, follow its standards, and contribute reusable learnings back there. Never code: repos don't share code.
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/autocad/*` (`01-observed-ui.md` holds measured observations of the running reference app; `04-menu-tree.txt` the menu tree by name).
|
||||
2. Library decisions: `plan/adr/0001-geometry-libraries.md` (what we may depend on, what is rejected for licence reasons).
|
||||
@@ -10,25 +12,24 @@ CADCraft is a clean-room, open-source, pure-Rust computer-aided design and draft
|
||||
`plan/` is gitignored (local only).
|
||||
|
||||
## Never crash
|
||||
People trust CADCraft with their drawings; a crash loses their work. **This outranks feature work.**
|
||||
People trust CADCraft with their drawings; a crash loses their work. **This outranks feature work.** Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
|
||||
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
|
||||
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature returns an error or reports "not available yet"; it never panics.
|
||||
- **Input-derived numbers are hostile** (DXF/DWG files, command-line text, MCP/control params): `get()` not `[i]`, checked arithmetic, no NaN casts, cap input-sized allocations and loop counts.
|
||||
- **Bound recursion** (nested/cyclic block references: `MAX_BLOCK_DEPTH`).
|
||||
- **Last-resort guard:** `Session::execute` and every step of an interactive command (its factory, `begin`, input, `prompt`, `preview` and the passes after it; `crates/engine/src/guard.rs`) run under `catch_unwind`; an escaped panic restores the drawing and reports an error. It does nothing on wasm, where a panic aborts.
|
||||
- **Prove it:** every crash fix lands with a regression test (see `crates/engine/tests/panic_hunt.rs` and `hostile_dxf_does_not_panic` in `crates/io/src/tests.rs`).
|
||||
- Every workspace crate manifest carries `[lints] workspace = true` to inherit `[workspace.lints]`; every production crate root carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
|
||||
- **Last-resort guard:** `Session::execute` and every step of an interactive command (its factory, `begin`, input, `prompt` and `preview`; `crates/engine/src/guard.rs`) run under `catch_unwind`; an escaped panic restores the drawing and reports an error.
|
||||
- **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`, `hostile_dxf_does_not_panic`).
|
||||
- Every production crate root carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
|
||||
|
||||
## Non-negotiables
|
||||
- **Clean-room.** AutoCAD is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic drawings, take screenshots (by window id) stored only under `plan/autocad/screenshots/` (never committed). **Never** read, disassemble or copy anything inside the AutoCAD bundle (names/listings only); never copy Autodesk fonts (SHX), hatch patterns (`.pat`), linetypes (`.lin`), templates, CUI/PGP files, icons, artwork or help text; never commit files produced by AutoCAD. File formats come from public specifications (Autodesk's published DXF Reference, the Open Design Alliance's public DWG specification) and our own synthetic tests. Never copy or link GPL/LGPL/AGPL code (LibreDWG, LibreCAD, QCAD, FreeCAD's planegcs, SolveSpace, OpenCASCADE) — don't even read their sources.
|
||||
- **Assets — absolutely essential.** CADCraft contains **no Autodesk, Adobe or Avid iconography, images, fonts, patterns or artwork — ever.** Every icon is drawn in code (`crates/ui-egui/src/icons.rs`), every hatch pattern and linetype is our own definition (`crates/doc/src/library.rs`), the drafting font is our own (`crates/fonts/src/stroke.rs`). Any file asset must be original, public domain/CC0, OSI-licensed, or redistributable Creative Commons (or licensed open source by a contributor who made it), and **must have a row in `ATTRIBUTION.md`** with author, source and licence (`cargo xtask assets` enforces it). The only exception is the ArtCraft brand in `docs/brand/` (trademarks, `docs/brand/LICENSE-brand.txt`). Screenshots of Autodesk software are never committed. Breaking this rule is the most serious mistake you can make in this repo.
|
||||
- **Fonts live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)**, never in this repo. It is an optional build input (`CRAFT_FONTS_DIR`, embedded by `crates/fonts/build.rs`), never a `Cargo.toml` dependency. Code and tests must work without it.
|
||||
- **Fonts live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)**, never in this repo. It is an optional build input (`CRAFT_FONTS_DIR`), never a `Cargo.toml` dependency. Code and tests must work without it.
|
||||
- **Shared test corpora** live in separate repos (`storytold/<app>-corpus`); never commit large binary fixtures here.
|
||||
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (`CommandSpec`: id = AutoCAD's command name in lower case, label, menu path, shortcut, aliases, params doc, `enabled`, JSON `run`, optional `interactive` prompt machine) + tests. UI-only commands live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). The command line, menus, toolbar, Tool Sets, scripts, CLI, control channel and MCP all reach the same commands.
|
||||
- **Programmatic calls never open dialogs.** `engine.execute` runs the JSON form with defaults; only menu/toolbar invocation starts the interactive prompt sequence.
|
||||
- **Layering** is enforced by `cargo xtask layers`: L0 `geom`, `dxf` → L1 `color`, `doc` → L2 `fonts`, `render`, `constraints` → L3 `io` → L4 `engine` → L5 `ui-egui`, `mcp` → apps. Nothing below L5 depends on egui/eframe/winit/wgpu/rfd. **The UI crate is swappable.**
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` / `app.start(id)` / `app.cmdline(text)`. Colours come from `theme::Tokens`.
|
||||
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
|
||||
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). Commit after every feature arc that builds, and push to `main`.
|
||||
|
||||
@@ -45,24 +46,22 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr
|
||||
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk).
|
||||
|
||||
## Roadmap
|
||||
`ROADMAP.md` (committed) is the one-page summary: stage, parity numbers, estimates. The assessment is `docs/target-app-parity.md`, the work list `docs/gaps.md` (pick work from its top rows), milestones `docs/roadmap.md`, deep checklists `docs/geometry-parity.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. PRs don't edit these docs: name the affected rows in the PR description, and maintainers update the rows, status lines and revision history in batches, which keeps open PRs from conflicting. `cargo xtask parity` regenerates the menu-breadth checklist in `docs/parity-checklist.md`.
|
||||
`ROADMAP.md` (committed) is the one-page summary: stage, parity numbers, estimates. It follows craftrules' `standards/progress-docs.md`: the assessment is `docs/target-app-parity.md`, the work list `docs/gaps.md` (pick work from its top rows), milestones `docs/roadmap.md`, deep checklists `docs/geometry-parity.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. When work lands, update the affected rows and the docs' status lines and revision history. `cargo xtask parity` regenerates the menu-breadth checklist in `docs/parity-checklist.md`.
|
||||
|
||||
## Contributor credits (About window)
|
||||
|
||||
- About ▸ Contributors/Models are compiled into the binary from `contributors/contributors.json`
|
||||
(commit stats; never hand-edit) and `contributors/people.toml` (names people chose for
|
||||
(commit stats; generated, never hand-edit) and `contributors/people.toml` (names people chose for
|
||||
themselves). See `docs/contributors.md`.
|
||||
- **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub
|
||||
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
|
||||
whether they want to be credited by more than their username: a real name, a display name, and/or
|
||||
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
|
||||
(copy the template at the top of the file)
|
||||
(copy the template at the top of the file, or run
|
||||
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
|
||||
and include it in their PR, committed as them. If no, change nothing: they are credited as
|
||||
`@username` anyway.
|
||||
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
|
||||
GitHub profiles). Never hand-edit `contributors.json`.
|
||||
|
||||
## CI workflows
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
|
||||
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
|
||||
|
||||
+8
-29
@@ -23,11 +23,6 @@ Generated-in-code assets are original and have no file to list:
|
||||
|
||||
| Asset | Author | Source | Licence | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 173cc737](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset; emoji-icon-font.ttf is the full monochrome fallback. Registered by the production default font definitions; dependency assets unmodified here, no font file copied into this repository. |
|
||||
| `krilla 0.8.2 / icc/sGrey-v2-magic.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 9f89fe8 vendor/krilla/icc/sGrey-v2-magic.icc](https://github.com/storytold/craft-libs/blob/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/vendor/krilla/icc/sGrey-v2-magic.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `4110169287e5f1b5149ca9f1da1814d32943905bba8030e849d1a3b729a19481`. |
|
||||
| `krilla 0.8.2 / icc/sGrey-v4.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 9f89fe8 vendor/krilla/icc/sGrey-v4.icc](https://github.com/storytold/craft-libs/blob/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/vendor/krilla/icc/sGrey-v4.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `00c0f94e09127520a17dc0e1d9264b5702081d96dbdb1549ee88e3631ce42a9d`. |
|
||||
| `krilla 0.8.2 / icc/sRGB-v2-magic.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 9f89fe8 vendor/krilla/icc/sRGB-v2-magic.icc](https://github.com/storytold/craft-libs/blob/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/vendor/krilla/icc/sRGB-v2-magic.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `af4efe28f6d311799865f325ba39184a2e978b113ca74124d60af7de22b105f4`. |
|
||||
| `krilla 0.8.2 / icc/sRGB-v4.icc` (dependency-embedded) | Clinton Ingram | [craft-libs 9f89fe8 vendor/krilla/icc/sRGB-v4.icc](https://github.com/storytold/craft-libs/blob/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/vendor/krilla/icc/sRGB-v4.icc) (Krilla 0.8.2 upstream 3ffdf0588cf98050aad6edba51ca70162e1fb5b5) | CC0-1.0 | Embedded by the pinned PDF writer (`craft-pdf` 0.1.0, Krilla 0.8.2); unmodified dependency asset, no file copied into this repository. SHA-256 `c56e1685d888f5edb92fe07f2750f387f8fe8e91b32ff8fb0b56bfbbb9458353`. |
|
||||
| `assets/app-icon/hicolor/128x128/apps/ai.storyteller.cadcraft.png` | CADCraft contributors | generated from `assets/app-icon/cadcraft-1024.png` | MIT OR Apache-2.0 | Linux hicolor icon |
|
||||
| `assets/app-icon/hicolor/16x16/apps/ai.storyteller.cadcraft.png` | CADCraft contributors | generated from `assets/app-icon/cadcraft-1024.png` | MIT OR Apache-2.0 | Linux hicolor icon |
|
||||
| `assets/app-icon/hicolor/24x24/apps/ai.storyteller.cadcraft.png` | CADCraft contributors | generated from `assets/app-icon/cadcraft-1024.png` | MIT OR Apache-2.0 | Linux hicolor icon |
|
||||
@@ -48,33 +43,17 @@ Generated-in-code assets are original and have no file to list:
|
||||
| `docs/images/ui-apartment.png` | CADCraft contributors | screenshot of CADCraft itself (examples/apartment.dxf) | MIT OR Apache-2.0 | Original; no Autodesk UI |
|
||||
| `docs/images/ui-layout.png` | CADCraft contributors | screenshot of CADCraft itself (a layout of examples/apartment.dxf) | MIT OR Apache-2.0 | Original; no Autodesk UI |
|
||||
| `docs/images/ui-bracket.png` | CADCraft contributors | screenshot of CADCraft itself (sample drawing generated in code) | MIT OR Apache-2.0 | Original; no Autodesk UI |
|
||||
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-logo.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-mark.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-logo.png` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-mark.png` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) | Trademark, not open source |
|
||||
|
||||
## Interface translations
|
||||
|
||||
| Asset | Author | Source | Licence | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `crates/ui-egui/src/i18n/uk.tsv` | @dmatviichuk | Original Ukrainian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/de.tsv` | the CADCraft contributors | Original German translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/es.tsv` | @PedroGuille | Original Spanish translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/pt_br.tsv` | @WladmirJunior | Original Brazilian Portuguese translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/bs.tsv` | Nine (@nine-codes) with Claude Code (AI-assisted) | Original Bosnian translations of CADCraft English strings, shared with storytold/craft-i18n `locales/bs/` | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; not yet reviewed by a native speaker |
|
||||
| `crates/ui-egui/src/i18n/cs.tsv` | @Losminek (AI-assisted) | Original Czech translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/ru.tsv` | @pechnikov2026-star | Original Russian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/fr.tsv` | @pechnikov2026-star | Original French translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/zh.tsv` | @mayloglog | Original Simplified Chinese translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/sv.tsv` | Erik Holmbom (@holmbom) with Claude Code (AI-assisted) | Original Swedish translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; terminology and the running app reviewed by a native speaker |
|
||||
| `crates/ui-egui/src/i18n/it.tsv` | Emanuele Bottelli (AI-assisted); terminology corrections: Andrea Brandi (@starise) with OpenAI Codex (AI-assisted) | Original Italian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; targeted Chamfer terminology review by a native speaker |
|
||||
| `crates/ui-egui/src/i18n/ko.tsv` | @huk082 (AI-assisted) | Original Korean translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; no font assets added |
|
||||
| `crates/ui-egui/src/i18n/ar.tsv` | @hasan-sdaif | Original Arabic translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/hu.tsv` | @Skaywalker | Original Hungarian translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied |
|
||||
| `crates/ui-egui/src/i18n/tr.tsv` | @ogzhnklvz (AI-assisted) | Original Turkish translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; written from the English strings, no other catalog used as a source; the running app reviewed by a native speaker |
|
||||
| `crates/ui-egui/src/i18n/el.tsv` | @Camthalion77 with ChatGPT (AI-assisted) | Original Greek translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; independent native proofreading pending |
|
||||
| `crates/ui-egui/src/i18n/vi.tsv` | @2ez4gcx with Claude Code (AI-assisted) | Original Vietnamese translations of CADCraft English strings | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | No proprietary localization resources copied; terminology chosen with a native speaker, full proofreading pending |
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
CADCraft is a clean-room, open-source, pure-Rust computer-aided design and drafting application targeting Autodesk AutoCAD parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows, Linux and FreeBSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop-class), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../pdfcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign).
|
||||
|
||||
Standards and learnings shared across the crafting apps live in `../../craftrules` (or `storytold/craftrules`). Read its `AGENTS.md` at the start of a session, follow its standards, and contribute reusable learnings back there. Never code: repos don't share code.
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next task), then the task in `plan/execution-plan.md` and the relevant `plan/architecture.md` section. Behaviour reference: `plan/autocad/*` (`01-observed-ui.md` holds measured observations of the running reference app; `04-menu-tree.txt` the menu tree by name).
|
||||
2. Library decisions: `plan/adr/0001-geometry-libraries.md` (what we may depend on, what is rejected for licence reasons).
|
||||
@@ -10,19 +12,19 @@ CADCraft is a clean-room, open-source, pure-Rust computer-aided design and draft
|
||||
`plan/` is gitignored (local only).
|
||||
|
||||
## Never crash
|
||||
People trust CADCraft with their drawings; a crash loses their work. **This outranks feature work.**
|
||||
People trust CADCraft with their drawings; a crash loses their work. **This outranks feature work.** Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
|
||||
- **No panics in non-test code:** no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
|
||||
- **Errors are `Result<T, E>`** through the crate's error type and `?`. An unfinished feature returns an error or reports "not available yet"; it never panics.
|
||||
- **Input-derived numbers are hostile** (DXF/DWG files, command-line text, MCP/control params): `get()` not `[i]`, checked arithmetic, no NaN casts, cap input-sized allocations and loop counts.
|
||||
- **Bound recursion** (nested/cyclic block references: `MAX_BLOCK_DEPTH`).
|
||||
- **Last-resort guard:** `Session::execute` and every step of an interactive command (its factory, `begin`, input, `prompt`, `preview` and the passes after it; `crates/engine/src/guard.rs`) run under `catch_unwind`; an escaped panic restores the drawing and reports an error. It does nothing on wasm, where a panic aborts.
|
||||
- **Prove it:** every crash fix lands with a regression test (see `crates/engine/tests/panic_hunt.rs` and `hostile_dxf_does_not_panic` in `crates/io/src/tests.rs`).
|
||||
- Every workspace crate manifest carries `[lints] workspace = true` to inherit `[workspace.lints]`; every production crate root carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
|
||||
- **Last-resort guard:** `Session::execute` and every step of an interactive command (its factory, `begin`, input, `prompt` and `preview`; `crates/engine/src/guard.rs`) run under `catch_unwind`; an escaped panic restores the drawing and reports an error.
|
||||
- **Prove it:** every crash fix lands with a regression test (see `hostile_params_never_panic`, `hostile_dxf_does_not_panic`).
|
||||
- Every production crate root carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
|
||||
|
||||
## Non-negotiables
|
||||
- **Clean-room.** AutoCAD is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic drawings, take screenshots (by window id) stored only under `plan/autocad/screenshots/` (never committed). **Never** read, disassemble or copy anything inside the AutoCAD bundle (names/listings only); never copy Autodesk fonts (SHX), hatch patterns (`.pat`), linetypes (`.lin`), templates, CUI/PGP files, icons, artwork or help text; never commit files produced by AutoCAD. File formats come from public specifications (Autodesk's published DXF Reference, the Open Design Alliance's public DWG specification) and our own synthetic tests. Never copy or link GPL/LGPL/AGPL code (LibreDWG, LibreCAD, QCAD, FreeCAD's planegcs, SolveSpace, OpenCASCADE) — don't even read their sources.
|
||||
- **Assets — absolutely essential.** CADCraft contains **no Autodesk, Adobe or Avid iconography, images, fonts, patterns or artwork — ever.** Every icon is drawn in code (`crates/ui-egui/src/icons.rs`), every hatch pattern and linetype is our own definition (`crates/doc/src/library.rs`), the drafting font is our own (`crates/fonts/src/stroke.rs`). Any file asset must be original, public domain/CC0, OSI-licensed, or redistributable Creative Commons (or licensed open source by a contributor who made it), and **must have a row in `ATTRIBUTION.md`** with author, source and licence (`cargo xtask assets` enforces it). The only exception is the ArtCraft brand in `docs/brand/` (trademarks, `docs/brand/LICENSE-brand.txt`). Screenshots of Autodesk software are never committed. Breaking this rule is the most serious mistake you can make in this repo.
|
||||
- **Fonts live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)**, never in this repo. It is an optional build input (`CRAFT_FONTS_DIR`, embedded by `crates/fonts/build.rs`), never a `Cargo.toml` dependency. Code and tests must work without it.
|
||||
- **Fonts live in [`storytold/craft-fonts`](https://github.com/storytold/craft-fonts)**, never in this repo. It is an optional build input (`CRAFT_FONTS_DIR`), never a `Cargo.toml` dependency. Code and tests must work without it.
|
||||
- **Shared test corpora** live in separate repos (`storytold/<app>-corpus`); never commit large binary fixtures here.
|
||||
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` (`CommandSpec`: id = AutoCAD's command name in lower case, label, menu path, shortcut, aliases, params doc, `enabled`, JSON `run`, optional `interactive` prompt machine) + tests. UI-only commands live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). The command line, menus, toolbar, Tool Sets, scripts, CLI, control channel and MCP all reach the same commands.
|
||||
- **Programmatic calls never open dialogs.** `engine.execute` runs the JSON form with defaults; only menu/toolbar invocation starts the interactive prompt sequence.
|
||||
@@ -44,9 +46,4 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr
|
||||
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own; delete your target dir when done (disk).
|
||||
|
||||
## Roadmap
|
||||
`ROADMAP.md` (committed) is the one-page summary: stage, parity numbers, estimates. The assessment is `docs/target-app-parity.md`, the work list `docs/gaps.md` (pick work from its top rows), milestones `docs/roadmap.md`, deep checklists `docs/geometry-parity.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. PRs don't edit these docs: name the affected rows in the PR description, and maintainers update the rows, status lines and revision history in batches, which keeps open PRs from conflicting. `cargo xtask parity` regenerates the menu-breadth checklist in `docs/parity-checklist.md`.
|
||||
|
||||
## CI workflows
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
`ROADMAP.md` (committed) is the one-page summary: stage, parity numbers, estimates. It follows craftrules' `standards/progress-docs.md`: the assessment is `docs/target-app-parity.md`, the work list `docs/gaps.md` (pick work from its top rows), milestones `docs/roadmap.md`, deep checklists `docs/geometry-parity.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md`. When work lands, update the affected rows and the docs' status lines and revision history. `cargo xtask parity` regenerates the menu-breadth checklist in `docs/parity-checklist.md`.
|
||||
|
||||
Generated
+314
-1728
File diff suppressed because it is too large
Load Diff
+8
-65
@@ -4,10 +4,10 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
# default-members
|
||||
|
||||
[workspace.package]
|
||||
version = "0.17.0"
|
||||
version = "0.5.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
rust-version = "1.90"
|
||||
repository = "https://github.com/storytold/cadcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
@@ -24,52 +24,23 @@ cadcraft-io = { path = "crates/io" }
|
||||
cadcraft-engine = { path = "crates/engine" }
|
||||
cadcraft-ui-egui = { path = "crates/ui-egui" }
|
||||
cadcraft-mcp = { path = "crates/mcp" }
|
||||
# Which paths stored in a drawing (xrefs, data links) may be opened.
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
# PDF writing (krilla) for plots, and reading them back in tests. The root patches below pin
|
||||
# craft-pdf's patched backends to the same revision (Cargo ignores dependency-local patches).
|
||||
craft-pdf = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", default-features = false }
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
clap_complete = "4.6.11"
|
||||
clap_mangen = { version = "0.3.3", features = ["env"] }
|
||||
|
||||
# foundation
|
||||
thiserror = "2"
|
||||
same-file = "1"
|
||||
serde = { version = "1", features = ["derive", "rc"] }
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
log = "0.4"
|
||||
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
|
||||
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
|
||||
rayon = "1"
|
||||
|
||||
# rendering / formats
|
||||
tiny-skia = { version = "0.12", default-features = false, features = ["std", "simd", "png-format"] }
|
||||
lyon_tessellation = "1.0.22"
|
||||
skrifa = "0.48"
|
||||
# Text shaping (OpenType GSUB/GPOS, HarfBuzz port on read-fonts; egui's epaint is still on 0.12,
|
||||
# so the graph has both) and the Unicode Bidirectional Algorithm for right-to-left drawing text.
|
||||
harfrust = "0.14"
|
||||
unicode-bidi = "0.3.18"
|
||||
# Line-break opportunities between Thai words for MTEXT word wrap (Thai writes no spaces between
|
||||
# words): UAX #14 with ICU4X's compiled-in LSTM word model (not its dictionaries).
|
||||
icu_segmenter = { version = "~2.3", default-features = false, features = ["compiled_data", "lstm"] }
|
||||
tiny-skia = { version = "0.11", default-features = false, features = ["std", "simd", "png-format"] }
|
||||
skrifa = "0.47"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
||||
encoding_rs = "0.8.35"
|
||||
# XML for the SVG reader (streaming parser, no entity expansion beyond the five predefined ones)
|
||||
quick-xml = "0.42"
|
||||
|
||||
# ui
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] }
|
||||
craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
|
||||
egui = { version = "0.37", default-features = false, features = ["default_fonts", "monochrome_emoji_fonts"] }
|
||||
egui_kittest = "0.37"
|
||||
eframe = { version = "0.37", default-features = false }
|
||||
egui-wgpu = "0.37"
|
||||
# Monotonic time on native and web (performance.now on wasm).
|
||||
web-time = "1.1"
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
|
||||
egui = "0.36"
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
egui-wgpu = "0.36"
|
||||
rfd = "0.17"
|
||||
arboard = { version = "3.6", default-features = false }
|
||||
|
||||
@@ -77,47 +48,19 @@ arboard = { version = "3.6", default-features = false }
|
||||
unsafe_code = "forbid"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
cognitive_complexity = "deny"
|
||||
too_many_arguments = "deny"
|
||||
wildcard_imports = "deny"
|
||||
dbg_macro = "warn"
|
||||
todo = "warn"
|
||||
too_many_arguments = "allow"
|
||||
type_complexity = "allow"
|
||||
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
debug = false
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 1
|
||||
debug = "line-tables-only"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = "thin"
|
||||
codegen-units = 16
|
||||
debug = 0
|
||||
|
||||
# PDF backends for craft-pdf: pinned to the same reviewed craft-libs revision as craft-pdf itself
|
||||
# (Cargo ignores dependency-local patches).
|
||||
[patch.crates-io]
|
||||
krilla = { version = "=0.8.2", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
|
||||
|
||||
# Keep the complete egui family and native event bridge on reviewed fork commits.
|
||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
|
||||
|
||||
@@ -15,36 +15,10 @@ Mozilla Public License 2.0 and is used unmodified as a separate library (crates/
|
||||
is available from crates.io and the repository above.
|
||||
|
||||
|
||||
CADCraft's icons, drafting font, hatch patterns and linetypes are drawn or defined in code.
|
||||
The UI also bundles the egui font inputs described below. The per-file list is in ATTRIBUTION.md.
|
||||
Builds made with the optional craft-fonts input (CRAFT_FONTS_DIR) embed the fonts listed in its
|
||||
ATTRIBUTION.md, each under the SIL Open Font License 1.1.
|
||||
None: every bundled asset is original to CADCraft (icons, drafting font, hatch patterns and
|
||||
linetypes are drawn or defined in code). The per-file list is in ATTRIBUTION.md. Builds made with
|
||||
the optional craft-fonts input (CRAFT_FONTS_DIR) embed the fonts listed in its ATTRIBUTION.md,
|
||||
each under the SIL Open Font License 1.1.
|
||||
|
||||
CADCraft is an independent project and is not affiliated with, sponsored by or endorsed by
|
||||
Autodesk, Inc. AutoCAD and DWG are trademarks of Autodesk, Inc.
|
||||
|
||||
|
||||
The PDF writer in the pinned craft-pdf 0.1.0 / Krilla 0.8.2 dependency embeds four Compact ICC
|
||||
Profiles by Clinton Ingram, under CC0-1.0. They are unmodified dependency assets; no profile file
|
||||
is copied into this repository. Per-file sources and SHA-256 hashes are in ATTRIBUTION.md.
|
||||
Source: https://github.com/LaurenzV/krilla/tree/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc
|
||||
(pinned through https://github.com/storytold/craft-libs/tree/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/vendor/krilla/icc).
|
||||
CC0 1.0 Universal: https://creativecommons.org/publicdomain/zero/1.0/
|
||||
Pinned licence text: https://github.com/storytold/craft-libs/blob/9f89fe8f6d72ab4db4cc06f186381a6a254463ab/vendor/krilla/icc/LICENSE
|
||||
|
||||
The UI's egui-icons.ttf subset and emoji-icon-font.ttf fallback are provided by epaint_default_fonts
|
||||
and registered through egui's default font definitions. They derive from emoji-icon-font by
|
||||
John Slegers, under the MIT License. The subset and full face share the following upstream notice.
|
||||
Source: https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts
|
||||
Original: https://github.com/jslegers/emoji-icon-font
|
||||
The following text is reproduced verbatim from emoji-icon-font-mit-license.txt:
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2014 John Slegers
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
@@ -7,18 +7,14 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.cadcraft.png" width="128" height="128" alt="CADCraft icon: a drafting set square and compass arc on a blue grid tile">
|
||||
</p>
|
||||
|
||||
<h1 align="center">CADCraft</h1>
|
||||
|
||||
<p align="center">
|
||||
<b>Computer-aided design and drafting; an open-source, modern 2D and 3D CAD application, built in pure Rust.</b>
|
||||
<b>Computer-aided design and drafting; an open-source, clean-room reimplementation of Autodesk AutoCAD, rebuilt in pure Rust.</b>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
A fast, open-source CAD workflow: the command line, object snaps, layers,
|
||||
A fast, open-source take on the AutoCAD workflow: the command line, object snaps, layers,
|
||||
dimensions, hatches, blocks and DXF drawings you already know. It runs natively on macOS,
|
||||
Windows, Linux and FreeBSD, and in the browser via WebAssembly.<br>
|
||||
<i>By the ArtCraft team.</i>
|
||||
@@ -30,9 +26,6 @@
|
||||
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%2F%20Apache--2.0-0b6f88?style=flat-square">
|
||||
<img alt="Agent-drivable over MCP" src="https://img.shields.io/badge/agents-MCP-14a3c7?style=flat-square">
|
||||
<img alt="Status: early development" src="https://img.shields.io/badge/status-early%20development-f07a3a?style=flat-square">
|
||||
<br>
|
||||
<a href="https://sonarcloud.io/project/overview?id=storytold_cadcraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
|
||||
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/cadcraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -118,13 +111,11 @@ numbers):
|
||||
| Layouts & plotting | Paper-space layouts, viewports (scale, lock, per-viewport layer freeze), MSPACE/PSPACE through viewports, page setups, PLOT and EXPORTPDF |
|
||||
| Parametric | Geometric and dimensional constraints, AUTOCONSTRAIN, PARAMETERS with expressions, conflict detection; constraints re-solve after every edit |
|
||||
| Grips | Hot grips with Space to cycle Stretch, Move, Rotate, Scale and Mirror |
|
||||
| Interface languages | English, Ukrainian (Українська), Brazilian Portuguese (Português (Brasil)), German (Deutsch), Spanish (Español), Czech (Čeština), Russian (Русский), French (Français), Simplified Chinese (简体中文, with an installed CJK font), Italian (Italiano), Korean (한국어, with an installed Hangul font), Swedish (Svenska), Arabic (العربية, with an installed Arabic font), Hungarian (Magyar) and Turkish (Türkçe), live switching and a remembered Auto/system-language choice; [details](docs/localization.md) |
|
||||
| Interface languages | English and Ukrainian (Українська), live switching and a remembered Auto/system-language choice; [details](docs/localization.md) |
|
||||
| Automation | MCP server, JSON control channel, `cadcraft-cli` (info, convert, run, commands, mcp) |
|
||||
|
||||
## Quick start
|
||||
|
||||
CADCraft requires Rust 1.95 or later.
|
||||
|
||||
```sh
|
||||
git clone https://github.com/storytold/cadcraft
|
||||
cd cadcraft
|
||||
@@ -140,17 +131,11 @@ offset 0.25 then pick the circle and a side
|
||||
zoom e zoom to extents
|
||||
```
|
||||
|
||||
The web build: `cd apps/cadcraft-web && trunk serve` (needs [trunk](https://github.com/trunk-rs/trunk)).
|
||||
The web build: `cd apps/cadcraft-web && trunk serve` (needs [trunk](https://trunkrs.dev)).
|
||||
|
||||
### Fonts
|
||||
|
||||
CADCraft has no fonts in its repository; it uses the TrueType/OpenType fonts installed on your
|
||||
system. Builds made with the optional [craft-fonts](https://github.com/storytold/craft-fonts)
|
||||
input (`CRAFT_FONTS_DIR=<absolute path to a checkout> cargo build --release`) also embed its
|
||||
fonts: BIZ UDPGothic, BIZ UDMincho and Shippori Mincho (Japanese) and Noto Sans Arabic (the web
|
||||
build, which has no system fonts, only BIZ UDPGothic Regular and Noto Sans Arabic). A text style
|
||||
can use them by family name, and they draw characters a text's font lacks when no installed
|
||||
wide-coverage CJK font has them. Without `CRAFT_FONTS_DIR` nothing is embedded. SHX
|
||||
CADCraft never ships fonts; it uses the TrueType/OpenType fonts installed on your system. SHX
|
||||
fonts (including big fonts such as `chineset.shx`) aren't read yet: text in an SHX style is drawn
|
||||
with our own stroke font, and any character a text's font lacks (CJK, for example) is drawn from
|
||||
an installed font that has it, preferring wide-coverage fonts such as Noto Sans CJK, Source Han
|
||||
@@ -164,26 +149,6 @@ run) an environment variable, steer this:
|
||||
|
||||
`.` clears either one.
|
||||
|
||||
**Your own fonts.** Any `.ttf`, `.otf` or `.ttc` font works: install it in the system font folder
|
||||
(Windows `C:\Windows\Fonts` or per-user fonts, macOS `/Library/Fonts` or `~/Library/Fonts`,
|
||||
Linux and the BSDs `/usr/share/fonts`, `/usr/local/share/fonts`, `~/.local/share/fonts` or
|
||||
`~/.fonts`) and name it in a text style (`STYLE`, or `style {"name":"FA","font":"Vazirmatn"}`)
|
||||
by file name (`Vazirmatn-Regular.ttf`) or family name (`Vazirmatn`), or inline in MTEXT with
|
||||
`\fVazirmatn;`. Restart CADCraft after installing a font: the font folders are scanned once.
|
||||
|
||||
**Right-to-left text (Persian, Arabic, Urdu, Hebrew).** TEXT and MTEXT lines are laid out with the
|
||||
Unicode Bidirectional Algorithm (the paragraph direction comes from its first letter, so numbers
|
||||
and Latin words inside Persian text keep their own order), and Arabic-script text is shaped with
|
||||
OpenType (contextual letter forms, lam-alef and other ligatures, ZWNJ / ZWJ, mark positioning).
|
||||
Justification (left, centre, right, MTEXT attachment and wrapping) still refers to the text's box.
|
||||
Our stroke font has no Arabic letters, so Persian in a stroke-font style is drawn from an
|
||||
installed font that has them: Vazirmatn, Noto Naskh Arabic, Noto Sans Arabic, Segoe UI, Tahoma,
|
||||
Arial and DejaVu Sans are looked for first. To choose the font, install a free Persian font such
|
||||
as [Vazirmatn](https://github.com/rastikerdar/vazirmatn) (SIL Open Font License) and either use it
|
||||
in the text style or set `FONTFALLBACK` (`CADCRAFT_FONTFALLBACK="Vazirmatn"`). Formatting changes
|
||||
inside a word (an MTEXT colour change in the middle of a Persian word) break the joining at that
|
||||
point.
|
||||
|
||||
### Logs
|
||||
|
||||
The desktop app writes its `log` records to standard error and to `logs/cadcraft.log` in the
|
||||
@@ -196,7 +161,7 @@ crashed survives the next start. The file stops growing at 16 MiB. `--version` w
|
||||
|
||||
| Variable | Effect |
|
||||
|---|---|
|
||||
| `CADCRAFT_CONFIG_DIR` | Use this directory instead of the platform settings directory (the log goes to its `logs/`); empty means no settings folder (no log file, autosave, workspace or plotters) |
|
||||
| `CADCRAFT_CONFIG_DIR` | Use this directory instead of the platform settings directory (the log goes to its `logs/`) |
|
||||
| `RUST_LOG` | Log levels for standard error and the log file. Default: `info` for CADCraft's own crates, `warn` for everything else. env_logger-style directives replace that, e.g. `RUST_LOG=debug`, `RUST_LOG=warn,cadcraft_io=trace` or `RUST_LOG=info,wgpu_core=warn`; a directive ending in `*` covers every target starting with it (`cadcraft*=debug`). `cadcraft_engine=debug` also logs every command-line message. |
|
||||
|
||||
The logger is `apps/cadcraft/src/logging.rs`; the web build logs to the browser console instead.
|
||||
@@ -218,10 +183,6 @@ cadcraft-cli convert drawing.dxf drawing.svg
|
||||
cadcraft-cli convert drawing.dwg detail.png --window 0,0,20000,15000
|
||||
cadcraft-cli run drawing.dwg --cmd 'zoom {"mode":"window","p1":[0,0],"p2":[20000,15000]}' --export detail.png
|
||||
cadcraft-cli run drawing.dwg --cmd 'select {"window":[[0,0],[20000,15000]]}' --cmd 'zoom {"mode":"object"}' --export sel.png
|
||||
|
||||
# Act on what an earlier --cmd made: {last} is the last handle it returned.
|
||||
cadcraft-cli run --metric --cmd 'layout.set {"name":"Layout1"}' \
|
||||
--cmd 'mview {"p1":[20,20],"p2":[200,150]}' --cmd 'viewport.set {"handle":"{last}","scale":1}'
|
||||
```
|
||||
|
||||
How image exports (PNG 2400×1600, SVG, PDF) are framed: `convert` fits the model-space extents.
|
||||
@@ -230,22 +191,11 @@ How image exports (PNG 2400×1600, SVG, PDF) are framed: `convert` fits the mode
|
||||
rectangle, centred and scaled without distortion; `--view extents` or `--view current` forces
|
||||
either framing. To frame a selection set, `zoom {"mode":"object"}` after selecting.
|
||||
|
||||
When reading a file leaves something out (records a DXF reader can't place, object records of a
|
||||
damaged DWG), `info` reports it (`skipped`, `readNotes`), `convert` warns on stderr and `run`
|
||||
prints the DWG notes with its log; the exit code stays 0. MCP `open` replies with the notes as
|
||||
`warnings`.
|
||||
|
||||
MCP tools include `command_line` (type at the prompt), `execute` (any command with JSON),
|
||||
`inspect_drawing`, `query_entities`, `render` (returns a PNG) and, when connected to the app,
|
||||
`screenshot` and `ui_click`. The desktop app's JSON control channel is documented in
|
||||
[docs/control-protocol.md](docs/control-protocol.md).
|
||||
|
||||
To run LISP routines, scripts and alias files, or to drive CADCraft from your own programs, see
|
||||
[docs/customization.md](docs/customization.md).
|
||||
|
||||
`cadcraft-cli --help` documents every subcommand and flag; exit codes, streams, environment
|
||||
variables and its RFC-0012 conventions are in [docs/cli.md](docs/cli.md).
|
||||
|
||||
## Architecture
|
||||
|
||||
```text
|
||||
@@ -263,35 +213,10 @@ apps: cadcraft · cadcraft-cli · cadcraft-web
|
||||
Nothing below `ui-egui` knows about egui, so the front end can be replaced. `cargo xtask ci`
|
||||
checks formatting, clippy, tests, asset attribution, the crate layering and the wasm build.
|
||||
|
||||
Drafting and layer-palette icon buttons use the pinned `craft-ui` button component for interaction,
|
||||
accessible labels and keyboard focus. CADCraft keeps its multicolor icon painter, dark palette,
|
||||
disabled tint and tool commands. The native host saves workspace visibility and toolset choices
|
||||
atomically; `CADCRAFT_CONFIG_DIR` selects an isolated profile. Compact toolbar groups move into a
|
||||
More menu, and status readouts truncate with a full-value tooltip before they reach layout tabs.
|
||||
Painted toolbar and tab controls
|
||||
have accessible labels and visible keyboard focus. Property controls remain application-specific.
|
||||
|
||||
## Accessibility
|
||||
|
||||
### System cursor
|
||||
|
||||
Over the drawing area CADCraft hides the system cursor and draws its own crosshair. Tools that follow the system cursor (Windows Magnifier set to follow or centre the mouse pointer, other screen magnifiers, screen recorders, remote desktops) lose track of it there. Turn on **View ▸ Accessibility ▸ Show System Cursor** to keep a small system crosshair visible at the centre of the drawn one, or start CADCraft with the environment variable `CADCRAFT_SYSTEM_CURSOR=1` (on Windows, set it once with `setx CADCRAFT_SYSTEM_CURSOR 1` and start CADCraft again). Agents can switch it with the control channel: `ui.set {"systemCursor": true}`.
|
||||
|
||||
### Animation effects
|
||||
|
||||
CADCraft follows the operating system's animation setting. When the system is set to show no animations, dialogs, menus and tooltips appear at once instead of fading in, collapsing sections and scroll bars change at once, and the mouse wheel scrolls lists a whole step at a time instead of gliding. Zooming the drawing with the wheel always moves a whole step; with Ctrl held (a touchpad pinch, on Windows) it glides to the new zoom only while animation effects show.
|
||||
|
||||
| System | Where the setting is |
|
||||
|---|---|
|
||||
| Windows | Settings ▸ Accessibility ▸ Visual effects ▸ Animation effects |
|
||||
| macOS | System Settings ▸ Accessibility ▸ Reduce motion |
|
||||
| Linux, BSD | the desktop's reduced-motion (or animations) setting, read through the XDG Desktop Portal |
|
||||
| Web | the browser's `prefers-reduced-motion` |
|
||||
|
||||
A change to the system setting is picked up while CADCraft runs, within a few seconds. To choose for CADCraft alone, use **View ▸ Accessibility ▸ Animation Effects**: **Use System Setting** (the default), **On** or **Off**. Options ▸ Display has the same choice. Agents can set it with the control channel: `ui.set {"animations": "off"}` (`"system"`, `"on"` or `"off"`), and `ui.inspect` reports whether animations show (`animations`).
|
||||
|
||||
Drafting feedback is not an animation effect and stays as it is: rubber bands and previews, snap markers, the pause that acquires an object snap tracking point, the text cursor's blink, the spinner shown while a drawing opens, and pan and zoom, which follow the pointer and the wheel directly.
|
||||
|
||||
## Roadmap
|
||||
|
||||
See [ROADMAP.md](ROADMAP.md) for the stage, measured parity and the estimate of remaining work, and [docs/gaps.md](docs/gaps.md) for everything still missing.
|
||||
@@ -300,14 +225,6 @@ See [ROADMAP.md](ROADMAP.md) for the stage, measured parity and the estimate of
|
||||
|
||||
**Download CADCraft** from GitHub: the [latest release](https://github.com/storytold/cadcraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/cadcraft/releases) has earlier versions and their notes. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
|
||||
|
||||
> [!WARNING]
|
||||
> Download official CADCraft builds from the [storytold/cadcraft releases](https://github.com/storytold/cadcraft/releases). Verify that the repository owner is `storytold`. Before you run a download, check that its checksum matches the line for that file in the release's `SHA256SUMS.txt`:
|
||||
>
|
||||
> ```sh
|
||||
> shasum -a 256 cadcraft-<ver>-macos-universal.dmg # macOS (on Linux: sha256sum FILE)
|
||||
> Get-FileHash .\cadcraft-<ver>-windows-x64.msi # Windows PowerShell
|
||||
> ```
|
||||
|
||||
### Windows
|
||||
|
||||
| Build | Installer | Portable |
|
||||
@@ -319,7 +236,6 @@ See [ROADMAP.md](ROADMAP.md) for the stage, measured parity and the estimate of
|
||||
Installers and executables are code-signed.
|
||||
|
||||
The installer adds CADCraft to the Start menu and asks whether to add a desktop shortcut too (unticked by default). For a silent install with the desktop shortcut: `msiexec /i cadcraft-<ver>-windows-x64.msi /qn DESKTOPSHORTCUT=1`.
|
||||
A silent install is per-user by default; add `ALLUSERS=1`, from an elevated terminal or deployment tool, for an all-users install.
|
||||
|
||||
**If the app doesn't open on Windows:** the desktop app initializes only DirectX 12 by default.
|
||||
Letting wgpu also create an OpenGL instance can crash some graphics drivers (AMD's
|
||||
@@ -382,15 +298,7 @@ stand on its own.
|
||||
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/figcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.figcraft.png" alt="" width="32" height="32"> | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/wordcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png" alt="" width="32" height="32"> | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.cadcraft.png" alt="" width="32" height="32"> | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/gridcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.gridcraft.png" alt="" width="32" height="32"> | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/texturecraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.texturecraft.png" alt="" width="32" height="32"> | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/craft-anything/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.craft-anything.png" alt="" width="32" height="32"> | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) |
|
||||
| <img src="https://raw.githubusercontent.com/tangency-labs/solidcraft/main/assets/icons/app/linux/exports/hicolor/64x64/apps/io.tangencylabs.solidcraft.png" alt="" width="32" height="32"> | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/cadcraft-64.png" alt="" width="32" height="32"> | **CADCraft** | **Computer-aided design and drafting · you are here** | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
|
||||
|
||||
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
|
||||
|
||||
@@ -432,9 +340,9 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
|
||||
part of this repository and CADCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
|
||||
Forks and modified versions must remove them.
|
||||
|
||||
<sub>Autodesk, AutoCAD and DWG are trademarks or registered trademarks of Autodesk, Inc. in the United States and/or other countries. CADCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Autodesk, Inc.; these names are used only to describe the workflows and file formats it is compatible with.</sub>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
|
||||
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
|
||||
</p>
|
||||
|
||||
Tool Sets, Layers, and Properties can be regrouped, split, floated within the app window, closed, and reopened. The drawing canvas stays visible and cannot be closed or turned into a tab. Drag a panel header to a target edge or another header. Its context menu offers Float panel, Move to group, and Close panel. Existing panel toggles reopen panels at their saved location; Reset Panel Layout restores the app layout. Floating panels remain inside the current app window.
|
||||
|
||||
+10
-23
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: pre-alpha** · next: alpha, ~2 points (38% → 40% ready for real work) and ~125–190 Opus 5.5 hours away (the four failing core workflows of the [alpha gate](docs/roadmap.md#alpha-gate))
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** trivial (progress log: proxy objects keep their class through a save, #1812; previously minor: HATCHEDIT boundary add/remove progress) · **Target:** Autodesk AutoCAD 2027
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% for each readiness number) · **Target:** Autodesk AutoCAD 2027
|
||||
|
||||
CADCraft targets full parity with AutoCAD (2D drafting first, then annotation, layouts and
|
||||
plotting, DWG, parametrics and 3D), plus what AutoCAD doesn't have: agent control over MCP, a
|
||||
@@ -10,8 +10,8 @@ scriptable CLI, a web build, Linux and FreeBSD builds, and a free licence.
|
||||
|
||||
**Why pre-alpha:** four of AutoCAD's five core workflows fail the [alpha gate](docs/roadmap.md#alpha-gate).
|
||||
Precise drafting lacks object snap tracking and has stubbed TRIM/EXTEND/OFFSET options; blocks
|
||||
can't be edited in a block editor (REFEDIT edits them in place, xrefs not yet); plots ignore plot styles; and a DWG saved by
|
||||
CADCraft drops everything it doesn't model and has never been checked in
|
||||
can't be edited (no block editor, REFEDIT or xrefs); plots ignore plot styles; and a DWG saved by
|
||||
CADCraft drops everything it doesn't model, is always R2000 and has never been checked in
|
||||
AutoCAD. Dimensioning passes. Ready for real work (≈ 38%) is also under the ~40% alpha bar. Beta
|
||||
is ≈ 600–930 h away.
|
||||
|
||||
@@ -80,8 +80,8 @@ Hours are Opus 5.5 agent wall-clock hours, calibrated on this repo's own build:
|
||||
|
||||
## Languages
|
||||
|
||||
AutoCAD 2027 for Mac ships 8 interface languages; CADCraft ships English plus partial
|
||||
Ukrainian and German. Detail: [localization-parity.md](docs/localization-parity.md).
|
||||
AutoCAD 2027 for Mac ships 8 interface languages; CADCraft ships English only. Detail:
|
||||
[localization-parity.md](docs/localization-parity.md).
|
||||
|
||||
| Language | Status | UI translated |
|
||||
|---|---|---:|
|
||||
@@ -94,12 +94,11 @@ Ukrainian and German. Detail: [localization-parity.md](docs/localization-parity.
|
||||
| Portuguese | none | 0% |
|
||||
| Indonesian | none | 0% |
|
||||
| Japanese | none | 0% |
|
||||
| Greek | complete current catalog | 1,330 current source keys; independent proofreading pending |
|
||||
| German | partial | ≈ 100% of the catalog (989 rows) |
|
||||
| German | none | 0% |
|
||||
| Korean | none | 0% |
|
||||
| Vietnamese | none | 0% |
|
||||
|
||||
Other languages shipped: Ukrainian and German (989 catalog rows each; Ukrainian landed with #36), Brazilian Portuguese, Spanish, Czech, Russian, French, Simplified Chinese and Italian (1,227 rows, 2026-10-12); engine messages stay English.
|
||||
Other languages shipped: Ukrainian, partial (936 catalog rows, landed with #36; engine messages stay English).
|
||||
|
||||
## Upcoming
|
||||
|
||||
@@ -119,14 +118,10 @@ Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md)
|
||||
|
||||
| Date | Entry |
|
||||
|---|---|
|
||||
| 2026-10-21 | DXF save keeps each proxy object's and proxy entity's class: its class number (group 91) is renumbered to the rewritten CLASSES instead of naming another class, so AutoCAD's visual styles in an R14 file no longer come back as SCALE or MLEADERSTYLE (#1812) |
|
||||
| 2026-10-14 | HATCHEDIT adds boundaries from internal points or closed objects and removes selected loops in the dialog, command line and JSON command; associative links and undo are covered by tests. |
|
||||
| 2026-10-12 | TRIM: a pick after a closed polyline's last cut keeps only the complementary run instead of an extra circuit of the outline (#726) |
|
||||
| 2026-10-12 | HATCHEDIT refuses an explicit bad `scale` or `angle` before it edits anything, so a rejected request leaves the drawing, undo and redo as they were (#724) |
|
||||
| 2026-10-10 | Merged main: Ukrainian interface (#36, first string catalog), Dynamic Input pointer boxes with Tab and relative entry (#60), press-drag selection windows (#63), WIPEOUT and hatch gradients kept through DXF/DWG save (#147, #162), SETVAR DIM* as style overrides (#129), UNITS dialog (#38), close-window save prompt (#47), DIMANGULAR on circles (#20), cargo fmt fixed on main. UI/UX 40% → 43% (dynamic input 15% → 45%), localization 3% → 5%; ready for real work ≈ 37% → 38% (weighted 37.2% → 37.7%), mainstream 27% → 28%, essentials 45% → 46%. Beta is now ~37 points away; hours unchanged within rounding. |
|
||||
| 2026-10-10 | Added mainstream practitioner (≈ 27%) and essentials user (≈ 45%) numbers; ready for real work unchanged at ≈ 37%. User evidence: 2 praise comments, 0 "switched from AutoCAD" reports, 27 of 37 open issues on the core path. |
|
||||
| 2026-10-10 | Stage re-normalized from alpha to **pre-alpha** under the core-workflow gate: drafting with tracking, blocks, plotting and lossless DWG save fail; dimensioning passes. Alpha is ≈ 125–190 h away. |
|
||||
| 2026-10-10 | Full re-measure against AutoCAD 2027 for Mac: menu breadth 245/491 (50%), ready for real work ≈ 37%, stage alpha. Remaining estimates rose (≈ 570 h → 1,300–2,100 h) on new evidence: behaviour audits found tracking/extension/parallel snaps and dynamic input are not real, saves drop unmodelled content, DWG saves as R2000, and the old estimate had no localization, ecosystem, stability, platform or AI rows. New docs: target-app-parity, gaps, roadmap, architecture, localization, file-format, hardware, UI and geometry parity; `docs/parity.md` became `docs/parity-checklist.md`. |
|
||||
| 2026-10-10 | Stage re-normalized from alpha to **pre-alpha** under the craftrules core-workflow gate: drafting with tracking, blocks, plotting and lossless DWG save fail; dimensioning passes. Alpha is ≈ 125–190 h away. |
|
||||
| 2026-10-10 | Full re-measure against AutoCAD 2027 for Mac in the craftrules progress-docs format: menu breadth 245/491 (50%), ready for real work ≈ 37%, stage alpha. Remaining estimates rose (≈ 570 h → 1,300–2,100 h) on new evidence: behaviour audits found tracking/extension/parallel snaps and dynamic input are not real, saves drop unmodelled content, DWG saves as R2000, and the old estimate had no localization, ecosystem, stability, platform or AI rows. New docs: target-app-parity, gaps, roadmap, architecture, localization, file-format, hardware, UI and geometry parity; `docs/parity.md` became `docs/parity-checklist.md`. |
|
||||
| 2026-10-10 | About 60 community PRs merged in a day: DXF fidelity (transparency, LWPOLYLINE elevation, frozen VP layers, dimension text rotation, arc-length dims, non-ASCII escapes), CJK font fallback, Save/Don't Save on close, Light/System themes, F2 history, trackpad pan, Linux XWayland drag-and-drop, correctness fixes across draw/modify/constraints. Release v0.4.0. |
|
||||
| 2026-10-08 | Releases v0.1.0–v0.3.0: Flatpak, AppImage updates, signed macOS universal, Windows x64/x86/arm64, Linux, FreeBSD and web builds. |
|
||||
| 2026-10-07 | Old assessment: menu breadth 233/491 (47%), ≈ 29% weighted parity, 2D drafting ≈ 55%, ≈ 570 h remaining; milestones as reported then: M1 ~85%, M2 ~65%, M3 ~75%, M4 ~50%, M5 ~35%, M6 ~55%, M7 ~55%, M8 ~20%, M9 ~60%, M10 ~70%, M11 0%, M12 ~25%, M13 ~10%. |
|
||||
@@ -136,17 +131,9 @@ Ranked; detail and the full beta checklist in [docs/roadmap.md](docs/roadmap.md)
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-21 | trivial | Progress log: proxy objects keep their class through a save (#1812) |
|
||||
| 2026-10-14 | minor | Progress log: HATCHEDIT boundary add/remove |
|
||||
| 2026-10-12 | trivial | Progress log: closed-polyline TRIM (#726) |
|
||||
| 2026-10-12 | trivial | Progress log: HATCHEDIT numeric validation (#724) |
|
||||
| 2026-10-10 | minor | Merged main's changes (Ukrainian catalog, dynamic input pointer boxes, DXF fixes); UI/UX and localization numbers updated |
|
||||
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% for each number |
|
||||
| 2026-10-10 | minor | Mainstream practitioner and essentials user headline numbers |
|
||||
| 2026-10-10 | minor | Stage alpha → pre-alpha under the core-workflow gate; banner, why, headline row |
|
||||
| 2026-10-10 | major | Progress docs restructured (stage, two numbers, dimensions, features, languages, upcoming, log); full re-measure; milestone detail moved to docs/roadmap.md, parity assessment to docs/target-app-parity.md |
|
||||
| 2026-10-10 | major | Restructured to the progress-docs standard (stage, two numbers, dimensions, features, languages, upcoming, log); full re-measure; milestone detail moved to docs/roadmap.md, parity assessment to docs/target-app-parity.md |
|
||||
| 2026-10-07 | major | Alpha checklist, milestone table, ≈ 29% weighted parity estimate |
|
||||
|
||||
## Shared UI workspace integration
|
||||
|
||||
Tool Sets, Layers and Properties use shared docking with persisted layouts, keyboard focus and compact toolbar overflow. No milestone percentage change.
|
||||
|
||||
@@ -6,14 +6,6 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
name = "cadcraft_cli"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "cadcraft-cli"
|
||||
path = "src/main.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -22,13 +14,4 @@ cadcraft-engine = { workspace = true }
|
||||
cadcraft-io = { workspace = true }
|
||||
cadcraft-mcp = { workspace = true }
|
||||
cadcraft-render = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
same-file = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# Shell completions and man pages (examples/man-and-completions.rs, for the Linux packages); not
|
||||
# in the binary.
|
||||
clap_complete = { workspace = true }
|
||||
clap_mangen = { workspace = true }
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
//! Writes cadcraft-cli's shell completions ([`clap_complete`]: bash, zsh, fish, elvish,
|
||||
//! PowerShell) and man pages ([`clap_mangen`]: `cadcraft-cli.1` and one per subcommand, such as
|
||||
//! `cadcraft-cli-convert.1`) into a directory. The Linux packages ship them
|
||||
//! (`packaging/linux/package.sh`):
|
||||
//!
|
||||
//! ```sh
|
||||
//! cargo run -p cadcraft-cli --example man-and-completions -- <out-dir>
|
||||
//! ```
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use clap::ValueEnum;
|
||||
use clap_complete::Shell;
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let mut args = std::env::args_os().skip(1);
|
||||
let (Some(dir), None) = (args.next(), args.next()) else {
|
||||
eprintln!("usage: man-and-completions <out-dir>");
|
||||
return ExitCode::from(2);
|
||||
};
|
||||
let dir = Path::new(&dir);
|
||||
match generate(dir) {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("error: {}: {e}", dir.display());
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn generate(dir: &Path) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let mut cmd = cadcraft_cli::command();
|
||||
for &shell in Shell::value_variants() {
|
||||
clap_complete::generate_to(shell, &mut cmd, "cadcraft-cli", dir)?;
|
||||
}
|
||||
// A fresh command: `clap_complete::generate_to` built `cmd`, adding the `help` subcommand,
|
||||
// which `clap_mangen` would otherwise give pages of its own (`cadcraft-cli-help-convert.1`).
|
||||
clap_mangen::generate_to(cadcraft_cli::command(), dir)
|
||||
}
|
||||
@@ -1,786 +0,0 @@
|
||||
//! The command line, as clap types. Values are parsed into their types here and the relations
|
||||
//! between flags are checked, so a bad value or combination is a usage error (exit 2) naming the
|
||||
//! flag, before any work starts; the handlers receive typed values only. A flag a subcommand
|
||||
//! doesn't take is a usage error too, so a typo can't be silently ignored. A value flag never takes
|
||||
//! the next flag as its value: a value starting with `-` is written `--save=-out.dxf` (or
|
||||
//! `./-out.dxf`).
|
||||
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use cadcraft_engine::geom::{Bounds2, Vec2};
|
||||
use cadcraft_io::Format;
|
||||
use cadcraft_mcp::args::{blank_env_is_unset, name_env_vars};
|
||||
use clap::builder::{OsStringValueParser, PathBufValueParser, TypedValueParser};
|
||||
use clap::error::ErrorKind;
|
||||
use clap::parser::ValueSource;
|
||||
use clap::{ArgAction, ArgGroup, ArgMatches, Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Help text that names the formats of `list`, built once from [`Format::READ`] or [`Format::WRITE`]
|
||||
/// so it can't drift from what the code accepts.
|
||||
fn with_formats(cell: &'static std::sync::OnceLock<String>, before: &str, list: &[Format], after: &str) -> &'static str {
|
||||
cell.get_or_init(|| format!("{before}({}){after}", Format::list(list)))
|
||||
}
|
||||
|
||||
/// The drawing an input argument names.
|
||||
fn read_help() -> &'static str {
|
||||
static C: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
||||
with_formats(&C, "The drawing to read ", Format::READ, "")
|
||||
}
|
||||
|
||||
fn open_help() -> &'static str {
|
||||
static C: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
||||
with_formats(&C, "Drawings to open ", Format::READ, "")
|
||||
}
|
||||
|
||||
fn write_help() -> &'static str {
|
||||
static C: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
||||
with_formats(&C, "The file to write; its extension picks the format ", Format::WRITE, "")
|
||||
}
|
||||
|
||||
fn save_help() -> &'static str {
|
||||
static C: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
||||
with_formats(&C, "Save the drawing to this file; its extension picks the format ", Format::WRITE, "")
|
||||
}
|
||||
|
||||
fn export_help() -> &'static str {
|
||||
static C: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
||||
with_formats(&C, "Export the drawing to this file; its extension picks the format ", Format::WRITE, "")
|
||||
}
|
||||
|
||||
fn convert_about() -> &'static str {
|
||||
static C: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
||||
with_formats(&C, "Convert a drawing; OUT's extension picks the format ", Format::WRITE, "")
|
||||
}
|
||||
|
||||
fn convert_long_about() -> &'static str {
|
||||
static C: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
||||
C.get_or_init(|| {
|
||||
format!(
|
||||
"{}\n\nImage output (png 2400x1600, svg, pdf) shows model space, fitted to the drawing extents unless\n--window or --view says otherwise.",
|
||||
convert_about()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// cadcraft-cli: headless CADCraft
|
||||
#[derive(Parser)]
|
||||
#[command(name = "cadcraft-cli", version = cadcraft_engine::build_version(), arg_required_else_help = true)]
|
||||
pub(crate) struct Cli {
|
||||
#[command(subcommand)]
|
||||
pub(crate) command: Command,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub(crate) enum Command {
|
||||
Info(InfoArgs),
|
||||
Convert(ConvertArgs),
|
||||
Run(RunArgs),
|
||||
Commands(CommandsArgs),
|
||||
Mcp(McpArgs),
|
||||
Perf(PerfArgs),
|
||||
Sample(SampleArgs),
|
||||
}
|
||||
|
||||
/// Print a drawing's summary as JSON
|
||||
#[derive(Args)]
|
||||
pub(crate) struct InfoArgs {
|
||||
#[arg(help = read_help())]
|
||||
pub(crate) file: PathBuf,
|
||||
/// One line of JSON instead of pretty-printed
|
||||
#[arg(long)]
|
||||
pub(crate) compact: bool,
|
||||
}
|
||||
|
||||
/// Convert a drawing; OUT's extension picks the format
|
||||
#[derive(Args)]
|
||||
#[command(about = convert_about(), long_about = convert_long_about())]
|
||||
pub(crate) struct ConvertArgs {
|
||||
#[arg(value_name = "IN", help = read_help())]
|
||||
pub(crate) input: PathBuf,
|
||||
#[arg(value_name = "OUT", help = write_help())]
|
||||
pub(crate) output: PathBuf,
|
||||
#[command(flatten)]
|
||||
pub(crate) framing: FramingArgs,
|
||||
/// Allow an output that is an input file, replacing the original
|
||||
#[arg(long)]
|
||||
pub(crate) in_place: bool,
|
||||
#[command(flatten)]
|
||||
pub(crate) settings: Settings,
|
||||
}
|
||||
|
||||
/// The settings the desktop app reads from its environment, for the subcommands that draw or run
|
||||
/// commands. A variable is the flag's fallback; an empty one counts as unset, except
|
||||
/// `CADCRAFT_CONFIG_DIR`.
|
||||
#[derive(Args)]
|
||||
#[command(next_help_heading = "Settings")]
|
||||
pub(crate) struct Settings {
|
||||
/// The settings folder; plotter configurations are in its `plotters` folder (default: the
|
||||
/// platform's settings folder). An empty value means no settings folder
|
||||
// Not RFC-0012 rule 1.7's "an empty variable counts as unset": an empty value is the
|
||||
// documented way to run with no settings folder (isolated runs, tests), and it predates the
|
||||
// CLI port. Keep it out of any blanket treatment of empty variables.
|
||||
#[arg(long, value_name = "DIR", env = "CADCRAFT_CONFIG_DIR", value_parser = OsStringValueParser::new().map(PathBuf::from))]
|
||||
pub(crate) config_dir: Option<PathBuf>,
|
||||
/// The font that replaces a missing one (FONTALT)
|
||||
#[arg(long, value_name = "FONT", env = "CADCRAFT_FONTALT")]
|
||||
pub(crate) fontalt: Option<String>,
|
||||
/// The fonts tried, in order, for characters a font lacks, comma-separated (FONTFALLBACK)
|
||||
#[arg(long, value_name = "FONTS", env = "CADCRAFT_FONTFALLBACK")]
|
||||
pub(crate) fontfallback: Option<String>,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// Install the settings for this run; an unset one is the default. An empty or blank
|
||||
/// `--config-dir` is no settings folder.
|
||||
pub(crate) fn apply(&self) {
|
||||
cadcraft_engine::settings_dir::set(self.settings_dir());
|
||||
cadcraft_engine::fonts::ttf::set_font_alt(self.fontalt.as_deref().unwrap_or(""));
|
||||
cadcraft_engine::fonts::ttf::set_fallback_fonts(self.fontfallback.as_deref().unwrap_or(""));
|
||||
}
|
||||
|
||||
/// The settings folder: `--config-dir`, else the platform's; `None` when the flag is empty or
|
||||
/// blank.
|
||||
fn settings_dir(&self) -> Option<PathBuf> {
|
||||
match &self.config_dir {
|
||||
Some(dir) if dir.to_str().is_some_and(|d| d.trim().is_empty()) => None,
|
||||
Some(dir) => Some(dir.clone()),
|
||||
None => cadcraft_engine::settings_dir::platform(|k| std::env::var_os(k)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// What part of model space an image shows: shared by `convert` and `run`.
|
||||
#[derive(Args)]
|
||||
pub(crate) struct FramingArgs {
|
||||
/// Show this rectangle (drawing units, x0<x1, y0<y1), centred and scaled to fit the image
|
||||
/// without distortion. A negative x0 needs the `=` form: --window=-10,0,10,5
|
||||
#[arg(long, value_name = "x0,y0,x1,y1", value_parser = parse_window, conflicts_with = "view")]
|
||||
pub(crate) window: Option<Bounds2>,
|
||||
/// What part of model space the image shows (case-insensitive)
|
||||
#[arg(long, value_name = "extents|current", value_enum, ignore_case = true)]
|
||||
pub(crate) view: Option<ViewMode>,
|
||||
}
|
||||
|
||||
/// `--view`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, ValueEnum)]
|
||||
pub(crate) enum ViewMode {
|
||||
/// Always fit the drawing extents
|
||||
Extents,
|
||||
/// The current view, as last set by zoom, pan or view.set (a freshly opened drawing's view is
|
||||
/// its extents plus a 10% margin)
|
||||
Current,
|
||||
}
|
||||
|
||||
/// Open drawings (or start a new one), run scripts and commands in order, save or export the result
|
||||
///
|
||||
/// Drawings open in the order given, and the last one is current; without any, a new imperial
|
||||
/// drawing is used. --script, --script-file and --cmd steps run in the order given, and each
|
||||
/// --cmd prints its JSON result on one line (JSON lines); a command with no result prints nothing.
|
||||
/// An image --export (png 2400x1600, svg, pdf) shows model space:
|
||||
/// the current view if the steps changed it (zoom, pan, view.set…), otherwise the drawing
|
||||
/// extents, unless --window or --view says otherwise.
|
||||
#[derive(Args)]
|
||||
#[command(group(ArgGroup::new("framing").args(["window", "view"]).multiple(true).requires("export")))]
|
||||
#[command(after_long_help = r#"--cmd chaining:
|
||||
A --cmd parameter value of exactly "{last}" is replaced with the last object handle an
|
||||
earlier --cmd in the same run returned, so a command can act on what the previous one made:
|
||||
--cmd 'mview {"p1":[20,20],"p2":[200,150]}' prints {"viewports":["100"]}
|
||||
--cmd 'viewport.set {"handle":"{last}","scale":1}' changes viewport 100
|
||||
It works in any parameter, at any depth (e.g. "handles":["{last}"]); every other string is
|
||||
passed unchanged. With no earlier returned handle the run fails and says so."#)]
|
||||
pub(crate) struct RunArgs {
|
||||
#[arg(value_name = "FILE", help = open_help())]
|
||||
pub(crate) files: Vec<PathBuf>,
|
||||
/// Open the built-in sample drawing
|
||||
#[arg(long)]
|
||||
pub(crate) sample: bool,
|
||||
/// Start a new metric drawing
|
||||
#[arg(long)]
|
||||
pub(crate) metric: bool,
|
||||
/// Command-line script text to run (`\n` separates lines)
|
||||
#[arg(long, value_name = "TEXT", action = ArgAction::Append)]
|
||||
pub(crate) script: Vec<String>,
|
||||
/// A script file (.scr) to run
|
||||
#[arg(long, value_name = "F.scr", action = ArgAction::Append)]
|
||||
pub(crate) script_file: Vec<PathBuf>,
|
||||
/// An engine command and its JSON parameters (default {}); see "--cmd chaining" in --help
|
||||
#[arg(long, value_name = "id {json}", action = ArgAction::Append, value_parser = cmd_step)]
|
||||
pub(crate) cmd: Vec<CmdStep>,
|
||||
#[arg(long, value_name = "OUT.dxf", help = save_help())]
|
||||
pub(crate) save: Option<PathBuf>,
|
||||
#[arg(long, value_name = "OUT.png", help = export_help())]
|
||||
pub(crate) export: Option<PathBuf>,
|
||||
#[command(flatten)]
|
||||
pub(crate) framing: FramingArgs,
|
||||
/// Allow an output that is an input file, replacing the original
|
||||
#[arg(long)]
|
||||
pub(crate) in_place: bool,
|
||||
#[command(flatten)]
|
||||
pub(crate) settings: Settings,
|
||||
/// The drawings, in command-line order, set by [`parse`].
|
||||
#[arg(skip)]
|
||||
pub(crate) drawings: Vec<Drawing>,
|
||||
/// The steps, in command-line order, set by [`parse`].
|
||||
#[arg(skip)]
|
||||
pub(crate) steps: Vec<Step>,
|
||||
}
|
||||
|
||||
/// A drawing `run` opens.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) enum Drawing {
|
||||
File(PathBuf),
|
||||
Sample,
|
||||
Metric,
|
||||
}
|
||||
|
||||
/// A `--script`, `--script-file` or `--cmd` of `run`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) enum Step {
|
||||
Script(String),
|
||||
ScriptFile(PathBuf),
|
||||
Cmd(CmdStep),
|
||||
}
|
||||
|
||||
/// `--cmd 'id {json}'`: an engine command and its parameters. `{last}` placeholders are filled in
|
||||
/// when it runs.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct CmdStep {
|
||||
pub(crate) id: String,
|
||||
pub(crate) params: Value,
|
||||
}
|
||||
|
||||
/// Print the command catalog as JSON
|
||||
#[derive(Args)]
|
||||
pub(crate) struct CommandsArgs {
|
||||
/// Only the commands whose id or label contains this text (case-insensitive)
|
||||
pub(crate) filter: Option<String>,
|
||||
/// One line of JSON instead of pretty-printed
|
||||
#[arg(long)]
|
||||
pub(crate) compact: bool,
|
||||
}
|
||||
|
||||
/// Run the MCP server on stdio: a headless drawing session, or a bridge to the running app
|
||||
#[derive(Args)]
|
||||
pub(crate) struct McpArgs {
|
||||
/// Bridge to the app's control channel (its --control port, e.g. 127.0.0.1:7979)
|
||||
#[arg(long, value_name = "HOST:PORT", value_parser = connect_addr)]
|
||||
pub(crate) connect: Option<String>,
|
||||
/// Reach the app at the first call instead of at startup, so the server can start before the
|
||||
/// app is open
|
||||
#[arg(long, requires = "connect")]
|
||||
pub(crate) lazy: bool,
|
||||
/// Like --lazy, and a call that finds nothing at HOST:PORT opens the app with that control
|
||||
/// port first (macOS)
|
||||
#[arg(long, requires = "connect")]
|
||||
pub(crate) launch: bool,
|
||||
#[command(flatten)]
|
||||
pub(crate) settings: Settings,
|
||||
}
|
||||
|
||||
/// Time the engine on a synthetic N-entity drawing (default 200000), or the canvas display-list
|
||||
/// build of a drawing, zoomed in step by step
|
||||
#[derive(Args)]
|
||||
pub(crate) struct PerfArgs {
|
||||
/// An entity count (clamped to 1-20000000), or a drawing to time
|
||||
#[arg(value_name = "N|FILE", value_parser = PathBufValueParser::new().map(perf_target))]
|
||||
pub(crate) target: Option<PerfTarget>,
|
||||
}
|
||||
|
||||
/// What `perf` times.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) enum PerfTarget {
|
||||
/// A synthetic drawing with this many entities, within [`MAX_PERF_COUNT`].
|
||||
Count(usize),
|
||||
File(PathBuf),
|
||||
}
|
||||
|
||||
/// The largest synthetic drawing `perf` builds.
|
||||
const MAX_PERF_COUNT: usize = 20_000_000;
|
||||
|
||||
/// `perf N|FILE`: a value that reads as a count is one, clamped to 1..=[`MAX_PERF_COUNT`] so `perf
|
||||
/// 0` still times something; anything else is a drawing.
|
||||
fn perf_target(p: PathBuf) -> PerfTarget {
|
||||
match p.to_str().and_then(|t| t.parse::<usize>().ok()) {
|
||||
Some(n) => PerfTarget::Count(n.clamp(1, MAX_PERF_COUNT)),
|
||||
None => PerfTarget::File(p),
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a built-in sample drawing
|
||||
#[derive(Args)]
|
||||
pub(crate) struct SampleArgs {
|
||||
/// Which sample
|
||||
#[arg(value_name = "bracket|floorplan", value_enum)]
|
||||
pub(crate) name: SampleName,
|
||||
#[arg(value_name = "OUT", help = write_help())]
|
||||
pub(crate) output: PathBuf,
|
||||
#[command(flatten)]
|
||||
pub(crate) settings: Settings,
|
||||
}
|
||||
|
||||
/// `sample`'s drawings.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, ValueEnum)]
|
||||
pub(crate) enum SampleName {
|
||||
Bracket,
|
||||
#[value(alias = "floor")]
|
||||
Floorplan,
|
||||
}
|
||||
|
||||
/// What part of model space an image export shows.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) enum Framing {
|
||||
/// `run`: the current view if the commands changed it, else the extents. `convert`: extents.
|
||||
Auto,
|
||||
Extents,
|
||||
Current,
|
||||
Window(Bounds2),
|
||||
}
|
||||
|
||||
/// Why a `--window` value is refused: clap prints it after the flag and its value (exit 2).
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(crate) enum WindowError {
|
||||
#[error("expected four numbers x0,y0,x1,y1")]
|
||||
NeedsFour,
|
||||
#[error("{name} must be a finite number, got `{value}`")]
|
||||
NotFinite { name: &'static str, value: String },
|
||||
#[error("need x0 < x1 and y0 < y1 (lower-left corner first)")]
|
||||
Order,
|
||||
#[error(transparent)]
|
||||
Limits(#[from] cadcraft_io::IoError),
|
||||
}
|
||||
|
||||
/// Why a value is refused: clap prints it after the flag and its value (exit 2).
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(crate) enum ValueError {
|
||||
#[error("expected HOST:PORT (the app's --control port, e.g. 127.0.0.1:7979)")]
|
||||
Connect,
|
||||
#[error("expected a command id, then optionally its JSON parameters")]
|
||||
CmdId,
|
||||
#[error(transparent)]
|
||||
CmdJson(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
/// Why `parse` refuses flags clap accepted: clap prints it as a usage error (exit 2).
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
enum UsageError {
|
||||
/// The framing flags choose what an image shows; a drawing file has nothing to frame.
|
||||
#[error("{flag} only applies to image output (png, svg, pdf), not `{}`", .out.display())]
|
||||
FramingNeedsImage { flag: &'static str, out: PathBuf },
|
||||
/// The output is one of the input files, so writing it would replace the original.
|
||||
#[error("{} is an input file, so the result would replace the original: choose another output, or pass --in-place to overwrite it", .0.display())]
|
||||
OutIsInput(PathBuf),
|
||||
}
|
||||
|
||||
impl UsageError {
|
||||
fn into_clap(self, cmd: &mut clap::Command) -> clap::Error {
|
||||
let kind = match self {
|
||||
Self::FramingNeedsImage { .. } | Self::OutIsInput(_) => ErrorKind::ArgumentConflict,
|
||||
};
|
||||
cmd.error(kind, self)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `--window x0,y0,x1,y1` (min corner first). Hostile values are errors, never panics.
|
||||
pub(crate) fn parse_window(text: &str) -> Result<Bounds2, WindowError> {
|
||||
let parts: Vec<&str> = text.split(',').map(str::trim).collect();
|
||||
let [x0, y0, x1, y1] = parts.as_slice() else {
|
||||
return Err(WindowError::NeedsFour);
|
||||
};
|
||||
let num = |name: &'static str, v: &str| -> Result<f64, WindowError> {
|
||||
match v.parse::<f64>() {
|
||||
Ok(n) if n.is_finite() => Ok(n),
|
||||
_ => Err(WindowError::NotFinite { name, value: v.to_string() }),
|
||||
}
|
||||
};
|
||||
let (x0, y0, x1, y1) = (num("x0", x0)?, num("y0", y0)?, num("x1", x1)?, num("y1", y1)?);
|
||||
if !(x0 < x1 && y0 < y1) {
|
||||
return Err(WindowError::Order);
|
||||
}
|
||||
let w = Bounds2 { min: Vec2::new(x0, y0), max: Vec2::new(x1, y1) };
|
||||
cadcraft_io::check_window(&w)?;
|
||||
Ok(w)
|
||||
}
|
||||
|
||||
/// `run --cmd 'id {json}'`: the id up to the first space, then JSON parameters (default `{}`).
|
||||
/// Whether they suit the command is the command's to say.
|
||||
fn cmd_step(s: &str) -> Result<CmdStep, ValueError> {
|
||||
let (id, json) = s.split_once(' ').unwrap_or((s, "{}"));
|
||||
if id.is_empty() {
|
||||
return Err(ValueError::CmdId);
|
||||
}
|
||||
Ok(CmdStep { id: id.to_string(), params: serde_json::from_str(json)? })
|
||||
}
|
||||
|
||||
impl FramingArgs {
|
||||
/// The framing `--window` or `--view` asks for (clap refuses both together).
|
||||
pub(crate) fn framing(&self) -> Framing {
|
||||
match (self.window, self.view) {
|
||||
(Some(w), _) => Framing::Window(w),
|
||||
(None, Some(ViewMode::Extents)) => Framing::Extents,
|
||||
(None, Some(ViewMode::Current)) => Framing::Current,
|
||||
(None, None) => Framing::Auto,
|
||||
}
|
||||
}
|
||||
|
||||
/// The framing flag given, if any.
|
||||
fn flag(&self) -> Option<&'static str> {
|
||||
if self.window.is_some() {
|
||||
Some("--window")
|
||||
} else if self.view.is_some() {
|
||||
Some("--view")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A framing flag given with an output that isn't an image (the extension decides). `run` without
|
||||
/// `--export` is clap's `requires` error.
|
||||
fn framing_error(cli: &Cli) -> Option<UsageError> {
|
||||
let (framing, out): (&FramingArgs, Option<&Path>) = match &cli.command {
|
||||
Command::Convert(c) => (&c.framing, Some(&c.output)),
|
||||
Command::Run(r) => (&r.framing, r.export.as_deref()),
|
||||
_ => return None,
|
||||
};
|
||||
let (flag, out) = (framing.flag()?, out?);
|
||||
(!cadcraft_io::is_image(out)).then(|| UsageError::FramingNeedsImage { flag, out: out.to_path_buf() })
|
||||
}
|
||||
|
||||
/// An output that is the same file as an input, without `--in-place`. An output that doesn't exist
|
||||
/// can't be an input, and an I/O error in the comparison is left to the open or write that follows.
|
||||
fn out_is_input(cli: &Cli) -> Option<UsageError> {
|
||||
let same = |input: &Path, out: &Path| out.exists() && same_file::is_same_file(input, out).unwrap_or(false);
|
||||
match &cli.command {
|
||||
Command::Convert(c) if !c.in_place && same(&c.input, &c.output) => Some(UsageError::OutIsInput(c.output.clone())),
|
||||
Command::Run(r) if !r.in_place => {
|
||||
let inputs = r.drawings.iter().filter_map(|d| if let Drawing::File(f) = d { Some(f) } else { None });
|
||||
let inputs: Vec<&PathBuf> = inputs.collect();
|
||||
[&r.save, &r.export].into_iter().flatten().find(|out| inputs.iter().any(|i| same(i, out))).map(|out| UsageError::OutIsInput(out.clone()))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The backend `cadcraft-cli mcp` serves from.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) enum McpTarget {
|
||||
/// An in-process drawing session (no `--connect`).
|
||||
Headless,
|
||||
/// The running app's control channel at `HOST:PORT`; `lazy`: reached at the first call;
|
||||
/// `launch`: opened then if it isn't running.
|
||||
Connect { addr: String, lazy: bool, launch: bool },
|
||||
}
|
||||
|
||||
impl McpArgs {
|
||||
pub(crate) fn target(&self) -> McpTarget {
|
||||
match &self.connect {
|
||||
// `--launch` connects at the first call too (the app opens then).
|
||||
Some(addr) => McpTarget::Connect { addr: addr.clone(), lazy: self.lazy || self.launch, launch: self.launch },
|
||||
None => McpTarget::Headless,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `mcp --connect`: an empty, flag-like or port-less address is an error, never a silent fall back
|
||||
/// to a headless session (#103). The host is not resolved here.
|
||||
fn connect_addr(s: &str) -> Result<String, ValueError> {
|
||||
let s = s.trim();
|
||||
let valid = !s.starts_with('-')
|
||||
&& s.rsplit_once(':').is_some_and(|(host, port)| {
|
||||
let host = host.strip_prefix('[').and_then(|h| h.strip_suffix(']')).unwrap_or(host);
|
||||
!host.is_empty() && port.parse::<u16>().is_ok()
|
||||
});
|
||||
if valid { Ok(s.to_string()) } else { Err(ValueError::Connect) }
|
||||
}
|
||||
|
||||
/// The values of `id` with their command-line indices.
|
||||
fn indexed<'a, T: Clone + Send + Sync + 'static>(m: &'a ArgMatches, id: &str) -> impl Iterator<Item = (usize, &'a T)> {
|
||||
let at = m.indices_of(id).into_iter().flatten();
|
||||
let values = m.try_get_many::<T>(id).ok().flatten().into_iter().flatten();
|
||||
at.zip(values)
|
||||
}
|
||||
|
||||
/// The command-line index of flag `id`, when it was given.
|
||||
fn flag_index(m: &ArgMatches, id: &str) -> Option<usize> {
|
||||
if m.value_source(id) == Some(ValueSource::CommandLine) { m.indices_of(id).and_then(Iterator::last) } else { None }
|
||||
}
|
||||
|
||||
/// `run`'s drawings and steps back in command-line order: clap keeps each flag's values apart,
|
||||
/// their indices say how they interleave.
|
||||
fn run_order(m: &ArgMatches) -> (Vec<Drawing>, Vec<Step>) {
|
||||
let mut drawings: Vec<(usize, Drawing)> = indexed(m, "files").map(|(i, f): (_, &PathBuf)| (i, Drawing::File(f.clone()))).collect();
|
||||
drawings.extend(flag_index(m, "sample").map(|i| (i, Drawing::Sample)));
|
||||
drawings.extend(flag_index(m, "metric").map(|i| (i, Drawing::Metric)));
|
||||
drawings.sort_by_key(|d| d.0);
|
||||
let mut steps: Vec<(usize, Step)> = indexed(m, "script").map(|(i, s): (_, &String)| (i, Step::Script(s.clone()))).collect();
|
||||
steps.extend(indexed(m, "script_file").map(|(i, s): (_, &PathBuf)| (i, Step::ScriptFile(s.clone()))));
|
||||
steps.extend(indexed(m, "cmd").map(|(i, c): (_, &CmdStep)| (i, Step::Cmd(c.clone()))));
|
||||
steps.sort_by_key(|s| s.0);
|
||||
(drawings.into_iter().map(|d| d.1).collect(), steps.into_iter().map(|s| s.1).collect())
|
||||
}
|
||||
|
||||
/// Parse the arguments (without the binary name). clap reads them as `OsString`s: a path need not
|
||||
/// be valid Unicode, text must. A bare `--help` or `-h` is help even after a mistake on the same
|
||||
/// line (`run --script --help`): clap reports the mistake first.
|
||||
pub(crate) fn parse(args: &[OsString]) -> Result<Cli, clap::Error> {
|
||||
parse_with(command(|k| std::env::var_os(k)), args)
|
||||
}
|
||||
|
||||
/// The command line. An empty or blank variable counts as unset, except `CADCRAFT_CONFIG_DIR`:
|
||||
/// empty means no settings folder. `var` looks a variable up.
|
||||
fn command(var: impl Fn(&OsStr) -> Option<OsString> + Copy) -> clap::Command {
|
||||
let blank = move |a: clap::Arg| if a.get_id() == "config_dir" { a } else { blank_env_is_unset(a, var) };
|
||||
Cli::command().mut_args(blank).mut_subcommands(|s| s.mut_args(blank))
|
||||
}
|
||||
|
||||
/// [`parse`] with `cmd` from [`command`]; a bad value from a variable is a usage error naming the
|
||||
/// variable.
|
||||
fn parse_with(mut cmd: clap::Command, args: &[OsString]) -> Result<Cli, clap::Error> {
|
||||
let argv = std::iter::once(OsString::from("cadcraft-cli")).chain(args.iter().cloned());
|
||||
let matches = match cmd.try_get_matches_from_mut(argv) {
|
||||
// `use_stderr`: a real error, not the help `-h` asked for (a retry would make it `--help`).
|
||||
Err(e) if e.use_stderr() && args.iter().take_while(|a| *a != "--").any(|a| *a == "--help" || *a == "-h") => {
|
||||
let help = [OsString::from("cadcraft-cli"), args.first().cloned().unwrap_or_default(), OsString::from("--help")];
|
||||
return Err(cmd.try_get_matches_from_mut(help).err().unwrap_or(e));
|
||||
}
|
||||
matches => matches.map_err(|e| name_env_vars(e, &cmd, args))?,
|
||||
};
|
||||
let mut cli = Cli::from_arg_matches(&matches)?;
|
||||
if let (Command::Run(r), Some((_, m))) = (&mut cli.command, matches.subcommand()) {
|
||||
(r.drawings, r.steps) = run_order(m);
|
||||
}
|
||||
if let Some(e) = framing_error(&cli).or_else(|| out_is_input(&cli)) {
|
||||
if let Some(sub) = matches.subcommand_name().and_then(|n| cmd.find_subcommand_mut(n)) {
|
||||
return Err(e.into_clap(sub));
|
||||
}
|
||||
return Err(e.into_clap(&mut cmd));
|
||||
}
|
||||
Ok(cli)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn parse_args(a: &[&str]) -> Result<Cli, clap::Error> {
|
||||
parse(&a.iter().map(OsString::from).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
fn mcp(a: &[&str]) -> Result<McpTarget, clap::Error> {
|
||||
let args: Vec<&str> = std::iter::once("mcp").chain(a.iter().copied()).collect();
|
||||
match parse_args(&args)?.command {
|
||||
Command::Mcp(m) => Ok(m.target()),
|
||||
_ => panic!("{a:?} is not mcp"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every file argument's help names each format of the list it uses.
|
||||
#[test]
|
||||
fn help_names_every_format() {
|
||||
let cli = Cli::command();
|
||||
let help = |sub: &str, arg: &str| {
|
||||
let a = cli.find_subcommand(sub).and_then(|c| c.get_arguments().find(|a| a.get_id() == arg)).expect("argument exists");
|
||||
a.get_help().map(|h| h.to_string()).unwrap_or_default()
|
||||
};
|
||||
let cases: [(&str, &str, &[Format]); 8] = [
|
||||
("info", "file", Format::READ),
|
||||
("convert", "input", Format::READ),
|
||||
("convert", "output", Format::WRITE),
|
||||
("run", "files", Format::READ),
|
||||
("run", "save", Format::WRITE),
|
||||
("run", "export", Format::WRITE),
|
||||
("sample", "output", Format::WRITE),
|
||||
("convert", "", Format::WRITE),
|
||||
];
|
||||
for (sub, arg, list) in cases {
|
||||
let text = if arg.is_empty() {
|
||||
cli.find_subcommand(sub).and_then(|c| c.get_about()).map(|a| a.to_string()).unwrap_or_default()
|
||||
} else {
|
||||
help(sub, arg)
|
||||
};
|
||||
for f in list {
|
||||
assert!(text.contains(f.extension()), "`{sub} {arg}` help `{text}` lacks {}", f.extension());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_without_connect_is_headless() {
|
||||
assert_eq!(mcp(&[]).unwrap(), McpTarget::Headless);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_connect_with_address_selects_remote() {
|
||||
let to = |addr: &str, lazy, launch| McpTarget::Connect { addr: addr.into(), lazy, launch };
|
||||
assert_eq!(mcp(&["--connect", "127.0.0.1:7979"]).unwrap(), to("127.0.0.1:7979", false, false));
|
||||
assert_eq!(mcp(&["--connect", " localhost:7979 "]).unwrap(), to("localhost:7979", false, false));
|
||||
assert_eq!(mcp(&["--lazy", "--connect", "localhost:7979"]).unwrap(), to("localhost:7979", true, false));
|
||||
assert_eq!(mcp(&["--connect", "localhost:7979", "--launch"]).unwrap(), to("localhost:7979", true, true));
|
||||
assert_eq!(mcp(&["--connect", "[::1]:7979"]).unwrap(), to("[::1]:7979", false, false));
|
||||
// Without an app to reach they mean nothing: never a headless session.
|
||||
for flag in ["--lazy", "--launch"] {
|
||||
assert_eq!(mcp(&[flag]).unwrap_err().kind(), clap::error::ErrorKind::MissingRequiredArgument, "{flag}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_connect_without_address_is_an_error_not_headless() {
|
||||
for bad in [
|
||||
&["--connect"][..],
|
||||
&["--connect", ""],
|
||||
&["--connect", " "],
|
||||
&["--connect=-v"],
|
||||
&["--connect", "example.com"],
|
||||
&["--connect", ":7979"],
|
||||
&["--connect", "host:99999"],
|
||||
&["--connect", "[]:1"],
|
||||
] {
|
||||
let r = mcp(bad);
|
||||
let Err(e) = r else { panic!("{bad:?} should be rejected, got {r:?}") };
|
||||
assert_eq!(e.exit_code(), 2, "{bad:?}");
|
||||
assert!(e.to_string().contains("--connect <HOST:PORT>"), "{bad:?}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_rejects_unknown_and_repeated_arguments() {
|
||||
assert!(mcp(&["--conect", "127.0.0.1:7979"]).is_err());
|
||||
assert!(mcp(&["127.0.0.1:7979"]).is_err());
|
||||
// clap names the unknown flag, not the value it left missing.
|
||||
for flag in ["--foo", "-v"] {
|
||||
assert_eq!(mcp(&["--connect", flag]).unwrap_err().kind(), clap::error::ErrorKind::UnknownArgument, "{flag}");
|
||||
}
|
||||
assert!(mcp(&["--connect", "127.0.0.1:1", "--connect", "127.0.0.1:2"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_keeps_command_line_order() {
|
||||
let args =
|
||||
["run", "a.dxf", "--script", "S1", "--metric", "--cmd", "c1", "b.dxf", "--script-file", "f.scr", "--sample", "--cmd", "c2 {\"n\":1}"];
|
||||
let Command::Run(r) = parse_args(&args).unwrap().command else { panic!("not run") };
|
||||
let file = |f: &str| Drawing::File(f.into());
|
||||
let cmd = |id: &str, params| Step::Cmd(CmdStep { id: id.into(), params });
|
||||
assert_eq!(r.drawings, [file("a.dxf"), Drawing::Metric, file("b.dxf"), Drawing::Sample]);
|
||||
assert_eq!(r.steps, [Step::Script("S1".into()), cmd("c1", json!({})), Step::ScriptFile("f.scr".into()), cmd("c2", json!({"n": 1}))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmd_values() {
|
||||
assert_eq!(cmd_step("line").unwrap(), CmdStep { id: "line".into(), params: json!({}) });
|
||||
assert_eq!(cmd_step("line [1]").unwrap(), CmdStep { id: "line".into(), params: json!([1]) });
|
||||
for bad in ["", " {}", "line {bad json", "line "] {
|
||||
assert!(cmd_step(bad).is_err(), "`{bad}` should be rejected");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perf_targets() {
|
||||
let target = |a: &[&str]| -> Option<PerfTarget> {
|
||||
let args: Vec<&str> = std::iter::once("perf").chain(a.iter().copied()).collect();
|
||||
let Command::Perf(p) = parse_args(&args).unwrap().command else { panic!("not perf") };
|
||||
p.target
|
||||
};
|
||||
assert_eq!(target(&[]), None);
|
||||
assert_eq!(target(&["5"]), Some(PerfTarget::Count(5)));
|
||||
assert_eq!(target(&["0"]), Some(PerfTarget::Count(1)));
|
||||
assert_eq!(target(&["999999999"]), Some(PerfTarget::Count(MAX_PERF_COUNT)));
|
||||
assert_eq!(target(&["a.dxf"]), Some(PerfTarget::File("a.dxf".into())));
|
||||
assert_eq!(target(&["1e3"]), Some(PerfTarget::File("1e3".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn framing_options() {
|
||||
let framing = |out: &str, a: &[&str]| -> Result<Framing, clap::Error> {
|
||||
let args: Vec<&str> = ["convert", "a.dxf", out].iter().chain(a).copied().collect();
|
||||
let Command::Convert(c) = parse_args(&args)?.command else { panic!("not convert") };
|
||||
Ok(c.framing.framing())
|
||||
};
|
||||
let b = |x0, y0, x1, y1| Bounds2::new(Vec2::new(x0, y0), Vec2::new(x1, y1));
|
||||
assert_eq!(framing("b.png", &[]).unwrap(), Framing::Auto);
|
||||
assert_eq!(framing("b.png", &["--view", "extents"]).unwrap(), Framing::Extents);
|
||||
assert_eq!(framing("b.png", &["--view", "CURRENT"]).unwrap(), Framing::Current);
|
||||
assert_eq!(framing("b.png", &["--window", "0,0,2,1"]).unwrap(), Framing::Window(b(0.0, 0.0, 2.0, 1.0)));
|
||||
assert_eq!(framing("b.dxf", &[]).unwrap(), Framing::Auto);
|
||||
let kind = |out: &str, a: &[&str]| framing(out, a).map(|_| ()).unwrap_err().kind();
|
||||
assert_eq!(kind("b.png", &["--view", "selection"]), ErrorKind::InvalidValue);
|
||||
assert_eq!(kind("b.png", &["--window", "0,0,2,1", "--view", "extents"]), ErrorKind::ArgumentConflict);
|
||||
assert_eq!(kind("b.dxf", &["--view", "extents"]), ErrorKind::ArgumentConflict);
|
||||
assert_eq!(kind("b.dxf", &["--window", "0,0,2,1"]), ErrorKind::ArgumentConflict);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_parses() {
|
||||
let b = |x0, y0, x1, y1| Bounds2::new(Vec2::new(x0, y0), Vec2::new(x1, y1));
|
||||
assert_eq!(parse_window("0,0,30,20").unwrap(), b(0.0, 0.0, 30.0, 20.0));
|
||||
assert_eq!(parse_window(" -5.5, -1e3 ,2e3,4 ").unwrap(), b(-5.5, -1000.0, 2000.0, 4.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_window_is_an_error() {
|
||||
for w in [
|
||||
"",
|
||||
"1,2,3",
|
||||
"1,2,3,4,5",
|
||||
"a,0,1,1",
|
||||
"NaN,0,1,1",
|
||||
"0,0,inf,1",
|
||||
"0,0,1e400,1",
|
||||
"0,0,0,1",
|
||||
"0,0,1,0",
|
||||
"5,0,1,1",
|
||||
"0,5,1,1",
|
||||
"-1e300,-1e300,1e300,1e300",
|
||||
"0,0,1e-12,1e-12",
|
||||
] {
|
||||
assert!(parse_window(w).is_err(), "`{w}` should be rejected");
|
||||
}
|
||||
}
|
||||
|
||||
/// The subcommands that draw or run commands take the settings flags; the others refuse them.
|
||||
#[test]
|
||||
fn settings_flags() {
|
||||
let Command::Convert(c) =
|
||||
parse_args(&["convert", "a.dxf", "b.png", "--config-dir", "/p", "--fontalt", "Noto Sans", "--fontfallback="]).unwrap().command
|
||||
else {
|
||||
panic!("not convert")
|
||||
};
|
||||
assert_eq!(c.settings.config_dir, Some(PathBuf::from("/p")));
|
||||
assert_eq!((c.settings.fontalt.as_deref(), c.settings.fontfallback.as_deref()), (Some("Noto Sans"), Some("")));
|
||||
for empty in ["--config-dir=", "--config-dir= "] {
|
||||
let Command::Run(r) = parse_args(&["run", "--sample", empty]).unwrap().command else { panic!("not run") };
|
||||
assert_eq!(r.settings.settings_dir(), None, "`{empty}` is no settings folder");
|
||||
}
|
||||
let Command::Convert(c) = parse_args(&["convert", "a.dxf", "b.png", "--config-dir", "/p"]).unwrap().command else { panic!("not convert") };
|
||||
assert_eq!(c.settings.settings_dir(), Some(PathBuf::from("/p")));
|
||||
assert!(parse_args(&["sample", "bracket", "b.dxf", "--fontalt", "x"]).is_ok());
|
||||
assert!(parse_args(&["mcp", "--config-dir", "/p"]).is_ok());
|
||||
assert!(parse_args(&["info", "a.dxf", "--config-dir", "/p"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_variables_count_as_unset_except_the_config_dir() {
|
||||
let cmd = command(|_| Some("".into()));
|
||||
for sub in cmd.get_subcommands().filter(|s| s.get_arguments().any(|a| a.get_id() == "fontalt")) {
|
||||
let env = |id: &str| sub.get_arguments().find(|a| a.get_id() == id).and_then(|a| a.get_env());
|
||||
assert_eq!((env("fontalt"), env("fontfallback")), (None, None), "{}", sub.get_name());
|
||||
assert_eq!(env("config_dir"), Some(OsStr::new("CADCRAFT_CONFIG_DIR")), "{}", sub.get_name());
|
||||
}
|
||||
}
|
||||
|
||||
/// `--fontalt` falls back to `PATH` and rejects every value here, as a bad value from a
|
||||
/// variable would; tests can't set a variable (`set_var` is unsafe).
|
||||
#[test]
|
||||
fn a_bad_value_from_a_variable_names_the_variable() {
|
||||
// Cargo sets CARGO_PKG_NAME for every test binary, so the variable always has a value.
|
||||
let reject = clap::builder::PossibleValuesParser::new(["never"]);
|
||||
let cmd = || Cli::command().mut_subcommand("sample", |s| s.mut_arg("fontalt", |a| a.env("CARGO_PKG_NAME").value_parser(reject.clone())));
|
||||
let args = |a: &[&str]| a.iter().map(OsString::from).collect::<Vec<_>>();
|
||||
let text = parse_with(cmd(), &args(&["sample"])).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(text.contains("for 'CARGO_PKG_NAME'") && !text.contains("--fontalt"), "{text}");
|
||||
let text = parse_with(cmd(), &args(&["sample", "--fontalt", "x"])).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(text.contains("--fontalt"), "{text}");
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
use super::args::CommandsArgs;
|
||||
use super::error::Result;
|
||||
use super::output::Streams;
|
||||
use cadcraft_engine::Session;
|
||||
use serde_json::Value;
|
||||
|
||||
pub(crate) fn commands(a: &CommandsArgs, io: &mut Streams) -> Result {
|
||||
let s = Session::new();
|
||||
let f = a.filter.as_ref().map(|x| x.to_ascii_lowercase());
|
||||
let v: Vec<Value> = cadcraft_engine::command_specs()
|
||||
.iter()
|
||||
.filter(|c| f.as_ref().is_none_or(|f| c.id.contains(f.as_str()) || c.label.to_ascii_lowercase().contains(f.as_str())))
|
||||
.map(|c| serde_json::to_value(c.info(&s)).unwrap_or_default())
|
||||
.collect();
|
||||
io.json(&Value::Array(v), a.compact);
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
use super::args::ConvertArgs;
|
||||
use super::error::Result;
|
||||
use super::output::{Streams, export_framed, export_window, read_note_messages, skipped_message, undrawn_message};
|
||||
use super::{open, unicode};
|
||||
use cadcraft_engine::Session;
|
||||
|
||||
pub(crate) fn convert(a: &ConvertArgs, io: &mut Streams) -> Result {
|
||||
let i = unicode(&a.input)?;
|
||||
let framing = a.framing.framing();
|
||||
let mut s = Session::empty();
|
||||
open(&mut s, &a.input)?;
|
||||
let d = s.doc()?;
|
||||
for m in read_note_messages(d, i).into_iter().chain(skipped_message(d, i)) {
|
||||
io.errln(format_args!("{m}"));
|
||||
}
|
||||
if cadcraft_io::is_image(&a.output)
|
||||
&& let Some(m) = undrawn_message(s.doc()?, i)
|
||||
{
|
||||
io.errln(format_args!("{m}"));
|
||||
}
|
||||
let window = export_window(&s, framing, None)?;
|
||||
export_framed(&s, &a.output, window, io)
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
use crate::output::OutputError;
|
||||
use crate::perf::PerfError;
|
||||
use crate::run::RunError;
|
||||
|
||||
/// Why a subcommand failed: printed as `error: {e}`, exit 1.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(crate) enum CliError {
|
||||
#[error(transparent)]
|
||||
Engine(#[from] cadcraft_engine::EngineError),
|
||||
#[error(transparent)]
|
||||
Doc(#[from] cadcraft_engine::doc::DocError),
|
||||
#[error(transparent)]
|
||||
Format(#[from] cadcraft_io::IoError),
|
||||
#[error(transparent)]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error(transparent)]
|
||||
Run(#[from] RunError),
|
||||
#[error(transparent)]
|
||||
Output(#[from] OutputError),
|
||||
#[error(transparent)]
|
||||
Perf(#[from] PerfError),
|
||||
/// The engine and io crates take file names as text.
|
||||
#[error("{}: the file name is not valid Unicode", .0.display())]
|
||||
NotUnicode(std::path::PathBuf),
|
||||
#[error("cannot connect to {addr}: {source}")]
|
||||
McpConnect { addr: String, source: std::io::Error },
|
||||
}
|
||||
|
||||
pub(crate) type Result<T = (), E = CliError> = std::result::Result<T, E>;
|
||||
@@ -1,14 +0,0 @@
|
||||
use super::args::InfoArgs;
|
||||
use super::error::Result;
|
||||
use super::open;
|
||||
use super::output::Streams;
|
||||
use cadcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
pub(crate) fn info(a: &InfoArgs, io: &mut Streams) -> Result {
|
||||
let mut s = Session::empty();
|
||||
open(&mut s, &a.file)?;
|
||||
let v = s.execute("drawing.inspect", &json!({ "entities": false }))?;
|
||||
io.json(&v, a.compact);
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,315 +0,0 @@
|
||||
//! `cadcraft-cli`: headless CADCraft. The subcommands and their options are the clap types in
|
||||
//! `args.rs`; `cadcraft-cli --help` lists them.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::io::Write;
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
mod args;
|
||||
mod commands;
|
||||
mod convert;
|
||||
mod error;
|
||||
mod info;
|
||||
mod mcp;
|
||||
mod output;
|
||||
mod perf;
|
||||
mod run;
|
||||
mod sample;
|
||||
|
||||
#[cfg(test)]
|
||||
use args::Framing;
|
||||
use args::{Command, parse};
|
||||
use clap::CommandFactory;
|
||||
use commands::commands;
|
||||
use convert::convert;
|
||||
use error::Result;
|
||||
use info::info;
|
||||
use mcp::mcp;
|
||||
use output::Streams;
|
||||
#[cfg(test)]
|
||||
use output::{export_window, model_views, write_atomic};
|
||||
use perf::perf;
|
||||
use run::run_cmds;
|
||||
#[cfg(test)]
|
||||
use run::{fill_last, returned_handle};
|
||||
use sample::sample;
|
||||
|
||||
fn install_io() {
|
||||
cadcraft_engine::cmd::file::set_io(cadcraft_engine::cmd::file::IoHooks {
|
||||
read: |b, name| cadcraft_io::read(b, name).map_err(|e| e.to_string()),
|
||||
write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()),
|
||||
plot: Some(|d, space, opts| cadcraft_io::plotter::plot(d, space, opts).map_err(|e| e.to_string())),
|
||||
});
|
||||
cadcraft_engine::cmd::file::set_write_as(|d, name, o| {
|
||||
cadcraft_io::write_with(d, name, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string())
|
||||
});
|
||||
// AutoLISP's entget, entmake and entmod see entities as DXF groups.
|
||||
cadcraft_engine::cmd::file::set_entity_codec(cadcraft_engine::cmd::file::EntityCodec {
|
||||
groups: cadcraft_io::entity_groups,
|
||||
entity: cadcraft_io::entity_from_groups,
|
||||
});
|
||||
}
|
||||
|
||||
/// `path` as text, for the engine and io calls that take a file name as a string.
|
||||
fn unicode(path: &std::path::Path) -> Result<&str> {
|
||||
path.to_str().ok_or_else(|| error::CliError::NotUnicode(path.to_path_buf()))
|
||||
}
|
||||
|
||||
fn open(s: &mut Session, path: &std::path::Path) -> Result {
|
||||
s.execute("open", &json!({ "path": unicode(path)? }))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The command line's clap definition, for generating shell completions and man pages.
|
||||
pub fn command() -> clap::Command {
|
||||
args::Cli::command()
|
||||
}
|
||||
|
||||
/// Run the CLI on its arguments (without the program name). Returns the process exit code (0 ok,
|
||||
/// 1 failure, 2 usage). clap reads the arguments as they are: a value that is not valid Unicode
|
||||
/// is a usage error, never a panic as with `std::env::args()`.
|
||||
pub fn run<I, T>(args: I, out: &mut dyn Write, err: &mut dyn Write) -> i32
|
||||
where
|
||||
I: IntoIterator<Item = T>,
|
||||
T: Into<OsString>,
|
||||
{
|
||||
let args: Vec<OsString> = args.into_iter().map(Into::into).collect();
|
||||
// Help and `--version` (`help`, `-h`, `<subcommand> --help`, `-V`) come back as errors too,
|
||||
// for `out`, exit 0: the subcommand never runs.
|
||||
let cli = match parse(&args) {
|
||||
Ok(cli) => cli,
|
||||
Err(e) => {
|
||||
let to: &mut dyn Write = if e.use_stderr() { err } else { out };
|
||||
return match write!(to, "{}", e.render()) {
|
||||
Err(w) if w.kind() != std::io::ErrorKind::BrokenPipe => {
|
||||
let _ = writeln!(err, "error: can't write the output: {w}");
|
||||
1
|
||||
}
|
||||
_ => e.exit_code(),
|
||||
};
|
||||
}
|
||||
};
|
||||
// The engine's hooks are set-once, so installing them on every call is harmless.
|
||||
install_io();
|
||||
match &cli.command {
|
||||
Command::Convert(a) => a.settings.apply(),
|
||||
Command::Run(a) => a.settings.apply(),
|
||||
Command::Mcp(a) => a.settings.apply(),
|
||||
Command::Sample(a) => a.settings.apply(),
|
||||
Command::Info(_) | Command::Commands(_) | Command::Perf(_) => {}
|
||||
}
|
||||
let mut io = Streams::new(out, err);
|
||||
let result = match &cli.command {
|
||||
Command::Info(a) => info(a, &mut io),
|
||||
Command::Convert(a) => convert(a, &mut io),
|
||||
Command::Run(a) => run_cmds(a, &mut io),
|
||||
Command::Commands(a) => commands(a, &mut io),
|
||||
Command::Mcp(a) => mcp(a),
|
||||
Command::Perf(a) => perf(a.target.as_ref(), &mut io),
|
||||
Command::Sample(a) => sample(a, &mut io),
|
||||
};
|
||||
let mut code = 0;
|
||||
if let Err(e) = result {
|
||||
io.errln(format_args!("error: {e}"));
|
||||
code = 1;
|
||||
}
|
||||
io.finish();
|
||||
if io.stdout_failed() { 1 } else { code }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use cadcraft_engine::doc::Space;
|
||||
use cadcraft_engine::geom::{Bounds2, Vec2};
|
||||
|
||||
fn scratch_dir(tag: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-cli-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("create scratch dir");
|
||||
dir
|
||||
}
|
||||
|
||||
struct FailingWriter(std::io::ErrorKind);
|
||||
|
||||
impl Write for FailingWriter {
|
||||
fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
|
||||
Err(self.0.into())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_write_of_help_exits_1_unless_the_pipe_is_closed() {
|
||||
let mut err = Vec::new();
|
||||
assert_eq!(run(["--help"], &mut FailingWriter(std::io::ErrorKind::StorageFull), &mut err), 1);
|
||||
assert!(String::from_utf8_lossy(&err).starts_with("error: "));
|
||||
assert_eq!(run(["--help"], &mut FailingWriter(std::io::ErrorKind::BrokenPipe), &mut Vec::new()), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_atomic_replaces_file_and_leaves_no_staging_files() {
|
||||
let dir = scratch_dir("replace");
|
||||
let out = dir.join("a.dxf");
|
||||
write_atomic(&out, b"old").expect("create");
|
||||
write_atomic(&out, b"new").expect("overwrite");
|
||||
assert_eq!(std::fs::read(&out).expect("read"), b"new");
|
||||
assert_eq!(std::fs::read_dir(&dir).expect("list").count(), 1);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_atomic_failure_keeps_destination_and_cleans_up() {
|
||||
let dir = scratch_dir("fail");
|
||||
let out = dir.join("target");
|
||||
std::fs::create_dir(&out).expect("dir as destination");
|
||||
std::fs::write(out.join("keep"), b"x").expect("seed");
|
||||
assert!(write_atomic(&out, b"new").is_err());
|
||||
assert_eq!(std::fs::read(out.join("keep")).expect("kept"), b"x");
|
||||
assert_eq!(std::fs::read_dir(&dir).expect("list").count(), 1);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
fn b(x0: f64, y0: f64, x1: f64, y1: f64) -> Bounds2 {
|
||||
Bounds2::new(Vec2::new(x0, y0), Vec2::new(x1, y1))
|
||||
}
|
||||
|
||||
fn sample_session() -> Session {
|
||||
let mut s = Session::empty();
|
||||
s.open_drawing(cadcraft_engine::sample::default_sample(), "Bracket", None);
|
||||
s
|
||||
}
|
||||
|
||||
fn view_box(svg: &str) -> String {
|
||||
let i = svg.find("viewBox=\"").map(|i| i + 9).unwrap_or(0);
|
||||
svg[i..].split('"').next().unwrap_or("").to_string()
|
||||
}
|
||||
|
||||
fn assert_near(a: Bounds2, e: Bounds2) {
|
||||
let d = (a.min - e.min).len().max((a.max - e.max).len());
|
||||
assert!(d < 1e-9, "{a:?} != {e:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_placeholder_takes_the_previous_handle() {
|
||||
let mut s = sample_session();
|
||||
let r = s.execute("line", &json!({"points": [[0, 0], [10, 0]]})).unwrap();
|
||||
let h = returned_handle(&s, &r).expect("LINE returns its handle");
|
||||
assert_eq!(fill_last(&json!({"handle": "{last}"}), Some(h)).unwrap(), json!({"handle": h.hex()}));
|
||||
// Nested, as in a selection list.
|
||||
assert_eq!(fill_last(&json!({"handles": ["{last}", "1"]}), Some(h)).unwrap(), json!({"handles": [h.hex(), "1"]}));
|
||||
// Only the exact token: a name or a sentence that merely contains it stays as written.
|
||||
for p in [json!("last"), json!("{last} "), json!({"name": "last"})] {
|
||||
assert_eq!(fill_last(&p, Some(h)).unwrap(), p);
|
||||
}
|
||||
// Nothing returned yet: an error, not a token passed to the command as a bad handle.
|
||||
assert!(fill_last(&json!({"handle": "{last}"}), None).unwrap_err().to_string().contains("{last}"));
|
||||
}
|
||||
|
||||
/// Handles are read from a result by looking the strings up in the drawing: counts and names
|
||||
/// are not mistaken for handles, and `mview`'s `viewports` works like `handle`/`handles`.
|
||||
#[test]
|
||||
fn returned_handle_reads_only_real_handles() {
|
||||
let mut s = Session::empty();
|
||||
s.new_drawing(false);
|
||||
s.execute("layout.set", &json!({"name": "Layout1"})).unwrap();
|
||||
let r = s.execute("mview", &json!({"p1": [20, 20], "p2": [200, 150]})).unwrap();
|
||||
let h = returned_handle(&s, &r).expect("mview returns its viewport handle");
|
||||
assert!(r["viewports"].as_array().is_some_and(|a| a.iter().any(|v| v.as_str() == Some(h.hex().as_str()))), "{r}");
|
||||
assert_eq!(returned_handle(&s, &json!({"changed": 3, "name": "A1"})), None);
|
||||
// The most recent of several.
|
||||
let a = s.execute("line", &json!({"points": [[0, 0], [1, 0]]})).unwrap();
|
||||
let b = s.execute("line", &json!({"points": [[0, 0], [2, 0]]})).unwrap();
|
||||
let (ha, hb) = (returned_handle(&s, &a).unwrap(), returned_handle(&s, &b).unwrap());
|
||||
assert_eq!(returned_handle(&s, &json!({"handles": [ha.hex(), hb.hex()]})), Some(hb));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_framing_is_extents() {
|
||||
// convert: nothing ran, always the extents.
|
||||
let s = sample_session();
|
||||
assert_eq!(export_window(&s, Framing::Auto, None).unwrap(), None);
|
||||
// run: commands that leave the view alone keep the extents framing.
|
||||
let mut s = sample_session();
|
||||
let before = model_views(&s);
|
||||
s.script("LINE 0,0 10,10\n\n").unwrap();
|
||||
assert_eq!(export_window(&s, Framing::Auto, Some(&before)).unwrap(), None);
|
||||
// A fresh drawing too.
|
||||
let mut s = Session::empty();
|
||||
s.new_drawing(false);
|
||||
let before = model_views(&s);
|
||||
s.execute("circle", &json!({"center": [0, 0], "radius": 5})).unwrap();
|
||||
assert_eq!(export_window(&s, Framing::Auto, Some(&before)).unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_window_frames_export() {
|
||||
let mut s = sample_session();
|
||||
let before = model_views(&s);
|
||||
s.execute("zoom", &json!({"mode": "window", "p1": [0, 0], "p2": [30, 20]})).unwrap();
|
||||
let w = export_window(&s, Framing::Auto, Some(&before)).unwrap().unwrap();
|
||||
// The CLI viewport is 3:2, so a 3:2 window is shown exactly.
|
||||
assert_near(w, b(0.0, 0.0, 30.0, 20.0));
|
||||
assert_eq!(export_window(&s, Framing::Current, Some(&before)).unwrap(), Some(w));
|
||||
// --view extents and --window override the zoom.
|
||||
assert_eq!(export_window(&s, Framing::Extents, Some(&before)).unwrap(), None);
|
||||
let explicit = b(1.0, 1.0, 2.0, 2.0);
|
||||
assert_eq!(export_window(&s, Framing::Window(explicit), Some(&before)).unwrap(), Some(explicit));
|
||||
// A taller window keeps its height; the 3:2 image adds room left and right.
|
||||
s.execute("zoom", &json!({"mode": "center", "center": [5, 5], "height": 4})).unwrap();
|
||||
assert_near(export_window(&s, Framing::Auto, Some(&before)).unwrap().unwrap(), b(2.0, 3.0, 8.0, 7.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_object_frames_the_selection() {
|
||||
let mut s = sample_session();
|
||||
let before = model_views(&s);
|
||||
// The smallest entity with an area, selected then framed with ZOOM Object.
|
||||
let d = s.doc().unwrap();
|
||||
let bounds = |e: &cadcraft_engine::doc::Entity| cadcraft_engine::doc::entity_bounds(d, e, 0);
|
||||
let e = d
|
||||
.model
|
||||
.iter()
|
||||
.filter(|e| bounds(e).width() > 0.0 && bounds(e).height() > 0.0)
|
||||
.min_by(|a, b| bounds(a).width().total_cmp(&bounds(b).width()))
|
||||
.unwrap();
|
||||
let (h, eb, ext) = (e.handle, bounds(e), d.extents(&Space::Model));
|
||||
s.set_selection(vec![h]);
|
||||
s.execute("zoom", &json!({"mode": "object"})).unwrap();
|
||||
let w = export_window(&s, Framing::Auto, Some(&before)).unwrap().unwrap();
|
||||
assert!(w.contains(eb.min) && w.contains(eb.max));
|
||||
assert!(w.width() < ext.width() && w.height() < ext.height(), "{w:?} vs extents {ext:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_export_honours_zoom() {
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-cli-165-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = |n: &str| dir.join(n).to_string_lossy().to_string();
|
||||
let run_svg = |extra: &[&str], out: &str| -> String {
|
||||
let mut args: Vec<String> = ["run", "--sample"].iter().chain(extra).map(|a| a.to_string()).collect();
|
||||
args.extend(["--export".to_string(), path(out)]);
|
||||
assert_eq!(run(args, &mut Vec::new(), &mut Vec::new()), 0);
|
||||
std::fs::read_to_string(path(out)).unwrap()
|
||||
};
|
||||
let base = run_svg(&[], "base.svg");
|
||||
let zoomed = run_svg(&["--cmd", r#"zoom {"mode":"window","p1":[0,0],"p2":[30,20]}"#], "zoom.svg");
|
||||
let window = run_svg(&["--window", "0,0,30,20"], "window.svg");
|
||||
let forced = run_svg(&["--cmd", r#"zoom {"mode":"window","p1":[0,0],"p2":[30,20]}"#, "--view", "extents"], "forced.svg");
|
||||
assert_ne!(view_box(&zoomed), view_box(&base));
|
||||
assert_eq!(view_box(&zoomed), "0 0 30.000000 20.000000");
|
||||
assert_eq!(zoomed, window);
|
||||
assert_eq!(forced, base);
|
||||
// Hostile --window: a usage error, nothing written.
|
||||
let mut err = Vec::new();
|
||||
assert_eq!(run(["run", "--sample", "--window", "0,0,NaN,1", "--export", &path("bad.png")], &mut Vec::new(), &mut err), 2);
|
||||
assert!(String::from_utf8_lossy(&err).contains("--window"));
|
||||
assert!(!dir.join("bad.png").exists());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,735 @@
|
||||
//! `cadcraft-cli`: headless CADCraft.
|
||||
//!
|
||||
//! ```text
|
||||
//! cadcraft-cli info FILE.dxf summary as JSON
|
||||
//! cadcraft-cli convert IN.dxf OUT.(dxf|svg|png) [--window x0,y0,x1,y1 | --view extents|current]
|
||||
//! cadcraft-cli run [FILE|--sample|--metric] [--script TEXT|--script-file F.scr] [--cmd 'id {json}']... [--save OUT] [--export OUT.png] [--window ... | --view ...]
|
||||
//! cadcraft-cli commands [FILTER] command catalog (JSON)
|
||||
//! cadcraft-cli mcp [--connect HOST:PORT] MCP server on stdio (headless or bridged to the app)
|
||||
//! cadcraft-cli perf [N] timing table on a synthetic N-entity drawing
|
||||
//! cadcraft-cli sample (bracket|floorplan) OUT write a built-in sample drawing
|
||||
//! ```
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
fn main() {
|
||||
// `args_os`, not `args`: a non-Unicode argument (a Latin-1 file name on Linux) must be
|
||||
// reported as an error, not panic before anything is printed.
|
||||
let code = cadcraft_cli::run(std::env::args_os().skip(1), &mut std::io::stdout(), &mut std::io::stderr());
|
||||
std::process::exit(code);
|
||||
use std::process::ExitCode;
|
||||
|
||||
use cadcraft_engine::doc::Space;
|
||||
use cadcraft_engine::geom::{Bounds2, Vec2};
|
||||
use cadcraft_engine::{Session, View};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const USAGE: &str = "usage:
|
||||
cadcraft-cli info FILE.dxf
|
||||
cadcraft-cli convert IN.dxf OUT.(dxf|dwg|svg|png|pdf) [FRAMING]
|
||||
cadcraft-cli run [FILE | --sample | --metric] [--script TEXT] [--script-file F.scr] [--cmd 'id {json}']... [--save OUT.dxf] [--export OUT.(png|svg|pdf)] [FRAMING]
|
||||
cadcraft-cli commands [FILTER]
|
||||
cadcraft-cli mcp [--connect HOST:PORT]
|
||||
cadcraft-cli perf [N]
|
||||
cadcraft-cli sample (bracket|floorplan) OUT.(dxf|dwg|svg|png)
|
||||
cadcraft-cli --version
|
||||
|
||||
image framing (png 2400x1600, svg, pdf; always model space):
|
||||
default convert: fit the drawing extents.
|
||||
run --export: the current view if the script/commands changed it
|
||||
(zoom, pan, view.set...), otherwise fit the drawing extents.
|
||||
--window x0,y0,x1,y1 show this rectangle (drawing units, x0<x1, y0<y1), centred and
|
||||
scaled to fit the image without distortion.
|
||||
--view extents always fit the drawing extents.
|
||||
--view current the current view (as last set by zoom/pan/view.set; a freshly
|
||||
opened drawing's view is its extents plus a 10% margin).";
|
||||
|
||||
/// What part of model space an image export shows.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Framing {
|
||||
/// `run`: the current view if the commands changed it, else the extents. `convert`: extents.
|
||||
Auto,
|
||||
Extents,
|
||||
Current,
|
||||
Window(Bounds2),
|
||||
}
|
||||
|
||||
/// Parse `--window x0,y0,x1,y1` (min corner first). Hostile values are errors, never panics.
|
||||
fn parse_window(text: &str) -> Result<Bounds2, String> {
|
||||
let parts: Vec<&str> = text.split(',').map(str::trim).collect();
|
||||
let [x0, y0, x1, y1] = parts.as_slice() else {
|
||||
return Err(format!("--window needs four numbers x0,y0,x1,y1, got `{text}`"));
|
||||
};
|
||||
let num = |name: &str, v: &str| -> Result<f64, String> {
|
||||
match v.parse::<f64>() {
|
||||
Ok(n) if n.is_finite() => Ok(n),
|
||||
_ => Err(format!("--window: {name} must be a finite number, got `{v}`")),
|
||||
}
|
||||
};
|
||||
let (x0, y0, x1, y1) = (num("x0", x0)?, num("y0", y0)?, num("x1", x1)?, num("y1", y1)?);
|
||||
if !(x0 < x1 && y0 < y1) {
|
||||
return Err(format!("--window: need x0 < x1 and y0 < y1 (lower-left corner first), got {x0},{y0},{x1},{y1}"));
|
||||
}
|
||||
let w = Bounds2 { min: Vec2::new(x0, y0), max: Vec2::new(x1, y1) };
|
||||
cadcraft_io::check_window(&w).map_err(|e| format!("--window: {e}"))?;
|
||||
Ok(w)
|
||||
}
|
||||
|
||||
/// Parse a framing option (`--window`, `--view`) at `a`, taking its value from `it`. `Ok(false)`
|
||||
/// means `a` is not a framing option.
|
||||
fn framing_option<'a>(a: &str, it: &mut impl Iterator<Item = &'a String>, framing: &mut Framing) -> Result<bool, String> {
|
||||
let f = match a {
|
||||
"--window" => Framing::Window(parse_window(it.next().ok_or("--window needs x0,y0,x1,y1")?)?),
|
||||
"--view" => match it.next().map(|v| v.to_ascii_lowercase()).as_deref() {
|
||||
Some("extents") => Framing::Extents,
|
||||
Some("current") => Framing::Current,
|
||||
_ => return Err("--view needs `extents` or `current`".into()),
|
||||
},
|
||||
_ => return Ok(false),
|
||||
};
|
||||
if *framing != Framing::Auto && std::mem::discriminant(framing) != std::mem::discriminant(&f) {
|
||||
return Err("use only one of --window and --view".into());
|
||||
}
|
||||
*framing = f;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// The model-space view of every open drawing, by drawing id (to tell whether commands changed it).
|
||||
fn model_views(s: &Session) -> Vec<(u64, Option<View>)> {
|
||||
s.docs.iter().map(|st| (st.uid, st.views.iter().find(|(sp, _)| *sp == Space::Model).map(|(_, v)| *v))).collect()
|
||||
}
|
||||
|
||||
/// The active drawing's current model-space view as a rectangle with the viewport's aspect ratio
|
||||
/// (the CLI viewport is 3:2 like the PNG export, so the image shows exactly this view).
|
||||
fn current_window(s: &Session) -> Option<Bounds2> {
|
||||
let st = s.state().ok()?;
|
||||
let v = st.views.iter().find(|(sp, _)| *sp == Space::Model).map(|(_, v)| *v)?;
|
||||
let (w, h) = s.viewport_px;
|
||||
let half = Vec2::new(v.height * w / h.max(1.0), v.height) * 0.5;
|
||||
Some(Bounds2::new(v.center - half, v.center + half))
|
||||
}
|
||||
|
||||
/// The window an image export shows, or `None` to fit the extents. `before` is [`model_views`]
|
||||
/// taken before the commands ran (`None` for `convert`, where nothing ran).
|
||||
fn export_window(s: &Session, framing: Framing, before: Option<&[(u64, Option<View>)]>) -> Result<Option<Bounds2>, String> {
|
||||
match framing {
|
||||
Framing::Window(w) => Ok(Some(w)),
|
||||
Framing::Extents => Ok(None),
|
||||
Framing::Current => current_window(s).map(Some).ok_or_else(|| "--view current: the drawing has no model-space view".into()),
|
||||
Framing::Auto => {
|
||||
let Some(before) = before else { return Ok(None) };
|
||||
let Ok(st) = s.state() else { return Ok(None) };
|
||||
let now = model_views(s);
|
||||
let was = before.iter().find(|(uid, _)| *uid == st.uid);
|
||||
let is = now.iter().find(|(uid, _)| *uid == st.uid);
|
||||
// Only a view the commands changed counts; a drawing opened by the commands keeps
|
||||
// the extents framing.
|
||||
match (was, is) {
|
||||
(Some(a), Some(b)) if a.1 != b.1 => Ok(current_window(s)),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn install_io() {
|
||||
cadcraft_engine::cmd::file::set_io(cadcraft_engine::cmd::file::IoHooks {
|
||||
read: |b, name| cadcraft_io::read(b, name).map_err(|e| e.to_string()),
|
||||
write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()),
|
||||
plot: Some(|d, space, opts| cadcraft_io::plot(d, space, opts).map_err(|e| e.to_string())),
|
||||
});
|
||||
}
|
||||
|
||||
fn open(s: &mut Session, path: &str) -> Result<(), String> {
|
||||
s.execute("open", &json!({ "path": path })).map(|_| ()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct OptionSpec {
|
||||
name: &'static str,
|
||||
takes_value: bool,
|
||||
}
|
||||
|
||||
fn validate_options(command: &str, args: &[String], specs: &[OptionSpec]) -> Result<(), String> {
|
||||
let mut i = 0;
|
||||
while let Some(arg) = args.get(i) {
|
||||
if let Some(name) = arg.strip_prefix("--") {
|
||||
let Some(spec) = specs.iter().find(|spec| spec.name == arg) else {
|
||||
return Err(format!("{command}: unknown option --{name}"));
|
||||
};
|
||||
if spec.takes_value && args.get(i + 1).is_some_and(|value| !value.starts_with("--")) {
|
||||
i += 2;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const NO_OPTIONS: &[OptionSpec] = &[];
|
||||
|
||||
fn info(path: &str) -> Result<(), String> {
|
||||
let mut s = Session::empty();
|
||||
open(&mut s, path)?;
|
||||
let v = s.execute("drawing.inspect", &json!({ "entities": false })).map_err(|e| e.to_string())?;
|
||||
println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn export(s: &Session, out: &str) -> Result<(), String> {
|
||||
export_framed(s, out, None)
|
||||
}
|
||||
|
||||
fn export_framed(s: &Session, out: &str, window: Option<Bounds2>) -> Result<(), String> {
|
||||
let d = s.doc().map_err(|e| e.to_string())?;
|
||||
let bytes = cadcraft_io::write_framed(d, out, window).map_err(|e| e.to_string())?;
|
||||
std::fs::write(out, &bytes).map_err(|e| format!("{out}: {e}"))?;
|
||||
eprintln!("wrote {out} ({} bytes)", bytes.len());
|
||||
if !cadcraft_io::is_image(out) {
|
||||
let lost = cadcraft_engine::cmd::file::not_saved(d);
|
||||
if let Some(m) = cadcraft_engine::cmd::file::not_saved_message(&lost) {
|
||||
eprintln!("{m}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn sample(args: &[String]) -> Result<(), String> {
|
||||
let (Some(which), Some(out)) = (args.first(), args.get(1)) else { return Err(USAGE.into()) };
|
||||
let d = match which.as_str() {
|
||||
"bracket" => cadcraft_engine::sample::bracket(),
|
||||
"floorplan" | "floor" => cadcraft_engine::sample::floor_plan(),
|
||||
other => return Err(format!("unknown sample `{other}` (bracket, floorplan)")),
|
||||
};
|
||||
let bytes = cadcraft_io::write(&d, out).map_err(|e| e.to_string())?;
|
||||
std::fs::write(out, &bytes).map_err(|e| format!("{out}: {e}"))?;
|
||||
eprintln!("wrote {out} ({} bytes)", bytes.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reject an explicit framing option without an image output to apply it to.
|
||||
fn check_framed_output(framing: Framing, out: Option<&str>) -> Result<(), String> {
|
||||
match out {
|
||||
_ if framing == Framing::Auto => Ok(()),
|
||||
Some(o) if !cadcraft_io::is_image(o) => Err(format!("--window/--view only apply to image output (png, svg, pdf), not `{o}`")),
|
||||
Some(_) => Ok(()),
|
||||
None => Err("--window/--view need an image output (--export OUT.png|svg|pdf)".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn convert(args: &[String]) -> Result<(), String> {
|
||||
let mut framing = Framing::Auto;
|
||||
let mut files = Vec::new();
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
if !framing_option(a, &mut it, &mut framing)? {
|
||||
files.push(a);
|
||||
}
|
||||
}
|
||||
let [i, o] = files.as_slice() else { return Err(USAGE.into()) };
|
||||
check_framed_output(framing, Some(o))?;
|
||||
let mut s = Session::empty();
|
||||
open(&mut s, i)?;
|
||||
let window = export_window(&s, framing, None)?;
|
||||
export_framed(&s, o, window)
|
||||
}
|
||||
|
||||
fn run(args: &[String]) -> Result<(), String> {
|
||||
let mut s = Session::empty();
|
||||
let mut it = args.iter();
|
||||
let mut save = None;
|
||||
let mut exp: Option<String> = None;
|
||||
let mut framing = Framing::Auto;
|
||||
let mut steps: Vec<(String, String)> = Vec::new();
|
||||
while let Some(a) = it.next() {
|
||||
if framing_option(a, &mut it, &mut framing)? {
|
||||
continue;
|
||||
}
|
||||
match a.as_str() {
|
||||
"--sample" => {
|
||||
s.open_drawing(cadcraft_engine::sample::default_sample(), "Bracket", None);
|
||||
}
|
||||
"--metric" => {
|
||||
s.new_drawing(true);
|
||||
}
|
||||
"--script" => steps.push(("script".into(), it.next().ok_or("--script needs text")?.replace("\\n", "\n"))),
|
||||
"--script-file" => {
|
||||
let p = it.next().ok_or("--script-file needs a path")?;
|
||||
steps.push(("script".into(), std::fs::read_to_string(p).map_err(|e| format!("{p}: {e}"))?));
|
||||
}
|
||||
"--cmd" => steps.push(("cmd".into(), it.next().ok_or("--cmd needs `id {json}`")?.clone())),
|
||||
"--save" => save = Some(it.next().ok_or("--save needs a path")?.clone()),
|
||||
"--export" => exp = Some(it.next().ok_or("--export needs a path")?.clone()),
|
||||
f if !f.starts_with("--") => open(&mut s, f)?,
|
||||
other => return Err(format!("unknown option {other}")),
|
||||
}
|
||||
}
|
||||
if s.docs.is_empty() {
|
||||
s.new_drawing(false);
|
||||
}
|
||||
check_framed_output(framing, exp.as_deref())?;
|
||||
let before = model_views(&s);
|
||||
for (kind, body) in steps {
|
||||
match kind.as_str() {
|
||||
"script" => s.script(&body).map_err(|e| e.to_string())?,
|
||||
_ => {
|
||||
let (id, json) = body.split_once(' ').unwrap_or((&body, "{}"));
|
||||
let params: Value = serde_json::from_str(json).map_err(|e| format!("--cmd {id}: {e}"))?;
|
||||
let r = s.execute(id, ¶ms).map_err(|e| e.to_string())?;
|
||||
if !r.is_null() {
|
||||
println!("{r}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for l in &s.log {
|
||||
eprintln!("{l}");
|
||||
}
|
||||
if let Some(p) = save {
|
||||
export(&s, &p)?;
|
||||
}
|
||||
if let Some(p) = exp {
|
||||
let window = if cadcraft_io::is_image(&p) { export_window(&s, framing, Some(&before))? } else { None };
|
||||
export_framed(&s, &p, window)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Small deterministic generator (xorshift64*) so perf runs are repeatable without a dependency.
|
||||
struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
fn next(&mut self) -> f64 {
|
||||
self.0 ^= self.0 >> 12;
|
||||
self.0 ^= self.0 << 25;
|
||||
self.0 ^= self.0 >> 27;
|
||||
(self.0.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 11) as f64 / (1u64 << 53) as f64
|
||||
}
|
||||
fn range(&mut self, lo: f64, hi: f64) -> f64 {
|
||||
lo + (hi - lo) * self.next()
|
||||
}
|
||||
}
|
||||
|
||||
/// A synthetic drawing with `n` mixed entities (lines, circles, arcs, polylines, text) spread
|
||||
/// over a square whose side grows with sqrt(n), so density stays constant.
|
||||
fn synthetic(n: usize, side: f64) -> Result<cadcraft_engine::doc::Drawing, String> {
|
||||
use cadcraft_engine::doc::{self as d, Common, EntityKind, Space};
|
||||
use cadcraft_engine::geom::{PolyVertex, Vec2, Vec3};
|
||||
let mut dr = d::Drawing::new_imperial();
|
||||
let mut r = Rng(0x9E37_79B9_7F4A_7C15);
|
||||
let v3 = |x: f64, y: f64| Vec3::new(x, y, 0.0);
|
||||
for i in 0..n {
|
||||
let (x, y) = (r.range(0.0, side), r.range(0.0, side));
|
||||
let s = r.range(0.5, 8.0);
|
||||
let kind = match i % 5 {
|
||||
0 => EntityKind::Line(d::Line { a: v3(x, y), b: v3(x + r.range(-s, s), y + r.range(-s, s)) }),
|
||||
1 => EntityKind::Circle(d::Circle { center: v3(x, y), radius: s * 0.5 }),
|
||||
2 => EntityKind::Arc(d::Arc { center: v3(x, y), radius: s * 0.5, start: r.range(0.0, 3.0), end: r.range(3.2, 6.2) }),
|
||||
3 => EntityKind::LwPolyline(d::LwPolyline {
|
||||
vertices: (0..6)
|
||||
.map(|k| PolyVertex {
|
||||
p: Vec2::new(x + f64::from(k) * s * 0.3, y + r.range(-s, s) * 0.3),
|
||||
bulge: if k == 2 { 0.4 } else { 0.0 },
|
||||
start_width: 0.0,
|
||||
end_width: 0.0,
|
||||
})
|
||||
.collect(),
|
||||
closed: i % 2 == 0,
|
||||
const_width: 0.0,
|
||||
elevation: 0.0,
|
||||
plinegen: false,
|
||||
}),
|
||||
_ => EntityKind::Text(d::Text {
|
||||
insert: v3(x, y),
|
||||
align_pt: None,
|
||||
height: s * 0.2,
|
||||
value: format!("T{i}"),
|
||||
rotation: 0.0,
|
||||
width_factor: 1.0,
|
||||
oblique: 0.0,
|
||||
style: "Standard".into(),
|
||||
halign: d::HAlign::Left,
|
||||
valign: d::VAlign::Baseline,
|
||||
}),
|
||||
};
|
||||
dr.add(&Space::Model, Common::default(), kind).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(dr)
|
||||
}
|
||||
|
||||
fn perf(args: &[String]) -> Result<(), String> {
|
||||
use cadcraft_engine::doc::Space;
|
||||
use cadcraft_engine::geom::{Bounds2, Vec2};
|
||||
use cadcraft_engine::{select, snap};
|
||||
use std::time::Instant;
|
||||
let n: usize = match args.first() {
|
||||
Some(a) => a.parse().map_err(|_| format!("perf: bad entity count {a}"))?,
|
||||
None => 200_000,
|
||||
};
|
||||
let n = n.clamp(1, 20_000_000);
|
||||
let side = (n as f64).sqrt() * 10.0;
|
||||
let mut rows: Vec<(String, f64, String)> = Vec::new();
|
||||
let ms = |t: Instant| t.elapsed().as_secs_f64() * 1000.0;
|
||||
|
||||
let t = Instant::now();
|
||||
let d = synthetic(n, side)?;
|
||||
rows.push(("build synthetic drawing".into(), ms(t), format!("{n} entities")));
|
||||
|
||||
// Display list at a 1600 px wide "zoom extents" view (tolerance = half a pixel).
|
||||
let px = side / 1600.0;
|
||||
let opts = cadcraft_engine::render::Options { tolerance: px * 0.5, min_dash: px * 2.0, ..Default::default() };
|
||||
let t = Instant::now();
|
||||
let list = cadcraft_engine::render::build(&d, &Space::Model, &opts);
|
||||
rows.push(("display list build".into(), ms(t), format!("{} prims, {} segments", list.prims.len(), list.segment_count())));
|
||||
|
||||
// Hit testing on a zoomed-in view (aperture = 5 px at 0.02 units/px).
|
||||
let ap = 0.1;
|
||||
let mut r = Rng(42);
|
||||
let pts: Vec<Vec2> = (0..1000).map(|_| Vec2::new(r.range(0.0, side), r.range(0.0, side))).collect();
|
||||
let t = Instant::now();
|
||||
let first = select::pick(&d, &Space::Model, pts.first().copied().unwrap_or(Vec2::ZERO), ap);
|
||||
rows.push(("first pick (incl. index build)".into(), ms(t), format!("{first:?}")));
|
||||
let t = Instant::now();
|
||||
let hits = pts.iter().filter(|p| select::pick(&d, &Space::Model, **p, ap).is_some()).count();
|
||||
let total = ms(t);
|
||||
rows.push(("pick x1000".into(), total, format!("{hits} hits, {:.3} ms/pick", total / 1000.0)));
|
||||
|
||||
// One edit, then a pick: only the touched chunk is re-indexed.
|
||||
let mut edited = d.clone();
|
||||
if let Some(h) = edited.model.last().map(|e| e.handle) {
|
||||
edited.model.modify(h, |e| e.common.layer = "0".into());
|
||||
}
|
||||
let t = Instant::now();
|
||||
let _ = select::pick(&edited, &Space::Model, pts.first().copied().unwrap_or(Vec2::ZERO), ap);
|
||||
rows.push(("pick after one edit (incremental re-index)".into(), ms(t), String::new()));
|
||||
|
||||
let mut sel = 0usize;
|
||||
let t = Instant::now();
|
||||
for k in 0..20 {
|
||||
let c = Vec2::new(r.range(0.0, side), r.range(0.0, side));
|
||||
let half = side * 0.05;
|
||||
sel += select::select_window(&d, &Space::Model, Bounds2::new(c - Vec2::new(half, half), c + Vec2::new(half, half)), k % 2 == 1).len();
|
||||
}
|
||||
rows.push(("window/crossing select x20 (10% side)".into(), ms(t), format!("{sel} selected")));
|
||||
|
||||
let t = Instant::now();
|
||||
let fence: Vec<Vec2> = (0..4).map(|_| Vec2::new(r.range(0.0, side), r.range(0.0, side))).collect();
|
||||
let fsel = select::select_fence(&d, &Space::Model, &fence).len();
|
||||
rows.push(("fence select (3 legs)".into(), ms(t), format!("{fsel} selected")));
|
||||
|
||||
let osmode = snap::mode::END | snap::mode::MID | snap::mode::CEN | snap::mode::QUA | snap::mode::INT;
|
||||
let t = Instant::now();
|
||||
let snaps = pts.iter().filter(|p| snap::osnap(&d, &Space::Model, **p, ap * 2.0, osmode, None, false).is_some()).count();
|
||||
let total = ms(t);
|
||||
rows.push(("osnap x1000".into(), total, format!("{snaps} hits, {:.3} ms/query", total / 1000.0)));
|
||||
|
||||
let t = Instant::now();
|
||||
let bytes = cadcraft_io::write(&d, "perf.dxf").map_err(|e| e.to_string())?;
|
||||
rows.push(("DXF write".into(), ms(t), format!("{:.1} MB", bytes.len() as f64 / 1e6)));
|
||||
let t = Instant::now();
|
||||
let back = cadcraft_io::read(&bytes, "perf.dxf").map_err(|e| e.to_string())?;
|
||||
rows.push(("DXF read".into(), ms(t), format!("{} entities", back.entity_count())));
|
||||
|
||||
println!("{:<40} {:>12} notes", "operation", "ms");
|
||||
println!("{}", "-".repeat(80));
|
||||
for (name, t, note) in rows {
|
||||
println!("{name:<40} {t:>12.2} {note}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The backend `cadcraft-cli mcp` serves from.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum McpTarget {
|
||||
/// An in-process drawing session (no `--connect`).
|
||||
Headless,
|
||||
/// The running app's control channel at `HOST:PORT`.
|
||||
Connect(String),
|
||||
}
|
||||
|
||||
/// Parses the arguments after `mcp`. `--connect` must be followed by a `HOST:PORT`; a missing,
|
||||
/// empty or flag-like value is an error, never a silent fall back to a headless session (#103).
|
||||
fn parse_mcp_args(args: &[String]) -> Result<McpTarget, String> {
|
||||
let mut target = McpTarget::Headless;
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
match a.as_str() {
|
||||
"--connect" => {
|
||||
let addr = it
|
||||
.next()
|
||||
.map(|v| v.trim())
|
||||
.filter(|v| !v.is_empty() && !v.starts_with('-'))
|
||||
.ok_or("--connect needs HOST:PORT (the app's --control port, e.g. 127.0.0.1:7979)")?;
|
||||
if target != McpTarget::Headless {
|
||||
return Err("--connect given more than once".into());
|
||||
}
|
||||
target = McpTarget::Connect(addr.to_string());
|
||||
}
|
||||
other => return Err(format!("mcp: unknown argument {other}\n{USAGE}")),
|
||||
}
|
||||
}
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
fn mcp(args: &[String]) -> Result<(), String> {
|
||||
let backend: Box<dyn cadcraft_mcp::Backend> = match parse_mcp_args(args)? {
|
||||
McpTarget::Connect(addr) => match cadcraft_mcp::Remote::connect(&addr) {
|
||||
Ok(r) => Box::new(r),
|
||||
Err(e) => return Err(format!("cadcraft-cli: cannot connect to {addr}: {e}")),
|
||||
},
|
||||
McpTarget::Headless => Box::new(cadcraft_mcp::Headless::default()),
|
||||
};
|
||||
let stdin = std::io::stdin();
|
||||
let stdout = std::io::stdout();
|
||||
cadcraft_mcp::Server::new(backend).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod mcp_args_tests {
|
||||
use super::*;
|
||||
|
||||
fn args(a: &[&str]) -> Vec<String> {
|
||||
a.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_without_connect_is_headless() {
|
||||
assert_eq!(parse_mcp_args(&args(&[])), Ok(McpTarget::Headless));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_connect_with_address_selects_remote() {
|
||||
assert_eq!(parse_mcp_args(&args(&["--connect", "127.0.0.1:7979"])), Ok(McpTarget::Connect("127.0.0.1:7979".into())));
|
||||
assert_eq!(parse_mcp_args(&args(&["--connect", "localhost:7979"])), Ok(McpTarget::Connect("localhost:7979".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_connect_without_address_is_an_error_not_headless() {
|
||||
for bad in [&["--connect"][..], &["--connect", ""], &["--connect", " "], &["--connect", "--foo"], &["--connect", "-v"]] {
|
||||
let r = parse_mcp_args(&args(bad));
|
||||
let Err(e) = r else { panic!("{bad:?} should be rejected, got {r:?}") };
|
||||
assert!(e.contains("--connect needs HOST:PORT"), "{bad:?}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_rejects_unknown_and_repeated_arguments() {
|
||||
assert!(parse_mcp_args(&args(&["--conect", "127.0.0.1:7979"])).is_err());
|
||||
assert!(parse_mcp_args(&args(&["127.0.0.1:7979"])).is_err());
|
||||
assert!(parse_mcp_args(&args(&["--connect", "127.0.0.1:1", "--connect", "127.0.0.1:2"])).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
install_io();
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let rest: Vec<String> = args.iter().skip(1).cloned().collect();
|
||||
let r = match args.first().map(String::as_str) {
|
||||
Some("info") => validate_options("info", &rest, NO_OPTIONS).and_then(|_| rest.first().ok_or_else(|| USAGE.to_string())).and_then(|p| info(p)),
|
||||
Some("convert") => convert(&rest),
|
||||
Some("run") => run(&rest),
|
||||
Some("perf") => validate_options("perf", &rest, NO_OPTIONS).and_then(|_| perf(&rest)),
|
||||
Some("sample") => validate_options("sample", &rest, NO_OPTIONS).and_then(|_| sample(&rest)),
|
||||
Some("commands") => validate_options("commands", &rest, NO_OPTIONS).map(|_| {
|
||||
let s = Session::new();
|
||||
let f = rest.first().map(|x| x.to_ascii_lowercase());
|
||||
let v: Vec<Value> = cadcraft_engine::command_specs()
|
||||
.iter()
|
||||
.filter(|c| f.as_ref().is_none_or(|f| c.id.contains(f.as_str()) || c.label.to_ascii_lowercase().contains(f.as_str())))
|
||||
.map(|c| serde_json::to_value(c.info(&s)).unwrap_or_default())
|
||||
.collect();
|
||||
println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
|
||||
}),
|
||||
Some("mcp") => mcp(&rest),
|
||||
Some("--version" | "-V") => {
|
||||
println!("cadcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(USAGE.into()),
|
||||
};
|
||||
match r {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("{e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn b(x0: f64, y0: f64, x1: f64, y1: f64) -> Bounds2 {
|
||||
Bounds2::new(Vec2::new(x0, y0), Vec2::new(x1, y1))
|
||||
}
|
||||
|
||||
fn sample_session() -> Session {
|
||||
let mut s = Session::empty();
|
||||
s.open_drawing(cadcraft_engine::sample::default_sample(), "Bracket", None);
|
||||
s
|
||||
}
|
||||
|
||||
fn view_box(svg: &str) -> String {
|
||||
let i = svg.find("viewBox=\"").map(|i| i + 9).unwrap_or(0);
|
||||
svg[i..].split('"').next().unwrap_or("").to_string()
|
||||
}
|
||||
|
||||
fn assert_near(a: Bounds2, e: Bounds2) {
|
||||
let d = (a.min - e.min).len().max((a.max - e.max).len());
|
||||
assert!(d < 1e-9, "{a:?} != {e:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_parses() {
|
||||
assert_eq!(parse_window("0,0,30,20"), Ok(b(0.0, 0.0, 30.0, 20.0)));
|
||||
assert_eq!(parse_window(" -5.5, -1e3 ,2e3,4 "), Ok(b(-5.5, -1000.0, 2000.0, 4.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_window_is_an_error() {
|
||||
for w in [
|
||||
"",
|
||||
"1,2,3",
|
||||
"1,2,3,4,5",
|
||||
"a,0,1,1",
|
||||
"NaN,0,1,1",
|
||||
"0,0,inf,1",
|
||||
"0,0,1e400,1",
|
||||
"0,0,0,1",
|
||||
"0,0,1,0",
|
||||
"5,0,1,1",
|
||||
"0,5,1,1",
|
||||
"-1e300,-1e300,1e300,1e300",
|
||||
"0,0,1e-12,1e-12",
|
||||
] {
|
||||
assert!(parse_window(w).is_err(), "`{w}` should be rejected");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn framing_options() {
|
||||
let parse = |args: &[&str]| -> Result<Framing, String> {
|
||||
let args: Vec<String> = args.iter().map(|a| a.to_string()).collect();
|
||||
let mut f = Framing::Auto;
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
assert!(framing_option(a, &mut it, &mut f)?, "{a} is a framing option");
|
||||
}
|
||||
Ok(f)
|
||||
};
|
||||
assert_eq!(parse(&["--view", "extents"]), Ok(Framing::Extents));
|
||||
assert_eq!(parse(&["--view", "CURRENT"]), Ok(Framing::Current));
|
||||
assert_eq!(parse(&["--window", "0,0,2,1"]), Ok(Framing::Window(b(0.0, 0.0, 2.0, 1.0))));
|
||||
assert!(parse(&["--view"]).is_err());
|
||||
assert!(parse(&["--view", "selection"]).is_err());
|
||||
assert!(parse(&["--window"]).is_err());
|
||||
assert!(parse(&["--window", "0,0,2,1", "--view", "extents"]).is_err());
|
||||
assert!(check_framed_output(Framing::Extents, Some("a.dxf")).is_err());
|
||||
assert!(check_framed_output(Framing::Extents, None).is_err());
|
||||
assert!(check_framed_output(Framing::Extents, Some("a.png")).is_ok());
|
||||
assert!(check_framed_output(Framing::Auto, Some("a.dxf")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_framing_is_extents() {
|
||||
// convert: nothing ran, always the extents.
|
||||
let s = sample_session();
|
||||
assert_eq!(export_window(&s, Framing::Auto, None), Ok(None));
|
||||
// run: commands that leave the view alone keep the extents framing.
|
||||
let mut s = sample_session();
|
||||
let before = model_views(&s);
|
||||
s.script("LINE 0,0 10,10\n\n").unwrap();
|
||||
assert_eq!(export_window(&s, Framing::Auto, Some(&before)), Ok(None));
|
||||
// A fresh drawing too.
|
||||
let mut s = Session::empty();
|
||||
s.new_drawing(false);
|
||||
let before = model_views(&s);
|
||||
s.execute("circle", &json!({"center": [0, 0], "radius": 5})).unwrap();
|
||||
assert_eq!(export_window(&s, Framing::Auto, Some(&before)), Ok(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_window_frames_export() {
|
||||
let mut s = sample_session();
|
||||
let before = model_views(&s);
|
||||
s.execute("zoom", &json!({"mode": "window", "p1": [0, 0], "p2": [30, 20]})).unwrap();
|
||||
let w = export_window(&s, Framing::Auto, Some(&before)).unwrap().unwrap();
|
||||
// The CLI viewport is 3:2, so a 3:2 window is shown exactly.
|
||||
assert_near(w, b(0.0, 0.0, 30.0, 20.0));
|
||||
assert_eq!(export_window(&s, Framing::Current, Some(&before)), Ok(Some(w)));
|
||||
// --view extents and --window override the zoom.
|
||||
assert_eq!(export_window(&s, Framing::Extents, Some(&before)), Ok(None));
|
||||
let explicit = b(1.0, 1.0, 2.0, 2.0);
|
||||
assert_eq!(export_window(&s, Framing::Window(explicit), Some(&before)), Ok(Some(explicit)));
|
||||
// A taller window keeps its height; the 3:2 image adds room left and right.
|
||||
s.execute("zoom", &json!({"mode": "center", "center": [5, 5], "height": 4})).unwrap();
|
||||
assert_near(export_window(&s, Framing::Auto, Some(&before)).unwrap().unwrap(), b(2.0, 3.0, 8.0, 7.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_object_frames_the_selection() {
|
||||
let mut s = sample_session();
|
||||
let before = model_views(&s);
|
||||
// The smallest entity with an area, selected then framed with ZOOM Object.
|
||||
let d = s.doc().unwrap();
|
||||
let bounds = |e: &cadcraft_engine::doc::Entity| cadcraft_engine::doc::entity_bounds(d, e, 0);
|
||||
let e = d
|
||||
.model
|
||||
.iter()
|
||||
.filter(|e| bounds(e).width() > 0.0 && bounds(e).height() > 0.0)
|
||||
.min_by(|a, b| bounds(a).width().total_cmp(&bounds(b).width()))
|
||||
.unwrap();
|
||||
let (h, eb, ext) = (e.handle, bounds(e), d.extents(&Space::Model));
|
||||
s.set_selection(vec![h]);
|
||||
s.execute("zoom", &json!({"mode": "object"})).unwrap();
|
||||
let w = export_window(&s, Framing::Auto, Some(&before)).unwrap().unwrap();
|
||||
assert!(w.contains(eb.min) && w.contains(eb.max));
|
||||
assert!(w.width() < ext.width() && w.height() < ext.height(), "{w:?} vs extents {ext:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_export_honours_zoom() {
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-cli-165-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = |n: &str| dir.join(n).to_string_lossy().to_string();
|
||||
let run_svg = |extra: &[&str], out: &str| -> String {
|
||||
let mut args: Vec<String> = ["--sample"].iter().chain(extra).map(|a| a.to_string()).collect();
|
||||
args.extend(["--export".to_string(), path(out)]);
|
||||
run(&args).unwrap();
|
||||
std::fs::read_to_string(path(out)).unwrap()
|
||||
};
|
||||
let base = run_svg(&[], "base.svg");
|
||||
let zoomed = run_svg(&["--cmd", r#"zoom {"mode":"window","p1":[0,0],"p2":[30,20]}"#], "zoom.svg");
|
||||
let window = run_svg(&["--window", "0,0,30,20"], "window.svg");
|
||||
let forced = run_svg(&["--cmd", r#"zoom {"mode":"window","p1":[0,0],"p2":[30,20]}"#, "--view", "extents"], "forced.svg");
|
||||
assert_ne!(view_box(&zoomed), view_box(&base));
|
||||
assert_eq!(view_box(&zoomed), "0 0 30.000000 20.000000");
|
||||
assert_eq!(zoomed, window);
|
||||
assert_eq!(forced, base);
|
||||
// Hostile --window: a clear error, nothing written.
|
||||
let bad: Vec<String> = ["--sample", "--window", "0,0,NaN,1", "--export", &path("bad.png")].iter().map(|a| a.to_string()).collect();
|
||||
assert!(run(&bad).unwrap_err().contains("--window"));
|
||||
assert!(!dir.join("bad.png").exists());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
fn args(values: &[&str]) -> Vec<String> {
|
||||
values.iter().map(|value| (*value).into()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validators_accept_only_the_documented_options() {
|
||||
assert!(validate_options("info", &args(&["file.dxf"]), NO_OPTIONS).is_ok());
|
||||
assert!(validate_options("perf", &args(&["100"]), NO_OPTIONS).is_ok());
|
||||
assert!(validate_options("sample", &args(&["bracket", "out.dxf"]), NO_OPTIONS).is_ok());
|
||||
assert!(validate_options("commands", &args(&["line"]), NO_OPTIONS).is_ok());
|
||||
for (command, specs) in [("info", NO_OPTIONS), ("perf", NO_OPTIONS), ("sample", NO_OPTIONS), ("commands", NO_OPTIONS)] {
|
||||
assert!(validate_options(command, &args(&["--typo"]), specs).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_options_are_rejected_before_side_effects() {
|
||||
assert_eq!(validate_options("info", &args(&["missing.dxf", "--typo"]), NO_OPTIONS), Err("info: unknown option --typo".into()));
|
||||
assert_eq!(validate_options("perf", &args(&["1", "--typo"]), NO_OPTIONS), Err("perf: unknown option --typo".into()));
|
||||
assert_eq!(validate_options("sample", &args(&["bracket", "out.dxf", "--typo"]), NO_OPTIONS), Err("sample: unknown option --typo".into()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
use super::args::{McpArgs, McpTarget};
|
||||
use super::error::{CliError, Result};
|
||||
|
||||
pub(crate) fn mcp(a: &McpArgs) -> Result {
|
||||
let backend: Box<dyn cadcraft_mcp::Backend> = match a.target() {
|
||||
McpTarget::Connect { addr, lazy: true, launch } => Box::new(cadcraft_mcp::Remote::lazy(&addr, launch)),
|
||||
McpTarget::Connect { addr, .. } => match cadcraft_mcp::Remote::connect(&addr) {
|
||||
Ok(r) => Box::new(r),
|
||||
Err(source) => return Err(CliError::McpConnect { addr, source }),
|
||||
},
|
||||
McpTarget::Headless => Box::new(cadcraft_mcp::Headless::default()),
|
||||
};
|
||||
let stdin = std::io::stdin();
|
||||
let stdout = std::io::stdout();
|
||||
cadcraft_mcp::Server::new(backend).serve(stdin.lock(), stdout.lock())?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
use super::args::Framing;
|
||||
use super::error::Result;
|
||||
use cadcraft_engine::doc::Space;
|
||||
use cadcraft_engine::geom::{Bounds2, Vec2};
|
||||
use cadcraft_engine::{Session, View};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The process's output streams. A write to stdout that fails never stops the program: once
|
||||
/// stdout has failed, later output is dropped and the work goes on, so the rest of `run` (later
|
||||
/// `--cmd` results, `--save`, `--export`) still happens and `cadcraft-cli run … --save out.dxf |
|
||||
/// head` keeps the drawing. A reader that stopped early (a closed pipe, `cadcraft-cli commands |
|
||||
/// head`) is not an error, as in ripgrep; any other write error is reported once on stderr and
|
||||
/// makes the exit status 1.
|
||||
pub(crate) struct Streams<'a> {
|
||||
out: &'a mut dyn Write,
|
||||
err: &'a mut dyn Write,
|
||||
/// Set once a write to `out` has failed; later output is dropped.
|
||||
gone: bool,
|
||||
/// Set when `out` failed for a reason other than a closed reader.
|
||||
failed: bool,
|
||||
}
|
||||
|
||||
impl<'a> Streams<'a> {
|
||||
pub(crate) fn new(out: &'a mut dyn Write, err: &'a mut dyn Write) -> Self {
|
||||
Streams { out, err, gone: false, failed: false }
|
||||
}
|
||||
|
||||
/// Write a line to stdout.
|
||||
pub(crate) fn outln(&mut self, args: std::fmt::Arguments) {
|
||||
if !self.gone
|
||||
&& let Err(e) = writeln!(self.out, "{args}")
|
||||
{
|
||||
self.out_failed(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Write `v` to stdout: pretty-printed, or on one line with `compact` (`--compact`).
|
||||
pub(crate) fn json(&mut self, v: &serde_json::Value, compact: bool) {
|
||||
if compact {
|
||||
self.outln(format_args!("{v}"));
|
||||
} else {
|
||||
self.outln(format_args!("{v:#}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a line to stderr. A failing stderr has nowhere to report to, so it is ignored.
|
||||
pub(crate) fn errln(&mut self, args: std::fmt::Arguments) {
|
||||
let _ = writeln!(self.err, "{args}");
|
||||
}
|
||||
|
||||
/// Flush stdout (a pipe that closed during buffered output surfaces here).
|
||||
pub(crate) fn finish(&mut self) {
|
||||
if !self.gone
|
||||
&& let Err(e) = self.out.flush()
|
||||
{
|
||||
self.out_failed(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether stdout failed for a reason other than a closed reader: the exit status is 1.
|
||||
pub(crate) fn stdout_failed(&self) -> bool {
|
||||
self.failed
|
||||
}
|
||||
|
||||
fn out_failed(&mut self, e: std::io::Error) {
|
||||
if std::mem::replace(&mut self.gone, true) || e.kind() == std::io::ErrorKind::BrokenPipe {
|
||||
return;
|
||||
}
|
||||
self.errln(format_args!("cadcraft-cli: can't write to stdout: {e}"));
|
||||
self.failed = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Why writing or framing an output failed.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(crate) enum OutputError {
|
||||
#[error("--view current: the drawing has no model-space view")]
|
||||
NoModelView,
|
||||
#[error("{}: {source}", path.display())]
|
||||
Write { path: PathBuf, source: std::io::Error },
|
||||
}
|
||||
|
||||
/// The model-space view of every open drawing, by drawing id (to tell whether commands changed it).
|
||||
pub(crate) fn model_views(s: &Session) -> Vec<(u64, Option<View>)> {
|
||||
s.docs.iter().map(|st| (st.uid, st.views.iter().find(|(sp, _)| *sp == Space::Model).map(|(_, v)| *v))).collect()
|
||||
}
|
||||
|
||||
/// The active drawing's current model-space view as a rectangle with the viewport's aspect ratio
|
||||
/// (the CLI viewport is 3:2 like the PNG export, so the image shows exactly this view).
|
||||
fn current_window(s: &Session) -> Option<Bounds2> {
|
||||
let st = s.state().ok()?;
|
||||
let v = st.views.iter().find(|(sp, _)| *sp == Space::Model).map(|(_, v)| *v)?;
|
||||
let (w, h) = s.viewport_px;
|
||||
let half = Vec2::new(v.height * w / h.max(1.0), v.height) * 0.5;
|
||||
Some(Bounds2::new(v.center - half, v.center + half))
|
||||
}
|
||||
|
||||
/// The window an image export shows, or `None` to fit the extents. `before` is [`model_views`]
|
||||
/// taken before the commands ran (`None` for `convert`, where nothing ran).
|
||||
pub(crate) fn export_window(s: &Session, framing: Framing, before: Option<&[(u64, Option<View>)]>) -> Result<Option<Bounds2>, OutputError> {
|
||||
match framing {
|
||||
Framing::Window(w) => Ok(Some(w)),
|
||||
Framing::Extents => Ok(None),
|
||||
Framing::Current => current_window(s).map(Some).ok_or(OutputError::NoModelView),
|
||||
Framing::Auto => {
|
||||
let Some(before) = before else { return Ok(None) };
|
||||
let Ok(st) = s.state() else { return Ok(None) };
|
||||
let now = model_views(s);
|
||||
let was = before.iter().find(|(uid, _)| *uid == st.uid);
|
||||
let is = now.iter().find(|(uid, _)| *uid == st.uid);
|
||||
// Only a view the commands changed counts; a drawing opened by the commands keeps
|
||||
// the extents framing.
|
||||
match (was, is) {
|
||||
(Some(a), Some(b)) if a.1 != b.1 => Ok(current_window(s)),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write `bytes` to `out` through the engine's atomic writer (an exclusively created sibling file
|
||||
/// renamed over `out`), so a failed or interrupted write leaves the previous file untouched. An
|
||||
/// existing `out` is resolved first: the rename would otherwise replace a symlink with a file and
|
||||
/// leave the file it points to (`--in-place` through a link) unchanged. Folders missing above `out`
|
||||
/// are created, and the topmost one returned for the caller to report.
|
||||
pub(crate) fn write_atomic(out: &Path, bytes: &[u8]) -> Result<Option<std::path::PathBuf>, OutputError> {
|
||||
let target = std::fs::canonicalize(out).unwrap_or_else(|_| out.to_path_buf());
|
||||
let folder = cadcraft_engine::atomic_write::missing_folder(&target);
|
||||
cadcraft_engine::atomic_write::write_atomic_making_folders(&target, bytes)
|
||||
.map_err(|source| OutputError::Write { path: out.to_path_buf(), source })?;
|
||||
Ok(folder)
|
||||
}
|
||||
|
||||
/// Say that `out` went into a folder that didn't exist, so a mistyped path is noticed.
|
||||
pub(crate) fn report_new_folder(folder: Option<std::path::PathBuf>, io: &mut Streams) {
|
||||
if let Some(folder) = folder {
|
||||
io.errln(format_args!("created the folder {}, which did not exist", folder.display()));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn export(s: &Session, out: &Path, io: &mut Streams) -> Result {
|
||||
export_framed(s, out, None, io)
|
||||
}
|
||||
|
||||
pub(crate) fn export_framed(s: &Session, out: &Path, window: Option<Bounds2>, io: &mut Streams) -> Result {
|
||||
let d = s.doc()?;
|
||||
let bytes = cadcraft_io::write_framed(d, out, window)?;
|
||||
let folder = write_atomic(out, &bytes)?;
|
||||
report_new_folder(folder, io);
|
||||
io.errln(format_args!("wrote {} ({} bytes)", out.display(), bytes.len()));
|
||||
if cadcraft_engine::cmd::file::drops_unknown(out) {
|
||||
let lost = cadcraft_engine::cmd::file::not_saved(d);
|
||||
if let Some(m) = cadcraft_engine::cmd::file::not_saved_message(&lost) {
|
||||
io.errln(format_args!("{m}"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The warning for the model-space entities of `path` an image can't show, by type (those kept
|
||||
/// as unknown objects, `Proxy` in `info`), or `None` when there are none.
|
||||
pub(crate) fn undrawn_message(d: &cadcraft_engine::doc::Drawing, path: &str) -> Option<String> {
|
||||
let mut types: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
|
||||
for e in d.model.iter() {
|
||||
if let cadcraft_engine::doc::EntityKind::Unknown(u) = &e.kind {
|
||||
let n = types.entry(u.dxf_type.as_str()).or_insert(0);
|
||||
*n = n.saturating_add(1);
|
||||
}
|
||||
}
|
||||
let n = types.values().fold(0usize, |a, n| a.saturating_add(*n));
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
let list: Vec<String> = types.iter().map(|(t, n)| format!("{t}: {n}")).collect();
|
||||
let entities = if n == 1 { "entity" } else { "entities" };
|
||||
Some(format!("{path}: {n} {entities} not drawn ({})", list.join(", ")))
|
||||
}
|
||||
|
||||
/// The reader's notes on what it left out of `path` (a damaged DWG's unreadable object records,
|
||||
/// #859), one warning line each; empty for a complete read.
|
||||
pub(crate) fn read_note_messages(d: &cadcraft_engine::doc::Drawing, path: &str) -> Vec<String> {
|
||||
d.read_notes.iter().map(|note| format!("{path}: {note}")).collect()
|
||||
}
|
||||
|
||||
/// The warning for the records of `path` the reader could not place, or `None` when there are none.
|
||||
pub(crate) fn skipped_message(d: &cadcraft_engine::doc::Drawing, path: &str) -> Option<String> {
|
||||
let n = d.skipped_records.values().fold(0usize, |a, n| a.saturating_add(*n));
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
let list: Vec<String> = d.skipped_records.iter().map(|(section, n)| format!("{section}: {n}")).collect();
|
||||
let records = if n == 1 { "record" } else { "records" };
|
||||
Some(format!("{path}: {n} {records} could not be read and are left out ({})", list.join(", ")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::read_note_messages;
|
||||
|
||||
#[test]
|
||||
fn read_notes_become_one_warning_each() {
|
||||
let mut d = cadcraft_engine::doc::Drawing::new_imperial();
|
||||
assert!(read_note_messages(&d, "a.dwg").is_empty());
|
||||
d.read_notes =
|
||||
vec!["DWG: 3 of 10 object records couldn't be read and were left out (LINE × 3); saving over the original file would lose them".into()];
|
||||
assert_eq!(
|
||||
read_note_messages(&d, "a.dwg"),
|
||||
vec!["a.dwg: DWG: 3 of 10 object records couldn't be read and were left out (LINE × 3); saving over the original file would lose them"]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
use super::args::PerfTarget;
|
||||
use super::error::Result;
|
||||
use super::open;
|
||||
use super::output::Streams;
|
||||
use cadcraft_engine::Session;
|
||||
|
||||
/// Why `perf` cannot run.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(crate) enum PerfError {
|
||||
#[error("perf: the drawing is empty")]
|
||||
EmptyDrawing,
|
||||
}
|
||||
|
||||
/// Small deterministic generator (xorshift64*) so perf runs are repeatable without a dependency.
|
||||
struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
fn next(&mut self) -> f64 {
|
||||
self.0 ^= self.0 >> 12;
|
||||
self.0 ^= self.0 << 25;
|
||||
self.0 ^= self.0 >> 27;
|
||||
(self.0.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 11) as f64 / (1u64 << 53) as f64
|
||||
}
|
||||
fn range(&mut self, lo: f64, hi: f64) -> f64 {
|
||||
lo + (hi - lo) * self.next()
|
||||
}
|
||||
}
|
||||
|
||||
/// A synthetic drawing with `n` mixed entities (lines, circles, arcs, polylines, text) spread
|
||||
/// over a square whose side grows with sqrt(n), so density stays constant.
|
||||
fn synthetic(n: usize, side: f64) -> Result<cadcraft_engine::doc::Drawing, cadcraft_engine::doc::DocError> {
|
||||
use cadcraft_engine::doc::{self as d, Common, EntityKind, Space};
|
||||
use cadcraft_engine::geom::{PolyVertex, Vec2, Vec3};
|
||||
let mut dr = d::Drawing::new_imperial();
|
||||
let mut r = Rng(0x9E37_79B9_7F4A_7C15);
|
||||
let v3 = |x: f64, y: f64| Vec3::new(x, y, 0.0);
|
||||
for i in 0..n {
|
||||
let (x, y) = (r.range(0.0, side), r.range(0.0, side));
|
||||
let s = r.range(0.5, 8.0);
|
||||
let kind = match i % 5 {
|
||||
0 => EntityKind::Line(d::Line { a: v3(x, y), b: v3(x + r.range(-s, s), y + r.range(-s, s)) }),
|
||||
1 => EntityKind::Circle(d::Circle { center: v3(x, y), radius: s * 0.5 }),
|
||||
2 => EntityKind::Arc(d::Arc { center: v3(x, y), radius: s * 0.5, start: r.range(0.0, 3.0), end: r.range(3.2, 6.2) }),
|
||||
3 => EntityKind::LwPolyline(d::LwPolyline {
|
||||
vertices: (0..6)
|
||||
.map(|k| PolyVertex {
|
||||
p: Vec2::new(x + f64::from(k) * s * 0.3, y + r.range(-s, s) * 0.3),
|
||||
bulge: if k == 2 { 0.4 } else { 0.0 },
|
||||
start_width: 0.0,
|
||||
end_width: 0.0,
|
||||
})
|
||||
.collect(),
|
||||
closed: i % 2 == 0,
|
||||
const_width: 0.0,
|
||||
elevation: 0.0,
|
||||
plinegen: false,
|
||||
}),
|
||||
_ => EntityKind::Text(d::Text {
|
||||
insert: v3(x, y),
|
||||
align_pt: None,
|
||||
height: s * 0.2,
|
||||
value: format!("T{i}"),
|
||||
rotation: 0.0,
|
||||
width_factor: 1.0,
|
||||
oblique: 0.0,
|
||||
style: "Standard".into(),
|
||||
halign: d::HAlign::Left,
|
||||
valign: d::VAlign::Baseline,
|
||||
}),
|
||||
};
|
||||
dr.add(&Space::Model, Common::default(), kind)?;
|
||||
}
|
||||
Ok(dr)
|
||||
}
|
||||
|
||||
/// `perf FILE`: the display list built as the canvas builds it (a 1600 x 1000 px view at the
|
||||
/// drawing's centre: tolerance, dash level of detail and view window from the zoom band), at
|
||||
/// zoom extents and zoomed in 4x at a time, next to the full-detail build plots use.
|
||||
fn perf_file(path: &std::path::Path, io: &mut Streams) -> Result {
|
||||
use cadcraft_engine::geom::{Bounds2, Vec2};
|
||||
use cadcraft_engine::render::{Options, build};
|
||||
use std::time::Instant;
|
||||
let ms = |t: Instant| t.elapsed().as_secs_f64() * 1000.0;
|
||||
let mut s = Session::empty();
|
||||
let t = Instant::now();
|
||||
open(&mut s, path)?;
|
||||
let open_ms = ms(t);
|
||||
let st = s.state()?;
|
||||
let (d, space) = (&st.doc, &st.space);
|
||||
let ext = d.extents(space);
|
||||
let size = ext.width().max(ext.height());
|
||||
if ext.is_empty() || !(size.is_finite() && size > 0.0) {
|
||||
return Err(PerfError::EmptyDrawing.into());
|
||||
}
|
||||
io.outln(format_args!("open {open_ms:.0} ms, {} entities, extents {:.1} x {:.1}", d.entity_count(), ext.width(), ext.height()));
|
||||
io.outln(format_args!("{:>8} {:>10} {:>10}", "zoom", "build ms", "prims"));
|
||||
let mut finest = Options::default();
|
||||
for k in 0..5 {
|
||||
let zoom = 4f64.powi(k);
|
||||
let h = size / zoom;
|
||||
let pixel = 2f64.powi((h / 1000.0).max(1e-300).log2().floor() as i32);
|
||||
let half = Vec2::new(h * 0.8, h * 0.5);
|
||||
let visible = Bounds2::new(ext.center() - half, ext.center() + half);
|
||||
let screen = Options { tolerance: pixel * 0.5, min_dash: pixel * 2.0, view: Some(visible.expand(h * 0.8)), ..Default::default() };
|
||||
let t = Instant::now();
|
||||
let n = build(d, space, &screen).prims.len();
|
||||
io.outln(format_args!("{:>7}x {:>10.1} {n:>10}", zoom, ms(t)));
|
||||
finest = screen;
|
||||
}
|
||||
// Plots: every pattern line, everywhere (last, so its large list doesn't weigh on the above).
|
||||
let t = Instant::now();
|
||||
let n = build(d, space, &Options { min_dash: 0.0, view: None, ..finest }).prims.len();
|
||||
io.outln(format_args!("{:>8} {:>10.1} {n:>10}", "full", ms(t)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn perf(target: Option<&PerfTarget>, io: &mut Streams) -> Result {
|
||||
use cadcraft_engine::doc::Space;
|
||||
use cadcraft_engine::geom::{Bounds2, Vec2};
|
||||
use cadcraft_engine::{select, snap};
|
||||
use std::time::Instant;
|
||||
let n = match target {
|
||||
Some(PerfTarget::File(path)) => return perf_file(path, io),
|
||||
Some(PerfTarget::Count(n)) => *n,
|
||||
None => 200_000,
|
||||
};
|
||||
let side = (n as f64).sqrt() * 10.0;
|
||||
let mut rows: Vec<(String, f64, String)> = Vec::new();
|
||||
let ms = |t: Instant| t.elapsed().as_secs_f64() * 1000.0;
|
||||
|
||||
let t = Instant::now();
|
||||
let d = synthetic(n, side)?;
|
||||
rows.push(("build synthetic drawing".into(), ms(t), format!("{n} entities")));
|
||||
|
||||
// Display list at a 1600 px wide "zoom extents" view (tolerance = half a pixel).
|
||||
let px = side / 1600.0;
|
||||
let opts = cadcraft_engine::render::Options { tolerance: px * 0.5, min_dash: px * 2.0, ..Default::default() };
|
||||
let t = Instant::now();
|
||||
let list = cadcraft_engine::render::build(&d, &Space::Model, &opts);
|
||||
rows.push(("display list build".into(), ms(t), format!("{} prims, {} segments", list.prims.len(), list.segment_count())));
|
||||
|
||||
// Hit testing on a zoomed-in view (aperture = 5 px at 0.02 units/px).
|
||||
let ap = 0.1;
|
||||
let mut r = Rng(42);
|
||||
let pts: Vec<Vec2> = (0..1000).map(|_| Vec2::new(r.range(0.0, side), r.range(0.0, side))).collect();
|
||||
let t = Instant::now();
|
||||
let first = select::pick(&d, &Space::Model, pts.first().copied().unwrap_or(Vec2::ZERO), ap);
|
||||
rows.push(("first pick (incl. index build)".into(), ms(t), format!("{first:?}")));
|
||||
let t = Instant::now();
|
||||
let hits = pts.iter().filter(|p| select::pick(&d, &Space::Model, **p, ap).is_some()).count();
|
||||
let total = ms(t);
|
||||
rows.push(("pick x1000".into(), total, format!("{hits} hits, {:.3} ms/pick", total / 1000.0)));
|
||||
|
||||
// One edit, then a pick: only the touched chunk is re-indexed.
|
||||
let mut edited = d.clone();
|
||||
if let Some(h) = edited.model.last().map(|e| e.handle) {
|
||||
edited.model.modify(h, |e| e.common.layer = "0".into());
|
||||
}
|
||||
let t = Instant::now();
|
||||
let _ = select::pick(&edited, &Space::Model, pts.first().copied().unwrap_or(Vec2::ZERO), ap);
|
||||
rows.push(("pick after one edit (incremental re-index)".into(), ms(t), String::new()));
|
||||
|
||||
let mut sel = 0usize;
|
||||
let t = Instant::now();
|
||||
for k in 0..20 {
|
||||
let c = Vec2::new(r.range(0.0, side), r.range(0.0, side));
|
||||
let half = side * 0.05;
|
||||
sel += select::select_window(&d, &Space::Model, Bounds2::new(c - Vec2::new(half, half), c + Vec2::new(half, half)), k % 2 == 1).len();
|
||||
}
|
||||
rows.push(("window/crossing select x20 (10% side)".into(), ms(t), format!("{sel} selected")));
|
||||
|
||||
let t = Instant::now();
|
||||
let fence: Vec<Vec2> = (0..4).map(|_| Vec2::new(r.range(0.0, side), r.range(0.0, side))).collect();
|
||||
let fsel = select::select_fence(&d, &Space::Model, &fence).len();
|
||||
rows.push(("fence select (3 legs)".into(), ms(t), format!("{fsel} selected")));
|
||||
|
||||
let osmode = snap::mode::END | snap::mode::MID | snap::mode::CEN | snap::mode::QUA | snap::mode::INT;
|
||||
let t = Instant::now();
|
||||
let snaps = pts.iter().filter(|p| snap::osnap(&d, &Space::Model, **p, ap * 2.0, osmode, None, false).is_some()).count();
|
||||
let total = ms(t);
|
||||
rows.push(("osnap x1000".into(), total, format!("{snaps} hits, {:.3} ms/query", total / 1000.0)));
|
||||
|
||||
let t = Instant::now();
|
||||
let bytes = cadcraft_io::write(&d, "perf.dxf")?;
|
||||
rows.push(("DXF write".into(), ms(t), format!("{:.1} MB", bytes.len() as f64 / 1e6)));
|
||||
let t = Instant::now();
|
||||
let back = cadcraft_io::read(&bytes, "perf.dxf")?;
|
||||
rows.push(("DXF read".into(), ms(t), format!("{} entities", back.entity_count())));
|
||||
|
||||
io.outln(format_args!("{:<40} {:>12} notes", "operation", "ms"));
|
||||
io.outln(format_args!("{}", "-".repeat(80)));
|
||||
for (name, t, note) in rows {
|
||||
io.outln(format_args!("{name:<40} {t:>12.2} {note}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
use super::args::{CmdStep, Drawing, RunArgs, Step};
|
||||
use super::error::Result;
|
||||
use super::open;
|
||||
use super::output::{Streams, export, export_framed, export_window, model_views};
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_engine::doc::Handle;
|
||||
use serde_json::Value;
|
||||
|
||||
/// A `--cmd` parameter value of exactly `{last}` is filled in with the last object handle an
|
||||
/// earlier `--cmd` in the same run returned: `mview` answers `{"viewports":["100"]}`, so
|
||||
/// `viewport.set {"handle":"{last}","scale":1}` changes viewport 100. Any other string is passed
|
||||
/// through unchanged, and `--script` text is never rewritten.
|
||||
const LAST_HANDLE: &str = "{last}";
|
||||
|
||||
/// Why a `run` step fails.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub(crate) enum RunError {
|
||||
#[error("{}: {source}", .path.display())]
|
||||
ReadScript { path: std::path::PathBuf, source: std::io::Error },
|
||||
#[error("--cmd {id}: {source}")]
|
||||
CmdLast { id: String, source: NoLastHandle },
|
||||
}
|
||||
|
||||
/// A `{last}` placeholder with no handle to fill in.
|
||||
#[derive(Debug, PartialEq, thiserror::Error)]
|
||||
#[error("`{{last}}` needs an earlier --cmd in this run to have returned a handle")]
|
||||
pub(crate) struct NoLastHandle;
|
||||
|
||||
/// How deep [`result_handles`] walks a command result (results are the engine's own JSON).
|
||||
const RESULT_DEPTH: usize = 16;
|
||||
|
||||
/// Result keys whose values are object references: the engine's result convention for handles.
|
||||
/// A handle can be a string (`handle`, `viewport`, `insert`…) or a list of them (`handles`,
|
||||
/// `viewports`, `members`…). Every other field may hold a user-defined name, a message or a
|
||||
/// count, and is never read as a handle — a group named `100` is a name, not object `100`.
|
||||
fn handle_field(key: &str) -> bool {
|
||||
matches!(
|
||||
key,
|
||||
"handle"
|
||||
| "handles"
|
||||
| "viewport"
|
||||
| "viewports"
|
||||
| "boundary"
|
||||
| "clip"
|
||||
| "insert"
|
||||
| "members"
|
||||
| "source"
|
||||
| "objects"
|
||||
| "entities"
|
||||
| "selection"
|
||||
| "parts"
|
||||
| "refs"
|
||||
| "matches"
|
||||
| "line"
|
||||
| "arc"
|
||||
| "blocks"
|
||||
)
|
||||
}
|
||||
|
||||
/// Collect the handles of `v`, a value under a handle field: a handle string, an array of them,
|
||||
/// or objects that carry handle fields of their own (`entities`, `parts`).
|
||||
fn collect_result_handles(s: &Session, v: &Value, depth: usize, out: &mut Vec<Handle>) {
|
||||
if depth > RESULT_DEPTH {
|
||||
return;
|
||||
}
|
||||
match v {
|
||||
Value::String(t) => {
|
||||
if let Some(h) = Handle::parse_hex(t)
|
||||
&& s.doc().is_ok_and(|d| d.entity(h).is_some())
|
||||
{
|
||||
out.push(h);
|
||||
}
|
||||
}
|
||||
Value::Array(a) => a.iter().for_each(|x| collect_result_handles(s, x, depth + 1, out)),
|
||||
Value::Object(m) => {
|
||||
for (k, x) in m {
|
||||
if handle_field(k) {
|
||||
collect_result_handles(s, x, depth + 1, out);
|
||||
} else {
|
||||
find_result_handles(s, x, depth + 1, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Find handle fields nested in containers that are not handles themselves (`groups` in
|
||||
/// `groups.list`, whose members are handles): strings here are names, never references.
|
||||
fn find_result_handles(s: &Session, v: &Value, depth: usize, out: &mut Vec<Handle>) {
|
||||
if depth > RESULT_DEPTH {
|
||||
return;
|
||||
}
|
||||
match v {
|
||||
Value::Array(a) => a.iter().for_each(|x| find_result_handles(s, x, depth + 1, out)),
|
||||
Value::Object(m) => {
|
||||
for (k, x) in m {
|
||||
if handle_field(k) {
|
||||
collect_result_handles(s, x, depth + 1, out);
|
||||
} else {
|
||||
find_result_handles(s, x, depth + 1, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The object handles a command result mentions, in the order it lists them. A handle-only result
|
||||
/// (a handle string or an array of them) counts as it is; otherwise only handle fields do.
|
||||
fn result_handles(s: &Session, v: &Value, depth: usize, out: &mut Vec<Handle>) {
|
||||
match v {
|
||||
Value::String(_) => collect_result_handles(s, v, depth, out),
|
||||
Value::Array(a) if a.iter().all(Value::is_string) => collect_result_handles(s, v, depth, out),
|
||||
Value::Array(_) | Value::Object(_) => find_result_handles(s, v, depth, out),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The last handle `r` returned: what a later `{last}` stands for.
|
||||
pub(crate) fn returned_handle(s: &Session, r: &Value) -> Option<Handle> {
|
||||
let mut hs = Vec::new();
|
||||
result_handles(s, r, 0, &mut hs);
|
||||
hs.last().copied()
|
||||
}
|
||||
|
||||
/// The active drawing's identity ([`cadcraft_engine::DocState::uid`]): a handle is only meaningful
|
||||
/// in the drawing it came from, and `new`, `open`, `document.switch` and `close` can make another
|
||||
/// drawing current.
|
||||
fn active_drawing(s: &Session) -> Option<u64> {
|
||||
s.docs.get(s.active).map(|st| st.uid)
|
||||
}
|
||||
|
||||
/// Every `{last}` in `p`, at any depth, replaced by `h`. With no handle to fill in it is an
|
||||
/// error, never a token reaching the command as a (bad) handle.
|
||||
pub(crate) fn fill_last(p: &Value, h: Option<Handle>) -> Result<Value, NoLastHandle> {
|
||||
match p {
|
||||
Value::String(t) if t.as_str() == LAST_HANDLE => match h {
|
||||
Some(h) => Ok(Value::String(h.hex())),
|
||||
None => Err(NoLastHandle),
|
||||
},
|
||||
Value::Array(a) => a.iter().map(|x| fill_last(x, h)).collect::<Result<Vec<_>, _>>().map(Value::Array),
|
||||
Value::Object(m) => {
|
||||
m.iter().map(|(k, x)| Ok((k.clone(), fill_last(x, h)?))).collect::<Result<serde_json::Map<String, Value>, _>>().map(Value::Object)
|
||||
}
|
||||
other => Ok(other.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A step ready to run: script text, or a command.
|
||||
enum Ready<'a> {
|
||||
Script(String),
|
||||
Cmd(&'a CmdStep),
|
||||
}
|
||||
|
||||
pub(crate) fn run_cmds(a: &RunArgs, io: &mut Streams) -> Result {
|
||||
let framing = a.framing.framing();
|
||||
let (save, export_to) = (a.save.as_deref(), a.export.as_deref());
|
||||
let mut s = Session::empty();
|
||||
for d in &a.drawings {
|
||||
match d {
|
||||
Drawing::File(f) => open(&mut s, f)?,
|
||||
Drawing::Sample => {
|
||||
s.open_drawing(cadcraft_engine::sample::default_sample(), "Bracket", None);
|
||||
}
|
||||
Drawing::Metric => {
|
||||
s.new_drawing(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut steps: Vec<Ready> = Vec::new();
|
||||
for step in &a.steps {
|
||||
steps.push(match step {
|
||||
Step::Script(text) => Ready::Script(text.replace("\\n", "\n")),
|
||||
Step::ScriptFile(p) => Ready::Script(std::fs::read_to_string(p).map_err(|source| RunError::ReadScript { path: p.clone(), source })?),
|
||||
Step::Cmd(c) => Ready::Cmd(c),
|
||||
});
|
||||
}
|
||||
if s.docs.is_empty() {
|
||||
s.new_drawing(false);
|
||||
}
|
||||
let before = model_views(&s);
|
||||
let mut last_handle: Option<Handle> = None;
|
||||
for step in steps {
|
||||
// A handle belongs to the drawing that returned it: if this step makes another drawing
|
||||
// current (`new`, `open`, `document.switch`, `close`, or a script running one of them),
|
||||
// the remembered handle is stale. A handle the step itself returns is taken afterwards,
|
||||
// so it is a handle of the drawing it made current.
|
||||
let drawing = active_drawing(&s);
|
||||
match step {
|
||||
Ready::Script(body) => {
|
||||
s.script(&body)?;
|
||||
if active_drawing(&s) != drawing {
|
||||
last_handle = None;
|
||||
}
|
||||
}
|
||||
Ready::Cmd(CmdStep { id, params }) => {
|
||||
let params = fill_last(params, last_handle).map_err(|source| RunError::CmdLast { id: id.clone(), source })?;
|
||||
let r = s.execute(id, ¶ms)?;
|
||||
if active_drawing(&s) != drawing {
|
||||
last_handle = None;
|
||||
}
|
||||
if let Some(h) = returned_handle(&s, &r) {
|
||||
last_handle = Some(h);
|
||||
}
|
||||
if !r.is_null() {
|
||||
io.outln(format_args!("{r}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for l in &s.log {
|
||||
io.errln(format_args!("{l}"));
|
||||
}
|
||||
if let Some(p) = save {
|
||||
export(&s, p, io)?;
|
||||
}
|
||||
if let Some(p) = export_to {
|
||||
let window = if cadcraft_io::is_image(p) { export_window(&s, framing, Some(&before))? } else { None };
|
||||
export_framed(&s, p, window, io)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
use super::args::{SampleArgs, SampleName};
|
||||
use super::error::Result;
|
||||
use super::output::{Streams, report_new_folder, write_atomic};
|
||||
|
||||
pub(crate) fn sample(a: &SampleArgs, io: &mut Streams) -> Result {
|
||||
let d = match a.name {
|
||||
SampleName::Bracket => cadcraft_engine::sample::bracket(),
|
||||
SampleName::Floorplan => cadcraft_engine::sample::floor_plan(),
|
||||
};
|
||||
let bytes = cadcraft_io::write(&d, &a.output)?;
|
||||
let folder = write_atomic(&a.output, &bytes)?;
|
||||
report_new_folder(folder, io);
|
||||
io.errln(format_args!("wrote {} ({} bytes)", a.output.display(), bytes.len()));
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
//! A `--cmd` can act on a handle an earlier `--cmd` in the same run returned (#1663).
|
||||
//!
|
||||
//! `mview` answers `{"viewports":["100"]}`, but a following `viewport.set` could not use that
|
||||
//! handle: it had to be guessed, selected by point, or activated with `mspace`. A `--cmd` parameter
|
||||
//! value of exactly `{last}` is now filled in with the last handle an earlier `--cmd` returned.
|
||||
|
||||
use std::process::{Command, Output};
|
||||
|
||||
fn run(args: &[&str]) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_cadcraft-cli"))
|
||||
.args(args)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.output()
|
||||
.expect("run cadcraft-cli")
|
||||
}
|
||||
|
||||
/// The issue's sequence: `viewport.set` straight after `mview`, addressing the new viewport by the
|
||||
/// handle the runner remembers.
|
||||
#[test]
|
||||
fn viewport_set_after_mview_uses_the_returned_handle() {
|
||||
let out = run(&[
|
||||
"run",
|
||||
"--metric",
|
||||
"--cmd",
|
||||
r#"layout.set {"name":"Layout1"}"#,
|
||||
"--cmd",
|
||||
r#"mview {"p1":[20,20],"p2":[200,150]}"#,
|
||||
"--cmd",
|
||||
r#"viewport.set {"handle":"{last}","scale":1}"#,
|
||||
]);
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(out.status.success(), "exit {:?}\nstdout: {stdout}\nstderr: {stderr}", out.status);
|
||||
let mview = stdout.lines().find(|l| l.contains("\"viewports\"")).unwrap_or_else(|| panic!("no mview result in {stdout:?}"));
|
||||
let created: serde_json::Value = serde_json::from_str(mview).expect("mview result is JSON");
|
||||
let handle = created["viewports"].as_array().and_then(|a| a.first()).and_then(|h| h.as_str()).expect("a viewport handle");
|
||||
assert!(handle.eq_ignore_ascii_case("100"), "{mview}");
|
||||
let set = stdout.lines().find(|l| l.contains("\"viewHeight\"")).unwrap_or_else(|| panic!("no viewport.set result in {stdout:?}"));
|
||||
let vp: serde_json::Value = serde_json::from_str(set).expect("viewport.set result is JSON");
|
||||
assert_eq!(vp[0]["handle"].as_str(), Some(handle), "viewport.set changed the viewport `{handle}`: {set}");
|
||||
assert_eq!(vp[0]["scale"].as_f64(), Some(1.0), "{set}");
|
||||
}
|
||||
|
||||
/// `{last}` with no handle to fill in is a clear error, not the token reaching the command.
|
||||
#[test]
|
||||
fn last_without_an_earlier_handle_is_an_error() {
|
||||
let out = run(&["run", "--metric", "--cmd", r#"viewport.set {"handle":"{last}","scale":1}"#]);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert_eq!(out.status.code(), Some(1), "stdout: {:?}\nstderr: {stderr}", String::from_utf8_lossy(&out.stdout));
|
||||
assert!(stderr.contains("{last}"), "the error names the placeholder: {stderr}");
|
||||
assert!(!stderr.contains("panicked"), "{stderr}");
|
||||
}
|
||||
|
||||
/// The last JSON value a run printed: command results are one JSON value a line (`null` is not
|
||||
/// printed).
|
||||
fn last_json(stdout: &str) -> serde_json::Value {
|
||||
let line = stdout
|
||||
.lines()
|
||||
.rev()
|
||||
.find(|l| {
|
||||
let t = l.trim_start();
|
||||
t.starts_with('{') || t.starts_with('[')
|
||||
})
|
||||
.unwrap_or_else(|| panic!("no JSON result in {stdout:?}"));
|
||||
serde_json::from_str(line).expect("command result is JSON")
|
||||
}
|
||||
|
||||
/// `{last}` was not replaced: the error names the placeholder, and the run fails cleanly.
|
||||
fn assert_needs_an_earlier_handle(out: &Output) {
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert_eq!(out.status.code(), Some(1), "stdout: {:?}\nstderr: {stderr}", String::from_utf8_lossy(&out.stdout));
|
||||
assert!(stderr.contains("{last}"), "the error names the placeholder: {stderr}");
|
||||
assert!(!stderr.contains("panicked"), "{stderr}");
|
||||
}
|
||||
|
||||
/// A user-defined name that happens to look like a handle is not a returned handle. `group`
|
||||
/// answers `{"name":"100","members":1}`: `100` is the first line's handle, but the name is not an
|
||||
/// object reference and must not replace the last handle the `line` command returned (`101`).
|
||||
#[test]
|
||||
fn a_group_name_that_looks_like_a_handle_is_not_last() {
|
||||
let out = run(&[
|
||||
"run",
|
||||
"--metric",
|
||||
"--cmd",
|
||||
r#"line {"points":[[0,0],[10,0]]}"#,
|
||||
"--cmd",
|
||||
r#"line {"points":[[0,10],[10,10]]}"#,
|
||||
"--cmd",
|
||||
r#"group {"name":"100","handles":["101"]}"#,
|
||||
"--cmd",
|
||||
r#"erase {"handles":["{last}"]}"#,
|
||||
"--cmd",
|
||||
r#"entities {"type":"LINE"}"#,
|
||||
]);
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(out.status.success(), "exit {:?}\nstdout: {stdout}\nstderr: {stderr}", out.status);
|
||||
let r = last_json(&stdout);
|
||||
assert_eq!(r["count"].as_u64(), Some(1), "one line is left: {stdout}");
|
||||
let left: Vec<&str> = r["entities"].as_array().into_iter().flatten().filter_map(|e| e["handle"].as_str()).collect();
|
||||
assert_eq!(left, ["100"], "the group name 100 did not replace the handle 101 the line command returned: {stdout}");
|
||||
}
|
||||
|
||||
/// `new` makes another drawing current: the handle an earlier `--cmd` returned in the old drawing
|
||||
/// is not a handle of the new one.
|
||||
#[test]
|
||||
fn last_is_cleared_when_new_switches_drawings() {
|
||||
let out = run(&[
|
||||
"run",
|
||||
"--metric",
|
||||
"--cmd",
|
||||
r#"line {"points":[[0,0],[10,0]]}"#,
|
||||
"--cmd",
|
||||
r#"new {"metric":true}"#,
|
||||
"--cmd",
|
||||
r#"erase {"handles":["{last}"]}"#,
|
||||
]);
|
||||
assert_needs_an_earlier_handle(&out);
|
||||
}
|
||||
|
||||
/// `open` makes the opened file the current drawing: a handle from the drawing before it is stale.
|
||||
#[test]
|
||||
fn last_is_cleared_when_open_switches_drawings() {
|
||||
let dir = std::env::temp_dir().join(format!("cc-cli-last-open-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).expect("temp dir");
|
||||
let dxf = dir.join("opened.dxf");
|
||||
let sample = run(&["sample", "bracket", dxf.to_str().expect("temp path")]);
|
||||
assert!(sample.status.success(), "sample failed: {}", String::from_utf8_lossy(&sample.stderr));
|
||||
let open = format!(r#"open {{"path":{}}}"#, serde_json::to_string(dxf.to_str().expect("temp path")).expect("path JSON"));
|
||||
let out = run(&["run", "--metric", "--cmd", r#"line {"points":[[0,0],[10,0]]}"#, "--cmd", &open, "--cmd", r#"erase {"handles":["{last}"]}"#]);
|
||||
assert_needs_an_earlier_handle(&out);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// `document.switch` changes the current drawing without adding one: a handle remembered from the
|
||||
/// drawing left behind must not be used in the one switched to.
|
||||
#[test]
|
||||
fn last_is_cleared_when_document_switch_changes_drawings() {
|
||||
let out = run(&[
|
||||
"run",
|
||||
"--metric",
|
||||
"--cmd",
|
||||
r#"line {"points":[[0,0],[10,0]]}"#,
|
||||
"--cmd",
|
||||
r#"new {"metric":true}"#,
|
||||
"--cmd",
|
||||
r#"line {"points":[[0,10],[10,10]]}"#,
|
||||
"--cmd",
|
||||
r#"document.switch {"index":0}"#,
|
||||
"--cmd",
|
||||
r#"erase {"handles":["{last}"]}"#,
|
||||
]);
|
||||
assert_needs_an_earlier_handle(&out);
|
||||
}
|
||||
|
||||
/// A handle a command returns after a switch belongs to the drawing now current, so it is kept.
|
||||
#[test]
|
||||
fn a_handle_returned_after_a_switch_is_last() {
|
||||
let out = run(&[
|
||||
"run",
|
||||
"--metric",
|
||||
"--cmd",
|
||||
r#"line {"points":[[0,0],[10,0]]}"#,
|
||||
"--cmd",
|
||||
r#"new {"metric":true}"#,
|
||||
"--cmd",
|
||||
r#"document.switch {"index":0}"#,
|
||||
"--cmd",
|
||||
r#"line {"points":[[0,10],[10,10]]}"#,
|
||||
"--cmd",
|
||||
r#"erase {"handles":["{last}"]}"#,
|
||||
"--cmd",
|
||||
r#"entities {"type":"LINE"}"#,
|
||||
]);
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(out.status.success(), "exit {:?}\nstdout: {stdout}\nstderr: {stderr}", out.status);
|
||||
let r = last_json(&stdout);
|
||||
assert_eq!(r["count"].as_u64(), Some(1), "one line is left: {stdout}");
|
||||
let left: Vec<&str> = r["entities"].as_array().into_iter().flatten().filter_map(|e| e["handle"].as_str()).collect();
|
||||
assert_eq!(left, ["100"], "the line created after the switch was erased: {stdout}");
|
||||
}
|
||||
|
||||
/// A script can change the current drawing too (`new {"metric":true}` at a script line), so the
|
||||
/// remembered handle is cleared after a script that changed it.
|
||||
#[test]
|
||||
fn last_is_cleared_by_a_script_that_changes_drawings() {
|
||||
let out = run(&[
|
||||
"run",
|
||||
"--metric",
|
||||
"--cmd",
|
||||
r#"line {"points":[[0,0],[10,0]]}"#,
|
||||
"--script",
|
||||
r#"new {"metric":true}"#,
|
||||
"--cmd",
|
||||
r#"erase {"handles":["{last}"]}"#,
|
||||
]);
|
||||
assert_needs_an_earlier_handle(&out);
|
||||
}
|
||||
|
||||
/// Only the exact token is a placeholder: ordinary JSON strings are left alone.
|
||||
#[test]
|
||||
fn ordinary_values_are_not_placeholders() {
|
||||
let dir = std::env::temp_dir().join(format!("cc-cli-last-placeholder-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).expect("temp dir");
|
||||
let out_dxf = dir.join("text.dxf");
|
||||
let out = run(&[
|
||||
"run",
|
||||
"--metric",
|
||||
"--cmd",
|
||||
r#"layout.set {"name":"Layout1"}"#,
|
||||
"--cmd",
|
||||
r#"mview {"p1":[20,20],"p2":[200,150]}"#,
|
||||
"--cmd",
|
||||
r#"text {"at":[0,0],"height":2,"text":"last and {last}"}"#,
|
||||
"--save",
|
||||
out_dxf.to_str().expect("temp path"),
|
||||
]);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(out.status.success(), "exit {:?}\nstderr: {stderr}", out.status);
|
||||
let saved = std::fs::read_to_string(&out_dxf).expect("saved DXF");
|
||||
assert!(saved.contains("last and {last}"), "the text is stored literally");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
//! `cadcraft-cli` against a stdout nobody reads any more (#537).
|
||||
|
||||
use std::io::Read;
|
||||
use std::process::{Command, Output, Stdio};
|
||||
|
||||
/// `cadcraft-cli commands | head` panicked with "failed printing to stdout: Broken pipe (os error
|
||||
/// 32)" and exit status 101: `commands` prints a few thousand lines, which no pipe buffer holds,
|
||||
/// so the reader is gone before the last write.
|
||||
#[test]
|
||||
fn closed_stdout_ends_quietly() {
|
||||
for args in [&["commands"][..], &["commands", "ellipse"], &["--version"]] {
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_cadcraft-cli")).args(args).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
let mut first = [0u8; 10];
|
||||
child.stdout.as_mut().unwrap().read_exact(&mut first).unwrap();
|
||||
drop(child.stdout.take());
|
||||
let out = child.wait_with_output().unwrap();
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(out.status.success() && err.is_empty(), "{args:?}: {:?}: {err}", out.status);
|
||||
}
|
||||
}
|
||||
|
||||
/// The CLI keeps working once stdout is gone: `run` still executes its commands and still saves,
|
||||
/// so `cadcraft-cli run … --save out.dxf | head` keeps the drawing.
|
||||
fn run_with_closed_stdout(args: &[&str]) -> Output {
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_cadcraft-cli")).args(args).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
drop(child.stdout.take());
|
||||
child.wait_with_output().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_stdout_still_runs_the_commands_and_saves() {
|
||||
let dir = std::env::temp_dir().join(format!("cc-cli-pipe-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let drawing = dir.join("piped.dxf");
|
||||
let out = run_with_closed_stdout(&[
|
||||
"run",
|
||||
"--metric",
|
||||
"--cmd",
|
||||
"ellipse {\"center\":[0,0],\"major\":[10,0],\"ratio\":0.5}",
|
||||
"--cmd",
|
||||
"drawing.inspect {}",
|
||||
"--save",
|
||||
drawing.to_str().unwrap(),
|
||||
]);
|
||||
assert!(out.status.success(), "{:?}: {}", out.status, String::from_utf8_lossy(&out.stderr));
|
||||
let saved = std::fs::read_to_string(&drawing).unwrap();
|
||||
assert!(saved.contains("ELLIPSE"), "the drawing is saved all the same: {}", &saved[..saved.len().min(200)]);
|
||||
|
||||
// A failing command is still a failure, whether or not anyone reads stdout.
|
||||
let out = run_with_closed_stdout(&["run", "--metric", "--cmd", "no.such.command {}"]);
|
||||
assert_eq!(out.status.code(), Some(1));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
//! Drawing snap/grid spacing survives saving and opening in a fresh CLI process (#607).
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
static NEXT_FILE: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
struct DxfFile(PathBuf);
|
||||
|
||||
impl DxfFile {
|
||||
fn new() -> Self {
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("cadcraft-drafting-spacing-{}-{}.dxf", std::process::id(), NEXT_FILE.fetch_add(1, Ordering::Relaxed)));
|
||||
std::fs::File::options().write(true).create_new(true).open(&path).expect("reserve test DXF");
|
||||
Self(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DxfFile {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_file(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn cli(args: &[&str]) -> Vec<Value> {
|
||||
let out = Command::new(env!("CARGO_BIN_EXE_cadcraft-cli")).args(args).output().expect("run cadcraft-cli");
|
||||
assert!(out.status.success(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
String::from_utf8_lossy(&out.stdout).lines().map(|line| serde_json::from_str(line).expect("CLI JSON reply")).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quarter_unit_spacing_survives_save_and_reopen() {
|
||||
let file = DxfFile::new();
|
||||
let path = file.0.to_str().expect("test file path");
|
||||
cli(&[
|
||||
"run",
|
||||
"--sample",
|
||||
"--cmd",
|
||||
r#"setvar {"name":"SNAPUNIT","value":[0.25,0.25]}"#,
|
||||
"--cmd",
|
||||
r#"setvar {"name":"GRIDUNIT","value":[0.25,0.25]}"#,
|
||||
"--save",
|
||||
path,
|
||||
]);
|
||||
let replies = cli(&["run", path, "--cmd", r#"getvar {"name":"SNAPUNIT"}"#, "--cmd", r#"getvar {"name":"GRIDUNIT"}"#]);
|
||||
for name in ["SNAPUNIT", "GRIDUNIT"] {
|
||||
let reply = replies.iter().find(|r| r["name"] == name).expect("getvar reply");
|
||||
assert_eq!(reply["value"], json!([0.25, 0.25]), "{name} reset on reopen");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drafting_settings_spacing_roundtrips_without_persisting_osmode() {
|
||||
let file = DxfFile::new();
|
||||
let path = file.0.to_str().expect("test file path");
|
||||
cli(&[
|
||||
"run",
|
||||
"--sample",
|
||||
"--cmd",
|
||||
r#"dsettings {"snapunit":[0.25,0.125],"gridunit":[0.75,1.5]}"#,
|
||||
"--cmd",
|
||||
r#"setvar {"name":"OSMODE","value":0}"#,
|
||||
"--save",
|
||||
path,
|
||||
]);
|
||||
let replies = cli(&[
|
||||
"run",
|
||||
path,
|
||||
"--cmd",
|
||||
r#"getvar {"name":"SNAPUNIT"}"#,
|
||||
"--cmd",
|
||||
r#"getvar {"name":"GRIDUNIT"}"#,
|
||||
"--cmd",
|
||||
r#"getvar {"name":"OSMODE"}"#,
|
||||
]);
|
||||
for (name, expected) in [("SNAPUNIT", json!([0.25, 0.125])), ("GRIDUNIT", json!([0.75, 1.5])), ("OSMODE", json!(4133))] {
|
||||
let reply = replies.iter().find(|r| r["name"] == name).expect("getvar reply");
|
||||
assert_eq!(reply["value"], expected, "{name} after reopening");
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
//! `--help` is a request, not a failure (#1525): it goes to stdout with exit 0, like `--version`.
|
||||
//! Bare `cadcraft-cli` is a usage error: the help on stderr, exit 2.
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn help_flags_print_help_to_stdout_and_exit_zero() {
|
||||
for args in [&["--help"][..], &["-h"], &["help"]] {
|
||||
let out = Command::new(env!("CARGO_BIN_EXE_cadcraft-cli")).args(args).output().expect("run cadcraft-cli");
|
||||
assert!(out.status.success(), "{args:?}: {:?}", out.status);
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(stdout.starts_with("cadcraft-cli: headless CADCraft\n"), "{args:?}: {stdout:?}");
|
||||
assert!(String::from_utf8_lossy(&out.stderr).is_empty(), "{args:?}: stderr should be empty");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_invocation_reports_on_stderr() {
|
||||
let out = Command::new(env!("CARGO_BIN_EXE_cadcraft-cli")).output().expect("run cadcraft-cli");
|
||||
assert_eq!(out.status.code(), Some(2));
|
||||
assert!(out.stdout.is_empty(), "bare invocation keeps its help on stderr");
|
||||
assert!(String::from_utf8_lossy(&out.stderr).starts_with("cadcraft-cli: headless CADCraft\n"));
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
mod cli;
|
||||
mod drafting_spacing;
|
||||
mod help;
|
||||
mod mcp_connect;
|
||||
mod skipped_records;
|
||||
mod undrawn_entities;
|
||||
mod usage;
|
||||
@@ -1,44 +0,0 @@
|
||||
//! `info` reports the records a DXF file lost on reading, by section, and `convert` warns about
|
||||
//! them before writing the copy (#865). The exit code stays 0.
|
||||
|
||||
use std::process::{Command, Output};
|
||||
|
||||
fn cli(args: &[&str]) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_cadcraft-cli")).args(args).output().unwrap()
|
||||
}
|
||||
|
||||
/// A LINE after the last ENDSEC and a VERTEX with no POLYLINE before it.
|
||||
const DAMAGED: &str = "0\nSECTION\n2\nENTITIES\n0\nLINE\n8\n0\n10\n0\n20\n0\n11\n5\n21\n5\n0\nVERTEX\n8\n0\n10\n1\n20\n1\n0\nENDSEC\n0\nLINE\n8\n0\n10\n9\n20\n9\n11\n8\n21\n8\n0\nEOF\n";
|
||||
|
||||
#[test]
|
||||
fn info_counts_and_convert_warns_about_skipped_records() {
|
||||
let dir = std::env::temp_dir().join(format!("cc-cli-skipped-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let damaged = dir.join("damaged.dxf");
|
||||
std::fs::write(&damaged, DAMAGED).unwrap();
|
||||
let damaged = damaged.to_str().unwrap();
|
||||
|
||||
let out = cli(&["info", damaged]);
|
||||
assert!(out.status.success(), "{:?}: {}", out.status, String::from_utf8_lossy(&out.stderr));
|
||||
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
assert_eq!(v["skipped"], serde_json::json!({ "count": 2, "sections": { "(no section)": 1, "ENTITIES": 1 } }));
|
||||
|
||||
let copy = dir.join("copy.dxf");
|
||||
let out = cli(&["convert", damaged, copy.to_str().unwrap()]);
|
||||
assert!(out.status.success(), "{:?}: {}", out.status, String::from_utf8_lossy(&out.stderr));
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
let warnings: Vec<&str> = err.lines().filter(|l| l.contains("could not be read")).collect();
|
||||
assert_eq!(warnings.len(), 1, "{err}");
|
||||
assert!(warnings[0].contains("2 records") && warnings[0].contains("ENTITIES: 1") && warnings[0].contains("(no section): 1"), "{err}");
|
||||
|
||||
// An intact file: a zero count, and no warning.
|
||||
let sample = dir.join("sample.dxf");
|
||||
assert!(cli(&["sample", "floorplan", sample.to_str().unwrap()]).status.success());
|
||||
let out = cli(&["info", sample.to_str().unwrap()]);
|
||||
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
assert_eq!(v["skipped"], serde_json::json!({ "count": 0, "sections": {} }));
|
||||
let out = cli(&["convert", sample.to_str().unwrap(), copy.to_str().unwrap()]);
|
||||
assert!(out.status.success());
|
||||
assert!(!String::from_utf8_lossy(&out.stderr).contains("could not be read"), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
//! `convert` to an image says how many model-space entities it can't draw, by type, instead of
|
||||
//! leaving them out silently (#1208). The exit code stays 0.
|
||||
|
||||
use std::process::{Command, Output};
|
||||
|
||||
fn cli(args: &[&str]) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_cadcraft-cli")).args(args).output().unwrap()
|
||||
}
|
||||
|
||||
/// A line, two foreign REGIONs and a PDF underlay: the last three are kept as unknown entities.
|
||||
const PROXIES: &str = "0\nSECTION\n2\nENTITIES\n\
|
||||
0\nLINE\n8\n0\n10\n0\n20\n0\n11\n5\n21\n5\n\
|
||||
0\nREGION\n8\n0\n70\n1\n1\nabc\n\
|
||||
0\nREGION\n8\n0\n70\n1\n1\ndef\n\
|
||||
0\nPDFUNDERLAY\n8\n0\n10\n0\n20\n0\n41\n1\n42\n1\n43\n1\n\
|
||||
0\nENDSEC\n0\nEOF\n";
|
||||
|
||||
#[test]
|
||||
fn convert_to_an_image_warns_about_entities_it_cannot_draw() {
|
||||
let dir = std::env::temp_dir().join(format!("cc-cli-undrawn-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let input = dir.join("proxies.dxf");
|
||||
std::fs::write(&input, PROXIES).unwrap();
|
||||
let input = input.to_str().unwrap();
|
||||
|
||||
let out = cli(&["info", input]);
|
||||
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
assert_eq!(v["counts"]["Proxy"], 3, "{v}");
|
||||
|
||||
for ext in ["svg", "png", "pdf"] {
|
||||
let image = dir.join(format!("out.{ext}"));
|
||||
let out = cli(&["convert", input, image.to_str().unwrap()]);
|
||||
assert!(out.status.success(), "{:?}: {}", out.status, String::from_utf8_lossy(&out.stderr));
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
let warnings: Vec<&str> = err.lines().filter(|l| l.contains("not drawn")).collect();
|
||||
assert_eq!(warnings.len(), 1, "{ext}: {err}");
|
||||
assert_eq!(warnings[0], format!("{input}: 3 entities not drawn (PDFUNDERLAY: 1, REGION: 2)"), "{ext}");
|
||||
}
|
||||
|
||||
// A DXF copy keeps them, so there is nothing to warn about.
|
||||
let copy = dir.join("copy.dxf");
|
||||
let out = cli(&["convert", input, copy.to_str().unwrap()]);
|
||||
assert!(out.status.success());
|
||||
assert!(!String::from_utf8_lossy(&out.stderr).contains("not drawn"), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
// A drawing without them: no warning.
|
||||
let sample = dir.join("sample.dxf");
|
||||
assert!(cli(&["sample", "floorplan", sample.to_str().unwrap()]).status.success());
|
||||
let out = cli(&["convert", sample.to_str().unwrap(), dir.join("sample.svg").to_str().unwrap()]);
|
||||
assert!(out.status.success());
|
||||
assert!(!String::from_utf8_lossy(&out.stderr).contains("not drawn"), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -1,950 +0,0 @@
|
||||
//! The command line's behaviour: exit codes, streams and messages for help requests, unknown and
|
||||
//! malformed options, value flags in both spellings, repeats and arity. Every case goes through
|
||||
//! `invoke`, so the table rows are the record of what each input does. Usage errors are clap's
|
||||
//! (exit 2, stderr); a subcommand's own failures are `error: …` with exit 1.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
struct Invoke {
|
||||
code: i32,
|
||||
stdout: String,
|
||||
stderr: String,
|
||||
}
|
||||
|
||||
fn invoke(args: &[&str]) -> Invoke {
|
||||
let (mut out, mut err) = (Vec::new(), Vec::new());
|
||||
let code = cadcraft_cli::run(args, &mut out, &mut err);
|
||||
Invoke { code, stdout: String::from_utf8_lossy(&out).into_owned(), stderr: String::from_utf8_lossy(&err).into_owned() }
|
||||
}
|
||||
|
||||
/// What a stream holds.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Expect {
|
||||
Empty,
|
||||
/// The first line equals this.
|
||||
Line(&'static str),
|
||||
/// The first line starts with this.
|
||||
Starts(&'static str),
|
||||
}
|
||||
|
||||
use Expect::{Empty, Line, Starts};
|
||||
|
||||
struct Case {
|
||||
name: &'static str,
|
||||
args: &'static [&'static str],
|
||||
code: i32,
|
||||
stdout: Expect,
|
||||
stderr: Expect,
|
||||
}
|
||||
|
||||
const fn case(name: &'static str, args: &'static [&'static str], code: i32, stdout: Expect, stderr: Expect) -> Case {
|
||||
Case { name, args, code, stdout, stderr }
|
||||
}
|
||||
|
||||
/// A scratch directory holding `b.dxf` (the bracket sample) and `s.scr` (a one-line script).
|
||||
struct Scratch(PathBuf);
|
||||
|
||||
impl Scratch {
|
||||
fn new(tag: &str) -> Scratch {
|
||||
let dir = std::env::temp_dir().join(format!("cc-cli-usage-{tag}-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).expect("create scratch dir");
|
||||
let b = dir.join("b.dxf");
|
||||
let r = invoke(&["sample", "bracket", b.to_str().expect("utf-8 path")]);
|
||||
assert_eq!(r.code, 0, "{}", r.stderr);
|
||||
std::fs::write(dir.join("s.scr"), "LINE 0,0 1,1\n").expect("write script");
|
||||
Scratch(dir)
|
||||
}
|
||||
|
||||
/// Replaces `{dir}` with the scratch directory and `{b}` with the sample drawing.
|
||||
fn expand(&self, text: &str) -> String {
|
||||
let dir = self.0.to_str().expect("utf-8 path");
|
||||
text.replace("{b}", &Path::new(dir).join("b.dxf").to_string_lossy()).replace("{dir}", dir)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn check(what: &str, stream: &str, got: &str, expect: Expect, scratch: &Scratch) {
|
||||
let first = got.lines().next().unwrap_or("");
|
||||
match expect {
|
||||
Empty => assert!(got.is_empty(), "{what}: {stream} should be empty, got {got:?}"),
|
||||
Line(l) => assert_eq!(first, scratch.expand(l), "{what}: {stream}"),
|
||||
Starts(p) => assert!(first.starts_with(&scratch.expand(p)), "{what}: {stream} should start with {p:?}, got {first:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_cases(tag: &str, cases: &[Case]) {
|
||||
let scratch = Scratch::new(tag);
|
||||
for c in cases {
|
||||
let args: Vec<String> = c.args.iter().map(|a| scratch.expand(a)).collect();
|
||||
let args: Vec<&str> = args.iter().map(String::as_str).collect();
|
||||
let what = format!("{}: {args:?}", c.name);
|
||||
let r = invoke(&args);
|
||||
assert_eq!(r.code, c.code, "{what}: exit code (stderr {:?})", r.stderr);
|
||||
check(&what, "stdout", &r.stdout, c.stdout, &scratch);
|
||||
check(&what, "stderr", &r.stderr, c.stderr, &scratch);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn top_level_requests() {
|
||||
run_cases(
|
||||
"top",
|
||||
&[
|
||||
case("--help", &["--help"], 0, Line("cadcraft-cli: headless CADCraft"), Empty),
|
||||
case("-h", &["-h"], 0, Line("cadcraft-cli: headless CADCraft"), Empty),
|
||||
case("help", &["help"], 0, Line("cadcraft-cli: headless CADCraft"), Empty),
|
||||
case("--version", &["--version"], 0, Starts("cadcraft-cli "), Empty),
|
||||
case("-V", &["-V"], 0, Starts("cadcraft-cli "), Empty),
|
||||
case("version is not a subcommand", &["version"], 2, Empty, Line("error: unrecognized subcommand 'version'")),
|
||||
case("bare", &[], 2, Empty, Line("cadcraft-cli: headless CADCraft")),
|
||||
case("unknown subcommand", &["foo"], 2, Empty, Line("error: unrecognized subcommand 'foo'")),
|
||||
case("unknown top-level flag", &["--bogus"], 2, Empty, Line("error: unexpected argument '--bogus' found")),
|
||||
case("empty subcommand", &[""], 2, Empty, Line("error: unrecognized subcommand ''")),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Each subcommand's help, from `--help`, `-h` or `help <subcommand>`: its first line is the
|
||||
/// subcommand's summary.
|
||||
#[test]
|
||||
fn subcommand_help_requests() {
|
||||
run_cases(
|
||||
"subhelp",
|
||||
&[
|
||||
case("info --help", &["info", "--help"], 0, Line("Print a drawing's summary as JSON"), Empty),
|
||||
case("info -h", &["info", "-h"], 0, Line("Print a drawing's summary as JSON"), Empty),
|
||||
case(
|
||||
"convert --help",
|
||||
&["convert", "--help"],
|
||||
0,
|
||||
Line("Convert a drawing; OUT's extension picks the format (dxf, dwg, dwt, svg, png, pdf)"),
|
||||
Empty,
|
||||
),
|
||||
case(
|
||||
"convert -h",
|
||||
&["convert", "-h"],
|
||||
0,
|
||||
Line("Convert a drawing; OUT's extension picks the format (dxf, dwg, dwt, svg, png, pdf)"),
|
||||
Empty,
|
||||
),
|
||||
case(
|
||||
"run --help",
|
||||
&["run", "--help"],
|
||||
0,
|
||||
Line("Open drawings (or start a new one), run scripts and commands in order, save or export the result"),
|
||||
Empty,
|
||||
),
|
||||
case(
|
||||
"run -h",
|
||||
&["run", "-h"],
|
||||
0,
|
||||
Line("Open drawings (or start a new one), run scripts and commands in order, save or export the result"),
|
||||
Empty,
|
||||
),
|
||||
case("commands --help", &["commands", "--help"], 0, Line("Print the command catalog as JSON"), Empty),
|
||||
case(
|
||||
"mcp --help",
|
||||
&["mcp", "--help"],
|
||||
0,
|
||||
Line("Run the MCP server on stdio: a headless drawing session, or a bridge to the running app"),
|
||||
Empty,
|
||||
),
|
||||
case(
|
||||
"perf --help",
|
||||
&["perf", "--help"],
|
||||
0,
|
||||
Line(
|
||||
"Time the engine on a synthetic N-entity drawing (default 200000), or the canvas display-list build of a drawing, zoomed in step by step",
|
||||
),
|
||||
Empty,
|
||||
),
|
||||
case("sample --help", &["sample", "--help"], 0, Line("Write a built-in sample drawing"), Empty),
|
||||
case(
|
||||
"help run",
|
||||
&["help", "run"],
|
||||
0,
|
||||
Line("Open drawings (or start a new one), run scripts and commands in order, save or export the result"),
|
||||
Empty,
|
||||
),
|
||||
case("help info", &["help", "info"], 0, Line("Print a drawing's summary as JSON"), Empty),
|
||||
case(
|
||||
"help after a flag missing its value",
|
||||
&["run", "--sample", "--script", "--help"],
|
||||
0,
|
||||
Line("Open drawings (or start a new one), run scripts and commands in order, save or export the result"),
|
||||
Empty,
|
||||
),
|
||||
case(
|
||||
"help after an unknown flag",
|
||||
&["run", "--bogus", "--help"],
|
||||
0,
|
||||
Line("Open drawings (or start a new one), run scripts and commands in order, save or export the result"),
|
||||
Empty,
|
||||
),
|
||||
case("help after an extra argument", &["info", "{b}", "extra", "-h"], 0, Line("Print a drawing's summary as JSON"), Empty),
|
||||
case("--help after -- is a file name", &["run", "--", "--help"], 1, Empty, Starts("error: invalid parameters for `open`: --help:")),
|
||||
case("run --version", &["run", "--version"], 2, Empty, Line("error: unexpected argument '--version' found")),
|
||||
case("info --version", &["info", "--version"], 2, Empty, Line("error: unexpected argument '--version' found")),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_flags_on_every_subcommand() {
|
||||
run_cases(
|
||||
"unknown",
|
||||
&[
|
||||
case("info", &["info", "{b}", "--bogus"], 2, Empty, Line("error: unexpected argument '--bogus' found")),
|
||||
case("info, flag first", &["info", "--bogus", "{b}"], 2, Empty, Line("error: unexpected argument '--bogus' found")),
|
||||
case("info, short flag", &["info", "-x"], 2, Empty, Line("error: unexpected argument '-x' found")),
|
||||
case("info, a file name starting with - after --", &["info", "--", "-x"], 1, Empty, Starts("error: invalid parameters for `open`: -x:")),
|
||||
case("convert", &["convert", "{b}", "{dir}/o.svg", "--bogus"], 2, Empty, Line("error: unexpected argument '--bogus' found")),
|
||||
case("run", &["run", "--sample", "--bogus"], 2, Empty, Line("error: unexpected argument '--bogus' found")),
|
||||
case("run, flag first", &["run", "--bogus"], 2, Empty, Line("error: unexpected argument '--bogus' found")),
|
||||
case("commands", &["commands", "--bogus"], 2, Empty, Line("error: unexpected argument '--bogus' found")),
|
||||
case("commands -- ends the options", &["commands", "--"], 0, Line("["), Empty),
|
||||
case("mcp", &["mcp", "--bogus"], 2, Empty, Line("error: unexpected argument '--bogus' found")),
|
||||
case("mcp positional", &["mcp", "extra"], 2, Empty, Line("error: unexpected argument 'extra' found")),
|
||||
case("perf", &["perf", "--bogus"], 2, Empty, Line("error: unexpected argument '--bogus' found")),
|
||||
case("sample", &["sample", "--bogus"], 2, Empty, Line("error: unexpected argument '--bogus' found")),
|
||||
case("sample after name", &["sample", "bracket", "--bogus"], 2, Empty, Line("error: unexpected argument '--bogus' found")),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// `--x=v` is a spelling of `--x v`, and the one a value starting with `-` needs.
|
||||
#[test]
|
||||
fn value_flags_in_both_forms() {
|
||||
run_cases(
|
||||
"forms",
|
||||
&[
|
||||
case("--window v", &["convert", "{b}", "{dir}/w1.svg", "--window", "0,0,100,100"], 0, Empty, Starts("wrote {dir}/w1.svg (")),
|
||||
case("--window=v", &["convert", "{b}", "{dir}/w2.svg", "--window=0,0,100,100"], 0, Empty, Starts("wrote {dir}/w2.svg (")),
|
||||
case(
|
||||
"run --window v",
|
||||
&["run", "--sample", "--window", "0,0,100,100", "--export", "{dir}/w3.svg"],
|
||||
0,
|
||||
Empty,
|
||||
Starts("wrote {dir}/w3.svg ("),
|
||||
),
|
||||
case(
|
||||
"run --window=v",
|
||||
&["run", "--sample", "--window=0,0,100,100", "--export", "{dir}/w4.svg"],
|
||||
0,
|
||||
Empty,
|
||||
Starts("wrote {dir}/w4.svg ("),
|
||||
),
|
||||
case("--view v", &["convert", "{b}", "{dir}/v1.svg", "--view", "extents"], 0, Empty, Starts("wrote {dir}/v1.svg (")),
|
||||
case("--view=v", &["convert", "{b}", "{dir}/v2.svg", "--view=extents"], 0, Empty, Starts("wrote {dir}/v2.svg (")),
|
||||
case("run --view v", &["run", "--sample", "--view", "current", "--export", "{dir}/v3.svg"], 0, Empty, Starts("wrote {dir}/v3.svg (")),
|
||||
case("run --view=v", &["run", "--sample", "--view=extents", "--export={dir}/v4.svg"], 0, Empty, Starts("wrote {dir}/v4.svg (")),
|
||||
case("--script v", &["run", "--sample", "--script", "LINE 0,0 1,1"], 0, Empty, Line("Command: LINE")),
|
||||
case("--script=v", &["run", "--sample", "--script=LINE 0,0 1,1"], 0, Empty, Line("Command: LINE")),
|
||||
case("--script-file v", &["run", "--sample", "--script-file", "{dir}/s.scr"], 0, Empty, Starts("Command: LINE")),
|
||||
case("--script-file=v", &["run", "--sample", "--script-file={dir}/s.scr"], 0, Empty, Starts("Command: LINE")),
|
||||
case("--cmd v", &["run", "--sample", "--cmd", "nosuch"], 1, Empty, Line("error: unknown command `nosuch`")),
|
||||
case("--cmd=v", &["run", "--sample", "--cmd=nosuch"], 1, Empty, Line("error: unknown command `nosuch`")),
|
||||
case("--cmd=-v", &["run", "--sample", "--cmd=-x"], 1, Empty, Line("error: unknown command `-x`")),
|
||||
case("--save v", &["run", "--sample", "--save", "{dir}/s1.dxf"], 0, Empty, Starts("wrote {dir}/s1.dxf (")),
|
||||
case("--save=v", &["run", "--sample", "--save={dir}/s2.dxf"], 0, Empty, Starts("wrote {dir}/s2.dxf (")),
|
||||
case("--export v", &["run", "--sample", "--export", "{dir}/e1.svg"], 0, Empty, Starts("wrote {dir}/e1.svg (")),
|
||||
case("--export=v", &["run", "--sample", "--export={dir}/e2.svg"], 0, Empty, Starts("wrote {dir}/e2.svg (")),
|
||||
case(
|
||||
"--window=-v: negative coordinates need =",
|
||||
&["convert", "{b}", "{dir}/w5.svg", "--window=-1000,-1000,1000,1000"],
|
||||
0,
|
||||
Empty,
|
||||
Starts("wrote {dir}/w5.svg ("),
|
||||
),
|
||||
case(
|
||||
"--window -v: a value starting with - is a flag",
|
||||
&["convert", "{b}", "{dir}/w6.svg", "--window", "-1000,-1000,1000,1000"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: unexpected argument '-1' found"),
|
||||
),
|
||||
case("--connect v", &["mcp", "--connect", "127.0.0.1:1"], 1, Empty, Starts("error: cannot connect to 127.0.0.1:1:")),
|
||||
case("--connect=v", &["mcp", "--connect=127.0.0.1:1"], 1, Empty, Starts("error: cannot connect to 127.0.0.1:1:")),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// A value flag at the end of the arguments, or followed by another flag, is missing its value:
|
||||
/// a flag is never taken as a value.
|
||||
#[test]
|
||||
fn flags_missing_their_value() {
|
||||
run_cases(
|
||||
"missing",
|
||||
&[
|
||||
case(
|
||||
"convert --window last",
|
||||
&["convert", "{b}", "{dir}/o.svg", "--window"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: a value is required for '--window <x0,y0,x1,y1>' but none was supplied"),
|
||||
),
|
||||
case(
|
||||
"convert --view last",
|
||||
&["convert", "{b}", "{dir}/o.svg", "--view"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: a value is required for '--view <extents|current>' but none was supplied"),
|
||||
),
|
||||
case(
|
||||
"convert --window then flag",
|
||||
&["convert", "{b}", "{dir}/o.svg", "--window", "--view", "extents"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: a value is required for '--window <x0,y0,x1,y1>' but none was supplied"),
|
||||
),
|
||||
case(
|
||||
"convert --view then flag",
|
||||
&["convert", "{b}", "{dir}/o.svg", "--view", "--window", "0,0,1,1"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: a value is required for '--view <extents|current>' but none was supplied"),
|
||||
),
|
||||
case(
|
||||
"run --window last",
|
||||
&["run", "--sample", "--window"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: a value is required for '--window <x0,y0,x1,y1>' but none was supplied"),
|
||||
),
|
||||
case(
|
||||
"run --view last",
|
||||
&["run", "--sample", "--view"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: a value is required for '--view <extents|current>' but none was supplied"),
|
||||
),
|
||||
case(
|
||||
"run --script last",
|
||||
&["run", "--sample", "--script"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: a value is required for '--script <TEXT>' but none was supplied"),
|
||||
),
|
||||
case(
|
||||
"run --script then flag",
|
||||
&["run", "--sample", "--script", "--sample"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: a value is required for '--script <TEXT>' but none was supplied"),
|
||||
),
|
||||
case(
|
||||
"run --script-file last",
|
||||
&["run", "--script-file"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: a value is required for '--script-file <F.scr>' but none was supplied"),
|
||||
),
|
||||
case(
|
||||
"run --script-file then flag",
|
||||
&["run", "--sample", "--script-file", "--sample"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: a value is required for '--script-file <F.scr>' but none was supplied"),
|
||||
),
|
||||
case(
|
||||
"run --cmd last",
|
||||
&["run", "--sample", "--cmd"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: a value is required for '--cmd <id {json}>' but none was supplied"),
|
||||
),
|
||||
case(
|
||||
"run --cmd then flag",
|
||||
&["run", "--sample", "--cmd", "--sample"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: a value is required for '--cmd <id {json}>' but none was supplied"),
|
||||
),
|
||||
case(
|
||||
"run --save last",
|
||||
&["run", "--sample", "--save"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: a value is required for '--save <OUT.dxf>' but none was supplied"),
|
||||
),
|
||||
case(
|
||||
"run --export last",
|
||||
&["run", "--sample", "--export"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: a value is required for '--export <OUT.png>' but none was supplied"),
|
||||
),
|
||||
case(
|
||||
"mcp --connect last",
|
||||
&["mcp", "--connect"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: a value is required for '--connect <HOST:PORT>' but none was supplied"),
|
||||
),
|
||||
case(
|
||||
"mcp --connect then flag",
|
||||
&["mcp", "--connect", "--lazy"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: a value is required for '--connect <HOST:PORT>' but none was supplied"),
|
||||
),
|
||||
case(
|
||||
"mcp --connect empty",
|
||||
&["mcp", "--connect", " "],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: invalid value ' ' for '--connect <HOST:PORT>': expected HOST:PORT (the app's --control port, e.g. 127.0.0.1:7979)"),
|
||||
),
|
||||
case(
|
||||
"mcp --connect without a port",
|
||||
&["mcp", "--connect", "example.com"],
|
||||
2,
|
||||
Empty,
|
||||
Line(
|
||||
"error: invalid value 'example.com' for '--connect <HOST:PORT>': expected HOST:PORT (the app's --control port, e.g. 127.0.0.1:7979)",
|
||||
),
|
||||
),
|
||||
case(
|
||||
"mcp --connect port out of range",
|
||||
&["mcp", "--connect", "host:99999"],
|
||||
2,
|
||||
Empty,
|
||||
Line(
|
||||
"error: invalid value 'host:99999' for '--connect <HOST:PORT>': expected HOST:PORT (the app's --control port, e.g. 127.0.0.1:7979)",
|
||||
),
|
||||
),
|
||||
case(
|
||||
"mcp --connect=-v",
|
||||
&["mcp", "--connect=-v"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: invalid value '-v' for '--connect <HOST:PORT>': expected HOST:PORT (the app's --control port, e.g. 127.0.0.1:7979)"),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_and_combined_flags() {
|
||||
run_cases(
|
||||
"repeat",
|
||||
&[
|
||||
case(
|
||||
"--save twice",
|
||||
&["run", "--sample", "--save", "{dir}/a.dxf", "--save", "{dir}/b2.dxf"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: the argument '--save <OUT.dxf>' cannot be used multiple times"),
|
||||
),
|
||||
case(
|
||||
"--export twice",
|
||||
&["run", "--sample", "--export", "{dir}/x1.svg", "--export", "{dir}/x2.svg"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: the argument '--export <OUT.png>' cannot be used multiple times"),
|
||||
),
|
||||
case(
|
||||
"--window twice",
|
||||
&["run", "--sample", "--window", "0,0,1,1", "--window", "0,0,2,2", "--export", "{dir}/ww.svg"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: the argument '--window <x0,y0,x1,y1>' cannot be used multiple times"),
|
||||
),
|
||||
case(
|
||||
"convert --window twice",
|
||||
&["convert", "{b}", "{dir}/cw.svg", "--window", "0,0,1,1", "--window", "0,0,2,2"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: the argument '--window <x0,y0,x1,y1>' cannot be used multiple times"),
|
||||
),
|
||||
case(
|
||||
"--view twice, different",
|
||||
&["run", "--sample", "--view", "extents", "--view", "current", "--export", "{dir}/vv.svg"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: the argument '--view <extents|current>' cannot be used multiple times"),
|
||||
),
|
||||
case(
|
||||
"--view twice, same",
|
||||
&["run", "--sample", "--view", "extents", "--view", "extents", "--export", "{dir}/vs.svg"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: the argument '--view <extents|current>' cannot be used multiple times"),
|
||||
),
|
||||
case("--sample twice", &["run", "--sample", "--sample"], 2, Empty, Line("error: the argument '--sample' cannot be used multiple times")),
|
||||
case("--metric twice", &["run", "--metric", "--metric"], 2, Empty, Line("error: the argument '--metric' cannot be used multiple times")),
|
||||
case(
|
||||
"--lazy twice",
|
||||
&["mcp", "--connect", "127.0.0.1:1", "--lazy", "--lazy"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: the argument '--lazy' cannot be used multiple times"),
|
||||
),
|
||||
case(
|
||||
"--launch twice",
|
||||
&["mcp", "--connect", "127.0.0.1:1", "--launch", "--launch"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: the argument '--launch' cannot be used multiple times"),
|
||||
),
|
||||
case(
|
||||
"--script, --script-file and --cmd repeat",
|
||||
&[
|
||||
"run",
|
||||
"--sample",
|
||||
"--script",
|
||||
"LINE 0,0 1,1",
|
||||
"--script",
|
||||
"LINE 0,0 2,2",
|
||||
"--script-file",
|
||||
"{dir}/s.scr",
|
||||
"--script-file",
|
||||
"{dir}/s.scr",
|
||||
],
|
||||
0,
|
||||
Empty,
|
||||
Starts("Command: LINE"),
|
||||
),
|
||||
case(
|
||||
"--window and --view",
|
||||
&["run", "--sample", "--window", "0,0,1,1", "--view", "extents", "--export", "{dir}/wv.svg"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: the argument '--window <x0,y0,x1,y1>' cannot be used with '--view <extents|current>'"),
|
||||
),
|
||||
case(
|
||||
"convert --window and --view",
|
||||
&["convert", "{b}", "{dir}/cwv.svg", "--window", "0,0,1,1", "--view", "extents"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: the argument '--window <x0,y0,x1,y1>' cannot be used with '--view <extents|current>'"),
|
||||
),
|
||||
case(
|
||||
"--view is case-insensitive",
|
||||
&["run", "--sample", "--view", "EXTENTS", "--export", "{dir}/vu.svg"],
|
||||
0,
|
||||
Empty,
|
||||
Starts("wrote {dir}/vu.svg ("),
|
||||
),
|
||||
case(
|
||||
"--connect twice",
|
||||
&["mcp", "--connect", "127.0.0.1:1", "--connect", "127.0.0.1:2"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: the argument '--connect <HOST:PORT>' cannot be used multiple times"),
|
||||
),
|
||||
case("--lazy without --connect", &["mcp", "--lazy"], 2, Empty, Line("error: the following required arguments were not provided:")),
|
||||
case("--launch without --connect", &["mcp", "--launch"], 2, Empty, Line("error: the following required arguments were not provided:")),
|
||||
case("--sample --metric: both accepted", &["run", "--sample", "--metric"], 0, Empty, Empty),
|
||||
case("--sample and a file", &["run", "--sample", "{b}"], 0, Empty, Empty),
|
||||
case("two files", &["run", "{b}", "{b}"], 0, Empty, Empty),
|
||||
case(
|
||||
"--window without export",
|
||||
&["run", "--sample", "--window", "0,0,1,1"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: the following required arguments were not provided:"),
|
||||
),
|
||||
case(
|
||||
"--view without export",
|
||||
&["run", "--sample", "--view", "current"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: the following required arguments were not provided:"),
|
||||
),
|
||||
case(
|
||||
"--window with a dxf output",
|
||||
&["convert", "{b}", "{dir}/o.dxf", "--window", "0,0,1,1"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: --window only applies to image output (png, svg, pdf), not `{dir}/o.dxf`"),
|
||||
),
|
||||
case(
|
||||
"--view with a dxf output",
|
||||
&["convert", "{b}", "{dir}/o.dxf", "--view", "extents"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: --view only applies to image output (png, svg, pdf), not `{dir}/o.dxf`"),
|
||||
),
|
||||
case(
|
||||
"run --window with a dxf export",
|
||||
&["run", "--sample", "--window", "0,0,1,1", "--export", "{dir}/o.dxf"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: --window only applies to image output (png, svg, pdf), not `{dir}/o.dxf`"),
|
||||
),
|
||||
case(
|
||||
"run --view with a dxf export",
|
||||
&["run", "--sample", "--view", "current", "--export", "{dir}/o.dxf"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: --view only applies to image output (png, svg, pdf), not `{dir}/o.dxf`"),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arity_and_positional_arguments() {
|
||||
run_cases(
|
||||
"arity",
|
||||
&[
|
||||
case("info without a file", &["info"], 2, Empty, Line("error: the following required arguments were not provided:")),
|
||||
case("info with two files", &["info", "{b}", "extra"], 2, Empty, Line("error: unexpected argument 'extra' found")),
|
||||
case("info missing file", &["info", "missing.dxf"], 1, Empty, Starts("error: invalid parameters for `open`: missing.dxf:")),
|
||||
case("convert with no files", &["convert"], 2, Empty, Line("error: the following required arguments were not provided:")),
|
||||
case("convert with one file", &["convert", "{b}"], 2, Empty, Line("error: the following required arguments were not provided:")),
|
||||
case(
|
||||
"convert with three files",
|
||||
&["convert", "{b}", "{dir}/o.svg", "{dir}/x.svg"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: unexpected argument '{dir}/x.svg' found"),
|
||||
),
|
||||
case("run missing file", &["run", "missing.dxf"], 1, Empty, Starts("error: invalid parameters for `open`: missing.dxf:")),
|
||||
case("commands, no match", &["commands", "zzzznomatch"], 0, Line("[]"), Empty),
|
||||
case(
|
||||
"commands with an extra argument",
|
||||
&["commands", "zzzznomatch", "extra"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: unexpected argument 'extra' found"),
|
||||
),
|
||||
case("sample without arguments", &["sample"], 2, Empty, Line("error: the following required arguments were not provided:")),
|
||||
case("sample without an output", &["sample", "bracket"], 2, Empty, Line("error: the following required arguments were not provided:")),
|
||||
case(
|
||||
"sample with a bad name",
|
||||
&["sample", "nosuch", "{dir}/o.dxf"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: invalid value 'nosuch' for '<bracket|floorplan>'"),
|
||||
),
|
||||
case(
|
||||
"sample names are case-sensitive",
|
||||
&["sample", "Bracket", "{dir}/o.dxf"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: invalid value 'Bracket' for '<bracket|floorplan>'"),
|
||||
),
|
||||
case(
|
||||
"sample with an empty output",
|
||||
&["sample", "bracket", ""],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: a value is required for '<OUT>' but none was supplied"),
|
||||
),
|
||||
case("info with an empty file name", &["info", ""], 2, Empty, Line("error: a value is required for '<FILE>' but none was supplied")),
|
||||
case("perf with an empty file name", &["perf", ""], 2, Empty, Line("error: a value is required for '[N|FILE]' but none was supplied")),
|
||||
case(
|
||||
"sample with an extra argument",
|
||||
&["sample", "bracket", "{dir}/sx.dxf", "extra"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: unexpected argument 'extra' found"),
|
||||
),
|
||||
case("sample floor alias", &["sample", "floor", "{dir}/sf.dxf"], 0, Empty, Starts("wrote {dir}/sf.dxf (")),
|
||||
case("sample with a bad extension", &["sample", "bracket", "{dir}/o.xyz"], 1, Empty, Line("error: unsupported file type `xyz`")),
|
||||
case("perf with a word", &["perf", "abc"], 1, Empty, Starts("error: invalid parameters for `open`: abc:")),
|
||||
case("perf with a negative number", &["perf", "-5"], 2, Empty, Line("error: unexpected argument '-5' found")),
|
||||
case("perf with a missing file", &["perf", "missing.dxf"], 1, Empty, Starts("error: invalid parameters for `open`: missing.dxf:")),
|
||||
case("perf 1", &["perf", "1"], 0, Starts("operation"), Empty),
|
||||
case("perf 0 is raised to 1", &["perf", "0"], 0, Starts("operation"), Empty),
|
||||
case("perf with two counts", &["perf", "1", "2"], 2, Empty, Line("error: unexpected argument '2' found")),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_values() {
|
||||
run_cases(
|
||||
"values",
|
||||
&[
|
||||
case(
|
||||
"--view sideways",
|
||||
&["convert", "{b}", "{dir}/o.svg", "--view", "sideways"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: invalid value 'sideways' for '--view <extents|current>'"),
|
||||
),
|
||||
case(
|
||||
"run --view sideways",
|
||||
&["run", "--sample", "--view", "sideways"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: invalid value 'sideways' for '--view <extents|current>'"),
|
||||
),
|
||||
case(
|
||||
"--view sideways before the output check",
|
||||
&["convert", "{b}", "{dir}/o.dxf", "--view", "sideways"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: invalid value 'sideways' for '--view <extents|current>'"),
|
||||
),
|
||||
case(
|
||||
"--window 3 numbers",
|
||||
&["convert", "{b}", "{dir}/o.svg", "--window", "1,2,3"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: invalid value '1,2,3' for '--window <x0,y0,x1,y1>': expected four numbers x0,y0,x1,y1"),
|
||||
),
|
||||
case(
|
||||
"--window 5 numbers",
|
||||
&["convert", "{b}", "{dir}/o.svg", "--window", "1,2,3,4,5"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: invalid value '1,2,3,4,5' for '--window <x0,y0,x1,y1>': expected four numbers x0,y0,x1,y1"),
|
||||
),
|
||||
case(
|
||||
"--window reversed",
|
||||
&["convert", "{b}", "{dir}/o.svg", "--window", "3,3,1,1"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: invalid value '3,3,1,1' for '--window <x0,y0,x1,y1>': need x0 < x1 and y0 < y1 (lower-left corner first)"),
|
||||
),
|
||||
case(
|
||||
"--window not numbers",
|
||||
&["convert", "{b}", "{dir}/o.svg", "--window", "a,b,c,d"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: invalid value 'a,b,c,d' for '--window <x0,y0,x1,y1>': x0 must be a finite number, got `a`"),
|
||||
),
|
||||
case(
|
||||
"--window NaN",
|
||||
&["convert", "{b}", "{dir}/o.svg", "--window", "NaN,0,1,1"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: invalid value 'NaN,0,1,1' for '--window <x0,y0,x1,y1>': x0 must be a finite number, got `NaN`"),
|
||||
),
|
||||
case(
|
||||
"--window too large",
|
||||
&["convert", "{b}", "{dir}/o.svg", "--window", "0,0,1e13,1"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: invalid value '0,0,1e13,1' for '--window <x0,y0,x1,y1>': export window: coordinates must lie within ±1e12"),
|
||||
),
|
||||
case(
|
||||
"run --window 3 numbers",
|
||||
&["run", "--sample", "--window", "1,2,3", "--export", "{dir}/o.svg"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: invalid value '1,2,3' for '--window <x0,y0,x1,y1>': expected four numbers x0,y0,x1,y1"),
|
||||
),
|
||||
case(
|
||||
"run --window reversed",
|
||||
&["run", "--sample", "--window", "3,3,1,1", "--export", "{dir}/o.svg"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: invalid value '3,3,1,1' for '--window <x0,y0,x1,y1>': need x0 < x1 and y0 < y1 (lower-left corner first)"),
|
||||
),
|
||||
case(
|
||||
"--cmd bad json",
|
||||
&["run", "--sample", "--cmd", "line {bad json"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: invalid value 'line {bad json' for '--cmd <id {json}>': key must be a string at line 1 column 2"),
|
||||
),
|
||||
case(
|
||||
"--cmd bad json after a good step: nothing runs",
|
||||
&["run", "--sample", "--cmd", "line {\"points\":[[0,0],[1,1]]}", "--cmd", "line {"],
|
||||
2,
|
||||
Empty,
|
||||
Starts("error: invalid value 'line {' for '--cmd <id {json}>': "),
|
||||
),
|
||||
case(
|
||||
"--cmd without an id",
|
||||
&["run", "--sample", "--cmd", " {}"],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: invalid value ' {}' for '--cmd <id {json}>': expected a command id, then optionally its JSON parameters"),
|
||||
),
|
||||
case(
|
||||
"--cmd empty",
|
||||
&["run", "--sample", "--cmd="],
|
||||
2,
|
||||
Empty,
|
||||
Line("error: invalid value '' for '--cmd <id {json}>': expected a command id, then optionally its JSON parameters"),
|
||||
),
|
||||
case("--cmd unknown id", &["run", "--sample", "--cmd", "nosuch"], 1, Empty, Line("error: unknown command `nosuch`")),
|
||||
case(
|
||||
"--cmd {last} with no earlier handle",
|
||||
&["run", "--sample", "--cmd", "erase {\"handles\":[\"{last}\"]}"],
|
||||
1,
|
||||
Empty,
|
||||
Line("error: --cmd erase: `{last}` needs an earlier --cmd in this run to have returned a handle"),
|
||||
),
|
||||
case("--script-file missing", &["run", "--sample", "--script-file", "missing.scr"], 1, Empty, Starts("error: missing.scr:")),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// `mcp` serves on the process's real stdin and stdout, so the headless server runs through the
|
||||
/// binary, with a closed stdin.
|
||||
#[test]
|
||||
fn mcp_headless_with_a_closed_stdin() {
|
||||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_cadcraft-cli"))
|
||||
.arg("mcp")
|
||||
.stdin(std::process::Stdio::null())
|
||||
.output()
|
||||
.expect("run cadcraft-cli");
|
||||
assert_eq!(out.status.code(), Some(0));
|
||||
assert!(out.stdout.is_empty() && out.stderr.is_empty());
|
||||
}
|
||||
|
||||
/// A drawing to open whose name isn't valid Unicode reaches the handler as a path; the engine opens
|
||||
/// files by text name, so it is refused there (exit 1), naming the file. Text that isn't Unicode is
|
||||
/// a usage error.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn non_unicode_arguments() {
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
let invoke_os = |args: Vec<OsString>| {
|
||||
let (mut out, mut err) = (Vec::new(), Vec::new());
|
||||
let code = cadcraft_cli::run(args, &mut out, &mut err);
|
||||
assert!(out.is_empty());
|
||||
(code, String::from_utf8_lossy(&err).into_owned())
|
||||
};
|
||||
let latin1 = || OsString::from_vec(b"caf\xe9.dxf".to_vec());
|
||||
let (code, err) = invoke_os(vec![OsString::from("info"), latin1()]);
|
||||
assert_eq!(code, 1);
|
||||
assert_eq!(err.lines().next(), Some("error: caf\u{FFFD}.dxf: the file name is not valid Unicode"));
|
||||
let (code, err) = invoke_os(vec![OsString::from("run"), OsString::from("--sample"), OsString::from("--cmd"), latin1()]);
|
||||
assert_eq!(code, 2);
|
||||
assert!(err.starts_with("error: invalid UTF-8 was detected"), "{err}");
|
||||
}
|
||||
|
||||
/// An output name that isn't valid Unicode is written as given: only its extension picks the format.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn non_unicode_output_names() {
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
let s = Scratch::new("nonunicode-out");
|
||||
let latin1 = |ext: &str| {
|
||||
let mut n = b"caf\xe9.".to_vec();
|
||||
n.extend_from_slice(ext.as_bytes());
|
||||
s.0.join(OsString::from_vec(n))
|
||||
};
|
||||
let run = |args: Vec<OsString>| {
|
||||
let (mut out, mut err) = (Vec::new(), Vec::new());
|
||||
let code = cadcraft_cli::run(args, &mut out, &mut err);
|
||||
(code, String::from_utf8_lossy(&err).into_owned())
|
||||
};
|
||||
let b = s.0.join("b.dxf");
|
||||
let cases: [(Vec<OsString>, std::path::PathBuf); 3] = [
|
||||
(vec!["sample".into(), "bracket".into(), latin1("dxf").into()], latin1("dxf")),
|
||||
(vec!["convert".into(), b.clone().into(), latin1("svg").into()], latin1("svg")),
|
||||
(vec!["run".into(), "--sample".into(), "--save".into(), latin1("dwg").into()], latin1("dwg")),
|
||||
];
|
||||
for (args, path) in cases {
|
||||
let (code, err) = run(args.clone());
|
||||
assert_eq!(code, 0, "{args:?}: {err}");
|
||||
assert!(path.exists(), "{path:?}");
|
||||
assert!(std::fs::metadata(&path).expect("stat output").len() > 0, "{path:?}");
|
||||
}
|
||||
// An input with such a name is still refused.
|
||||
let (code, err) = run(vec!["info".into(), latin1("dxf").into()]);
|
||||
assert_eq!(code, 1);
|
||||
assert!(err.contains("not valid Unicode"), "{err}");
|
||||
}
|
||||
|
||||
/// An output that is an input file is refused before any work (exit 2, the input untouched) unless
|
||||
/// `--in-place` allows it; the same file under another spelling counts, another existing file doesn't.
|
||||
#[test]
|
||||
fn output_that_replaces_an_input() {
|
||||
let s = Scratch::new("inplace");
|
||||
let dir = s.0.to_str().expect("utf-8 path").to_string();
|
||||
let b = format!("{dir}/b.dxf");
|
||||
let original = std::fs::read(&b).expect("read input");
|
||||
std::fs::create_dir_all(format!("{dir}/sub")).expect("create sub");
|
||||
let dotted = format!("{dir}/sub/../b.dxf");
|
||||
#[allow(unused_mut)]
|
||||
let mut spellings = vec![b.clone(), dotted];
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let link = format!("{dir}/link.dxf");
|
||||
std::os::unix::fs::symlink(&b, &link).expect("symlink");
|
||||
spellings.push(link);
|
||||
}
|
||||
for out in &spellings {
|
||||
for args in [vec!["convert", &b, out], vec!["run", &b, "--save", out], vec!["run", &b, "--export", out]] {
|
||||
let r = invoke(&args);
|
||||
assert_eq!(r.code, 2, "{args:?}: {}", r.stderr);
|
||||
assert!(r.stdout.is_empty(), "{args:?}");
|
||||
assert!(r.stderr.starts_with(&format!("error: {out} is an input file")), "{args:?}: {}", r.stderr);
|
||||
assert!(r.stderr.contains("--in-place"), "{args:?}: {}", r.stderr);
|
||||
assert_eq!(std::fs::read(&b).expect("read input"), original, "{args:?}: input changed");
|
||||
}
|
||||
}
|
||||
|
||||
// Another existing file is replaced as before.
|
||||
let other = format!("{dir}/other.dxf");
|
||||
std::fs::write(&other, "x").expect("write other");
|
||||
let r = invoke(&["convert", &b, &other]);
|
||||
assert_eq!(r.code, 0, "{}", r.stderr);
|
||||
assert_ne!(std::fs::read(&other).expect("read other"), b"x");
|
||||
|
||||
// --in-place allows it, for each spelling and flag: the input itself is rewritten, and a
|
||||
// symlink stays a link to it.
|
||||
let line = "line {\"points\":[[0,0],[1,1]]}";
|
||||
let lines = |p: &str| std::fs::read_to_string(p).expect("read input").lines().filter(|l| l.trim_end() == "LINE").count();
|
||||
let before = lines(&b);
|
||||
for out in &spellings {
|
||||
std::fs::write(&b, &original).expect("restore input");
|
||||
let r = invoke(&["convert", &b, out, "--in-place"]);
|
||||
assert_eq!(r.code, 0, "convert {out}: {}", r.stderr);
|
||||
assert_eq!(lines(&b), before, "convert {out} keeps the drawing");
|
||||
for flag in ["--save", "--export"] {
|
||||
std::fs::write(&b, &original).expect("restore input");
|
||||
let r = invoke(&["run", &b, "--cmd", line, "--in-place", flag, out]);
|
||||
assert_eq!(r.code, 0, "{flag} {out}: {}", r.stderr);
|
||||
assert_eq!(lines(&b), before + 1, "{flag} {out}: the input gains the line");
|
||||
}
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let link = format!("{dir}/link.dxf");
|
||||
assert!(std::fs::symlink_metadata(&link).expect("stat link").file_type().is_symlink(), "{link} is still a symlink");
|
||||
}
|
||||
|
||||
// An output that doesn't exist yet is never an input.
|
||||
let new = format!("{dir}/new.dxf");
|
||||
let r = invoke(&["run", &b, "--save", &new]);
|
||||
assert_eq!(r.code, 0, "{}", r.stderr);
|
||||
assert!(std::path::Path::new(&new).is_file(), "{new} written");
|
||||
}
|
||||
|
||||
/// JSON results are pretty-printed; `--compact` puts each on one line (RFC-0012 rule 1.3). `run`
|
||||
/// prints one line per `--cmd` result either way.
|
||||
#[test]
|
||||
fn json_output_is_pretty_unless_compact() {
|
||||
let s = Scratch::new("compact");
|
||||
let b = format!("{}/b.dxf", s.0.to_str().expect("utf-8 path"));
|
||||
for args in [&["info", b.as_str()][..], &["commands", "ellipse"]] {
|
||||
let help = invoke(&[args[0], "--help"]);
|
||||
assert!(
|
||||
help.stdout.contains("--compact") && help.stdout.contains("One line of JSON instead of pretty-printed"),
|
||||
"{} --help: {}",
|
||||
args[0],
|
||||
help.stdout
|
||||
);
|
||||
let pretty = invoke(args);
|
||||
assert_eq!(pretty.code, 0, "{args:?}: {}", pretty.stderr);
|
||||
assert!(pretty.stdout.lines().count() > 1, "{args:?} is pretty-printed: {}", pretty.stdout);
|
||||
let compact = invoke(&[args, &["--compact"]].concat());
|
||||
assert_eq!(compact.code, 0, "{args:?} --compact: {}", compact.stderr);
|
||||
assert_eq!(compact.stdout.lines().count(), 1, "{args:?} --compact is one line: {}", compact.stdout);
|
||||
let a: serde_json::Value = serde_json::from_str(&pretty.stdout).expect("pretty JSON");
|
||||
let c: serde_json::Value = serde_json::from_str(&compact.stdout).expect("compact JSON");
|
||||
assert_eq!(a, c, "{args:?}: same JSON either way");
|
||||
}
|
||||
let r = invoke(&["run", "--sample", "--cmd", "drawing.inspect", "--cmd", "drawing.inspect"]);
|
||||
assert_eq!(r.code, 0, "{}", r.stderr);
|
||||
assert_eq!(r.stdout.lines().count(), 2, "one line per --cmd result: {}", r.stdout);
|
||||
assert_eq!(invoke(&["run", "--sample", "--compact"]).code, 2, "run takes no --compact");
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
//! The clap definition is consistent, and `examples/man-and-completions.rs` writes every shell's
|
||||
//! completions and every man page under the names `packaging/linux/package.sh` installs.
|
||||
|
||||
// The example itself, so the test runs its code; its `main` is unused here.
|
||||
#[allow(dead_code)]
|
||||
#[path = "../examples/man-and-completions.rs"]
|
||||
mod example;
|
||||
|
||||
#[test]
|
||||
fn command_is_consistent() {
|
||||
cadcraft_cli::command().debug_assert();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_example_writes_the_files_the_packages_install() {
|
||||
let dir = std::path::Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("man-and-completions-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
example::generate(&dir).unwrap();
|
||||
|
||||
let mut written: Vec<String> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name().into_string().unwrap()).collect();
|
||||
written.sort();
|
||||
let completions = ["cadcraft-cli.bash", "_cadcraft-cli", "cadcraft-cli.fish", "cadcraft-cli.elv", "_cadcraft-cli.ps1"];
|
||||
let subcommands = ["info", "convert", "run", "commands", "mcp", "perf", "sample"];
|
||||
let mut expected: Vec<String> = completions.map(String::from).into();
|
||||
expected.push("cadcraft-cli.1".into());
|
||||
expected.extend(subcommands.iter().map(|s| format!("cadcraft-cli-{s}.1")));
|
||||
expected.sort();
|
||||
assert_eq!(written, expected);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
@@ -33,15 +33,12 @@ fn replies(out: &Output) -> Vec<Value> {
|
||||
#[test]
|
||||
fn connect_without_address_fails_before_serving() {
|
||||
let input = format!("{INIT}\n{CIRCLE}\n");
|
||||
// clap names an unknown flag rather than the value it left missing.
|
||||
for (args, named) in
|
||||
[(&["--connect"][..], "--connect <HOST:PORT>"), (&["--connect", ""], "--connect <HOST:PORT>"), (&["--connect", "--foo"], "'--foo'")]
|
||||
{
|
||||
for args in [&["--connect"][..], &["--connect", ""], &["--connect", "--foo"]] {
|
||||
let out = mcp(args, &input);
|
||||
assert_eq!(out.status.code(), Some(2), "{args:?} is a usage error");
|
||||
assert!(!out.status.success(), "{args:?} must exit non-zero");
|
||||
assert!(out.stdout.is_empty(), "{args:?} must not answer MCP requests: {}", String::from_utf8_lossy(&out.stdout));
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(err.contains(named), "{args:?}: {err}");
|
||||
assert!(err.contains("--connect needs HOST:PORT"), "{args:?}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
//! A damaged DWG opens partially (#859); `info` lists the reader's notes on what it left out
|
||||
//! (`readNotes`) and `convert` warns with them before writing the copy, so a script or agent sees
|
||||
//! the loss. The exit code stays 0.
|
||||
|
||||
use std::process::{Command, Output};
|
||||
|
||||
fn cli(args: &[&str]) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_cadcraft-cli")).args(args).output().unwrap()
|
||||
}
|
||||
|
||||
/// A DWG of 200 lines, and a copy damaged so that the reader skips some of its records (found the
|
||||
/// way the reader's own test finds one: flipping a few bytes at a time across the objects area).
|
||||
fn dwg_and_damaged_copy() -> (Vec<u8>, Vec<u8>) {
|
||||
let mut dxf = String::from("0\nSECTION\n2\nENTITIES\n");
|
||||
for i in 0..200 {
|
||||
dxf += &format!("0\nLINE\n8\n0\n10\n{i}\n20\n0\n11\n{i}\n21\n10\n");
|
||||
}
|
||||
dxf += "0\nENDSEC\n0\nEOF\n";
|
||||
let d = cadcraft_io::read(dxf.as_bytes(), "lines.dxf").unwrap();
|
||||
let opts = cadcraft_io::SaveOptions { version: Some(cadcraft_io::Version::R2000), binary: false };
|
||||
let dwg = cadcraft_io::write_with(&d, "lines.dwg", &opts).unwrap();
|
||||
let damaged = (dwg.len() / 4..dwg.len() * 3 / 5)
|
||||
.step_by(97)
|
||||
.map(|at| {
|
||||
let mut b = dwg.clone();
|
||||
b[at..at + 6].iter_mut().for_each(|x| *x ^= 0xA5);
|
||||
b
|
||||
})
|
||||
.find(|b| cadcraft_io::read(b, "damaged.dwg").is_ok_and(|d| !d.read_notes.is_empty()))
|
||||
.expect("no damaged copy read partially");
|
||||
(dwg, damaged)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_lists_and_convert_warns_about_read_notes() {
|
||||
let dir = std::env::temp_dir().join(format!("cc-cli-read-notes-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (whole, damaged) = dwg_and_damaged_copy();
|
||||
let path = dir.join("damaged.dwg");
|
||||
std::fs::write(&path, &damaged).unwrap();
|
||||
let path = path.to_str().unwrap();
|
||||
|
||||
let out = cli(&["info", path]);
|
||||
assert!(out.status.success(), "{:?}: {}", out.status, String::from_utf8_lossy(&out.stderr));
|
||||
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
let notes = v["readNotes"].as_array().unwrap();
|
||||
assert_eq!(notes.len(), 1, "{v}");
|
||||
assert!(notes[0].as_str().unwrap().contains(" couldn't be read and "), "{v}");
|
||||
|
||||
let copy = dir.join("copy.dxf");
|
||||
let out = cli(&["convert", path, copy.to_str().unwrap()]);
|
||||
assert!(out.status.success(), "{:?}: {}", out.status, String::from_utf8_lossy(&out.stderr));
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
let warnings: Vec<&str> = err.lines().filter(|l| l.contains(" couldn't be read and ")).collect();
|
||||
assert_eq!(warnings.len(), 1, "{err}");
|
||||
assert!(warnings[0].starts_with(&format!("{path}: DWG: ")), "{err}");
|
||||
assert!(copy.exists());
|
||||
|
||||
// A complete read: no notes, and no warning.
|
||||
let intact = dir.join("whole.dwg");
|
||||
std::fs::write(&intact, &whole).unwrap();
|
||||
let out = cli(&["info", intact.to_str().unwrap()]);
|
||||
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
assert_eq!(v["readNotes"], serde_json::json!([]));
|
||||
let out = cli(&["convert", intact.to_str().unwrap(), copy.to_str().unwrap()]);
|
||||
assert!(out.status.success());
|
||||
assert!(!String::from_utf8_lossy(&out.stderr).contains("couldn't be read"), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -4,7 +4,6 @@ description = "CADCraft in the browser (eframe web runner, WebGPU with WebGL2 fa
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.95"
|
||||
publish = false
|
||||
|
||||
[lints]
|
||||
@@ -27,7 +26,7 @@ serde_json = { workspace = true }
|
||||
web-sys = { version = "0.3", features = ["HtmlInputElement", "FileList", "File"] }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
eframe = { workspace = true, features = ["persistence", "wgpu", "accesskit"] }
|
||||
eframe = { workspace = true, features = ["persistence", "default_fonts", "wgpu", "accesskit"] }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Location", "Navigator", "Blob", "BlobPropertyBag", "Url", "MessageEvent", "UrlSearchParams", "MediaQueryList"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Location", "Navigator", "Blob", "BlobPropertyBag", "Url"] }
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<title>CADCraft</title>
|
||||
<link data-trunk rel="rust" data-bin="cadcraft-web" data-wasm-opt="z" data-weak-refs />
|
||||
<style>
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed
|
||||
//! CADCraft in an `<iframe>` (such as a Nextcloud app). Messages are plain objects with a `type`:
|
||||
//!
|
||||
//! - `cadcraft:ready` (to the parent): `{ version }`, the app is listening; send a drawing now.
|
||||
//! - `cadcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or
|
||||
//! `Uint8Array`. It opens like a file picked with File › Open or dropped on the window.
|
||||
//! - `cadcraft:save` (to the parent): `{ name, bytes }` (a transferred `ArrayBuffer`) for every
|
||||
//! file CADCraft would otherwise download (SAVE, SAVEAS, exports): it replaces the engine's
|
||||
//! delivery hook. The parent stores the file and reports failures itself.
|
||||
//! - `cadcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.
|
||||
//! - `cadcraft:failed` (to the parent): `{ error }` when the app couldn't start.
|
||||
//!
|
||||
//! Only the parent window on the page's own origin is heard or answered. In host mode the app
|
||||
//! starts on the Start tab rather than an empty Drawing1, and the parent guards leaving the page.
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
|
||||
use cadcraft_ui_egui::CadApp;
|
||||
use wasm_bindgen::JsCast as _;
|
||||
use wasm_bindgen::JsValue;
|
||||
use wasm_bindgen::closure::Closure;
|
||||
use web_sys::js_sys;
|
||||
|
||||
/// Longest file name taken from the parent page.
|
||||
const MAX_NAME_CHARS: usize = 255;
|
||||
|
||||
thread_local! {
|
||||
/// The bridge [`deliver`] posts through (the engine's hook is a plain `fn`).
|
||||
static BRIDGE: RefCell<Option<Bridge>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Bridge {
|
||||
window: web_sys::Window,
|
||||
parent: web_sys::Window,
|
||||
origin: String,
|
||||
}
|
||||
|
||||
/// The unsaved-changes state last reported to the parent.
|
||||
#[derive(Default)]
|
||||
pub struct DirtyReport(Cell<Option<bool>>);
|
||||
|
||||
impl Bridge {
|
||||
/// `None` when the page isn't framed (there is no parent to talk to).
|
||||
pub fn new() -> Option<Self> {
|
||||
let window = web_sys::window()?;
|
||||
let parent = window.parent().ok().flatten()?;
|
||||
if js_sys::Object::is(&parent, &window) {
|
||||
return None;
|
||||
}
|
||||
let origin = window.location().origin().ok()?;
|
||||
Some(Self { window, parent, origin })
|
||||
}
|
||||
|
||||
/// Make this the bridge [`deliver`] uses.
|
||||
pub fn install(&self) {
|
||||
BRIDGE.with(|b| *b.borrow_mut() = Some(self.clone()));
|
||||
}
|
||||
|
||||
pub fn post_ready(&self) {
|
||||
self.send(&[("type", "cadcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]);
|
||||
}
|
||||
|
||||
pub fn post_failed(&self, error: &str) {
|
||||
self.send(&[("type", "cadcraft:failed".into()), ("error", error.into())]);
|
||||
}
|
||||
|
||||
/// Hear drawings from the parent; they arrive through the upload inbox.
|
||||
pub fn listen(&self, inbox: crate::upload::Inbox) {
|
||||
let parent = self.parent.clone();
|
||||
let origin = self.origin.clone();
|
||||
let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {
|
||||
if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {
|
||||
return;
|
||||
}
|
||||
let data = event.data();
|
||||
if string(&data, "type").as_deref() != Some("cadcraft:open") {
|
||||
return;
|
||||
}
|
||||
let name = string(&data, "name")
|
||||
.filter(|n| !n.trim().is_empty())
|
||||
.map(|n| n.chars().take(MAX_NAME_CHARS).collect())
|
||||
.unwrap_or_else(|| "Drawing.dxf".to_string());
|
||||
// Refuse an oversized drawing before copying it into the app's memory.
|
||||
let Some(array) = byte_array(&data) else { return };
|
||||
if let Some(e) = CadApp::open_size_error(&name, u64::from(array.length())) {
|
||||
inbox.refuse(e);
|
||||
return;
|
||||
}
|
||||
inbox.receive(name, array.to_vec());
|
||||
});
|
||||
if self.window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {
|
||||
// The listener lives as long as the page.
|
||||
on_message.forget();
|
||||
}
|
||||
}
|
||||
|
||||
/// After each logic callback (including a hidden tab's): tell the parent when unsaved changes
|
||||
/// appear or go away. `DirtyReport` suppresses duplicates, so this is idempotent.
|
||||
pub fn report_dirty(&self, app: &CadApp, reported: &DirtyReport) {
|
||||
let dirty = app.session.docs.iter().any(|d| d.is_dirty());
|
||||
if reported.0.get() != Some(dirty) && self.send(&[("type", "cadcraft:dirty".into()), ("dirty", dirty.into())]) {
|
||||
reported.0.set(Some(dirty));
|
||||
}
|
||||
}
|
||||
|
||||
fn post_save(&self, name: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
let buffer = js_sys::Uint8Array::from(bytes).buffer();
|
||||
let message = message(&[("type", "cadcraft:save".into()), ("name", name.into()), ("bytes", buffer.clone().into())])
|
||||
.ok_or_else(|| "couldn't build the message".to_string())?;
|
||||
self.parent
|
||||
.post_message_with_transfer(&message, &self.origin, &js_sys::Array::of1(&buffer))
|
||||
.map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))
|
||||
}
|
||||
|
||||
/// Post a message to the parent; false if it couldn't be sent.
|
||||
fn send(&self, fields: &[(&str, JsValue)]) -> bool {
|
||||
message(fields).is_some_and(|m| self.parent.post_message(&m, &self.origin).is_ok())
|
||||
}
|
||||
}
|
||||
|
||||
/// The engine's delivery hook in host mode ([`cadcraft_engine::cmd::file::set_deliver`]): hand
|
||||
/// the file to the parent page. `Ok` once it was posted; the engine then counts a drawing as saved.
|
||||
pub fn deliver(name: &str, _mime: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
let bridge = BRIDGE.with(|b| b.borrow().clone()).ok_or("the page that embeds CADCraft is gone")?;
|
||||
bridge.post_save(name, bytes)
|
||||
}
|
||||
|
||||
fn message(fields: &[(&str, JsValue)]) -> Option<js_sys::Object> {
|
||||
let m = js_sys::Object::new();
|
||||
for (key, value) in fields {
|
||||
js_sys::Reflect::set(&m, &JsValue::from_str(key), value).ok()?;
|
||||
}
|
||||
Some(m)
|
||||
}
|
||||
|
||||
fn string(data: &JsValue, key: &str) -> Option<String> {
|
||||
js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string()
|
||||
}
|
||||
|
||||
/// `bytes` (an `ArrayBuffer` or `Uint8Array`) as a view, not yet copied.
|
||||
fn byte_array(data: &JsValue) -> Option<js_sys::Uint8Array> {
|
||||
let bytes = js_sys::Reflect::get(data, &JsValue::from_str("bytes")).ok()?;
|
||||
if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {
|
||||
Some(array.clone())
|
||||
} else if bytes.is_instance_of::<js_sys::ArrayBuffer>() {
|
||||
Some(js_sys::Uint8Array::new(&bytes))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
+17
-228
@@ -2,20 +2,12 @@
|
||||
//!
|
||||
//! Runs the same [`cadcraft_ui_egui::CadApp`] as the desktop app through eframe's web runner
|
||||
//! (wgpu: WebGPU where available, WebGL2 otherwise). Build with `trunk build --release` from this
|
||||
//! directory (output in `dist/web`). URL flags: `?webgl` forces WebGL2, `?cpu` draws the drawing
|
||||
//! with egui shapes instead of the GPU canvas, `?sample` opens the sample drawing. Saving downloads
|
||||
//! the drawing through the browser (`download.rs`). With `?host=parent` (inside an `<iframe>`), a
|
||||
//! same-origin parent page sends the drawing to open and stores what CADCraft saves instead
|
||||
//! (`host.rs`).
|
||||
//! directory (output in `dist/web`). URL flags: `?webgl` forces WebGL2, `?sample` opens the sample
|
||||
//! drawing. Saving downloads the drawing through the browser (`download.rs`).
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod download;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod host;
|
||||
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod panel_layout;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web {
|
||||
@@ -26,90 +18,39 @@ mod web {
|
||||
const CANVAS_ID: &str = "cadcraft_canvas";
|
||||
const LOADING_ID: &str = "cadcraft_loading";
|
||||
|
||||
/// The app, the files File > Open… and drag and drop read for it in the background, the
|
||||
/// page embedding it (`?host=parent`), the browser's `prefers-reduced-motion` (it stays
|
||||
/// live, so View ▸ Accessibility ▸ Animation Effects ▸ Use System Setting follows a change),
|
||||
/// and the document title last written to the page.
|
||||
struct Shell(
|
||||
CadApp,
|
||||
crate::upload::Inbox,
|
||||
Option<(crate::host::Bridge, crate::host::DirtyReport)>,
|
||||
Option<web_sys::MediaQueryList>,
|
||||
Option<String>,
|
||||
);
|
||||
/// The app, and the files File > Open… and drag and drop read for it in the background.
|
||||
struct Shell(CadApp, crate::upload::Inbox);
|
||||
|
||||
impl eframe::App for Shell {
|
||||
/// Runs one frame: takes in files dropped on or picked for the page, drains the background
|
||||
/// uploads, follows the browser's reduced-motion setting, runs the app, writes the browser
|
||||
/// document title when it changed (so the tab and an embedding page name the open
|
||||
/// drawing), and reports unsaved changes to that page.
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
// Background reads are consume-based and run on hidden ticks too. Dropped files are
|
||||
// input and are read from `ui` only.
|
||||
self.1.take_dropped(ctx);
|
||||
self.1.drain(&mut self.0);
|
||||
self.0.system_animations = self.3.as_ref().map(|reduced| !reduced.matches());
|
||||
self.0.logic(ctx);
|
||||
let drawing = self.0.session.state().ok();
|
||||
crate::push_title(&mut self.4, drawing.map(|d| d.title.as_str()), drawing.is_some_and(|d| d.is_dirty()), set_page_title);
|
||||
// `DirtyReport` suppresses duplicates, so reporting after every logic callback
|
||||
// (including a hidden tab's) is idempotent and keeps the parent's guard current.
|
||||
if let Some((bridge, reported)) = &self.2 {
|
||||
bridge.report_dirty(&self.0, reported);
|
||||
}
|
||||
}
|
||||
/// Forward raw input to the web `CadApp` and capture Tab at the command line.
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.0.raw_input_hook(ctx, raw);
|
||||
self.0.raw_input_hook(raw);
|
||||
cadcraft_ui_egui::cmdline::capture_tab(ctx, raw);
|
||||
}
|
||||
/// Preferences and validated panel placement survive reloads; egui memory is not kept.
|
||||
/// Preferences (interface theme and language) survive reloads; egui state is not kept.
|
||||
fn save(&mut self, storage: &mut dyn eframe::Storage) {
|
||||
storage.set_string(cadcraft_ui_egui::options::PROFILE_KEY, self.0.profile_json());
|
||||
storage.set_string(cadcraft_ui_egui::PREFS_KEY, self.0.prefs_json());
|
||||
match crate::panel_layout::save(&self.0.ui) {
|
||||
Ok(layout) => storage.set_string(crate::panel_layout::KEY, layout),
|
||||
Err(error) => log::warn!("panel layout was not saved: {error}"),
|
||||
}
|
||||
}
|
||||
fn persist_egui_memory(&self) -> bool {
|
||||
false
|
||||
}
|
||||
/// On the first pass consume files dropped on the page, then delegate drawing to `CadApp::ui`.
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
let first_pass = ui.ctx().current_pass_index() == 0;
|
||||
if first_pass {
|
||||
// `take_dropped` consumes the raw list; a second pass or hidden tick cannot
|
||||
// re-read the same file.
|
||||
self.1.take_dropped(ui.ctx());
|
||||
}
|
||||
self.0.ui(ui);
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes the browser's document title, which names the tab and is what an embedding page
|
||||
/// (`?host=parent`) reads. eframe's web runner ignores [`egui::ViewportCommand::Title`], so
|
||||
/// the title has to reach the DOM directly.
|
||||
fn set_page_title(title: &str) {
|
||||
match web_sys::window().and_then(|window| window.document()) {
|
||||
Some(document) => document.set_title(title),
|
||||
None => log::warn!("cannot set the page title to {title:?}: there is no document"),
|
||||
}
|
||||
}
|
||||
|
||||
fn query() -> String {
|
||||
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn query_param(key: &str) -> Option<String> {
|
||||
web_sys::UrlSearchParams::new_with_str(&query()).ok()?.get(key)
|
||||
}
|
||||
|
||||
/// No file picker on the web: Save / Save As from the menu (and the close prompt) download the
|
||||
/// drawing under its current name; `saveas {"path": "name.dxf"}` picks another name. There is
|
||||
/// no file system either, so a dropped file is opened from its contents and APPLOAD says to
|
||||
/// drop a `.lsp` file rather than asking for a name it cannot read.
|
||||
/// drawing under its current name; `saveas {"path": "name.dxf"}` picks another name.
|
||||
fn services() -> Services {
|
||||
Services { pick_save: Some(Box::new(|name: &str| Some(name.to_string()))), no_file_system: true, ..Services::default() }
|
||||
Services { pick_save: Some(Box::new(|name: &str| Some(name.to_string()))), ..Services::default() }
|
||||
}
|
||||
|
||||
pub fn start() {
|
||||
@@ -117,29 +58,11 @@ mod web {
|
||||
cadcraft_engine::cmd::file::set_io(cadcraft_engine::cmd::file::IoHooks {
|
||||
read: |b, name| cadcraft_io::read(b, name).map_err(|e| e.to_string()),
|
||||
write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()),
|
||||
plot: Some(|d, space, opts| cadcraft_io::plotter::plot(d, space, opts).map_err(|e| e.to_string())),
|
||||
plot: Some(|d, space, opts| cadcraft_io::plot(d, space, opts).map_err(|e| e.to_string())),
|
||||
});
|
||||
cadcraft_engine::cmd::file::set_write_as(|d, name, o| {
|
||||
cadcraft_io::write_with(d, name, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string())
|
||||
});
|
||||
// AutoLISP's entget, entmake and entmod see entities as DXF groups.
|
||||
cadcraft_engine::cmd::file::set_entity_codec(cadcraft_engine::cmd::file::EntityCodec {
|
||||
groups: cadcraft_io::entity_groups,
|
||||
entity: cadcraft_io::entity_from_groups,
|
||||
});
|
||||
// `?host=parent`: a same-origin page framing CADCraft (such as a Nextcloud app) opens
|
||||
// drawings in it and stores what it saves. Otherwise there is no file system here: saved
|
||||
// files reach the user as browser downloads.
|
||||
let bridge = if query_param("host").as_deref() == Some("parent") { crate::host::Bridge::new() } else { None };
|
||||
match &bridge {
|
||||
Some(bridge) => {
|
||||
bridge.install();
|
||||
cadcraft_engine::cmd::file::set_deliver(crate::host::deliver);
|
||||
}
|
||||
None => cadcraft_engine::cmd::file::set_deliver(crate::download::download),
|
||||
}
|
||||
let app_bridge = bridge.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
// No file system here: saved files reach the user as browser downloads.
|
||||
cadcraft_engine::cmd::file::set_deliver(crate::download::download);
|
||||
wasm_bindgen_futures::spawn_local(async {
|
||||
let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };
|
||||
let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok()) else {
|
||||
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
|
||||
@@ -156,29 +79,14 @@ mod web {
|
||||
canvas,
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
// Embedded, the parent sends the drawing: start on the Start tab, not Drawing1.
|
||||
let session = if app_bridge.is_some() { Session::empty() } else { Session::new() };
|
||||
let mut app = CadApp::new(session, services());
|
||||
let mut app = CadApp::new(Session::new(), services());
|
||||
app.system_languages =
|
||||
web_sys::window().map(|w| w.navigator().languages().iter().filter_map(|v| v.as_string()).collect()).unwrap_or_default();
|
||||
if let Some(profile) = cc.storage.and_then(|s| s.get_string(cadcraft_ui_egui::options::PROFILE_KEY)) {
|
||||
app.load_profile(&profile);
|
||||
}
|
||||
if let Some(prefs) = cc.storage.and_then(|s| s.get_string(cadcraft_ui_egui::PREFS_KEY)) {
|
||||
app.load_prefs(&prefs);
|
||||
}
|
||||
if let Some(layout) = cc.storage.and_then(|s| s.get_string(crate::panel_layout::KEY))
|
||||
&& let Err(error) = crate::panel_layout::restore(&mut app.ui, &layout)
|
||||
{
|
||||
log::warn!("panel layout was not restored: {error}");
|
||||
}
|
||||
// `?cpu`: no render state for the app, so the canvas draws with egui shapes.
|
||||
if !crate::wants_cpu(&query())
|
||||
&& let Some(rs) = &cc.wgpu_render_state
|
||||
{
|
||||
// eframe's web runner has no stencil buffer: clipped layout
|
||||
// viewports stay in the layout's display list.
|
||||
app.set_wgpu(rs, None);
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
app.set_wgpu(rs);
|
||||
}
|
||||
if query().contains("sample") {
|
||||
let _ = app.run("ui.sample", serde_json::json!({}));
|
||||
@@ -191,19 +99,10 @@ mod web {
|
||||
picker.pick();
|
||||
None
|
||||
}));
|
||||
let host = app_bridge.map(|bridge| {
|
||||
bridge.listen(inbox.clone());
|
||||
bridge.post_ready();
|
||||
(bridge, crate::host::DirtyReport::default())
|
||||
});
|
||||
let reduced_motion = web_sys::window().and_then(|w| w.match_media("(prefers-reduced-motion: reduce)").ok().flatten());
|
||||
Ok(Box::new(Shell(app, inbox, host, reduced_motion, None)))
|
||||
Ok(Box::new(Shell(app, inbox)))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
if let (Err(e), Some(bridge)) = (&result, &bridge) {
|
||||
bridge.post_failed(&format!("{e:?}"));
|
||||
}
|
||||
if let Some(el) = document.get_element_by_id(LOADING_ID) {
|
||||
match result {
|
||||
Ok(()) => el.remove(),
|
||||
@@ -216,50 +115,6 @@ mod web {
|
||||
}
|
||||
}
|
||||
|
||||
/// True when the URL query (`?cpu&sample`) has a `cpu` flag: the CPU canvas path is requested.
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
fn wants_cpu(query: &str) -> bool {
|
||||
query.trim_start_matches('?').split('&').any(|p| p == "cpu" || p.starts_with("cpu="))
|
||||
}
|
||||
|
||||
/// The browser tab title: the open drawing's file name, a bullet once it has unsaved changes,
|
||||
/// and the app name (`drawing.dwg • — CADCraft`), or just the app name with no drawing. An
|
||||
/// embedding page (`?host=parent`) reads it to tell which drawing opened and whether it is dirty.
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
fn document_title(name: Option<&str>, dirty: bool) -> String {
|
||||
match name.filter(|n| !n.trim().is_empty()) {
|
||||
Some(name) => format!("{name}{} — CADCraft", if dirty { " •" } else { "" }),
|
||||
None => "CADCraft".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The title to write this frame, or `None` while the page already shows it: the browser needs
|
||||
/// the document title only when it changes, not on every frame.
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
fn title_to_send(sent: &mut Option<String>, name: Option<&str>, dirty: bool) -> Option<String> {
|
||||
let title = document_title(name, dirty);
|
||||
if sent.as_deref() == Some(title.as_str()) {
|
||||
return None;
|
||||
}
|
||||
*sent = Some(title.clone());
|
||||
Some(title)
|
||||
}
|
||||
|
||||
/// Writes a changed title through `write` (on the web, the browser's document title) and reports
|
||||
/// whether it wrote one. Split from [`title_to_send`] so the change decision and the write are
|
||||
/// both testable: eframe's web backend ignores `egui::ViewportCommand::Title`, so a title that is
|
||||
/// computed but not written leaves the tab on `CADCraft`.
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
fn push_title(sent: &mut Option<String>, name: Option<&str>, dirty: bool, write: impl FnOnce(&str)) -> bool {
|
||||
match title_to_send(sent, name, dirty) {
|
||||
Some(title) => {
|
||||
write(&title);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod upload;
|
||||
|
||||
@@ -272,69 +127,3 @@ fn main() {
|
||||
fn main() {
|
||||
eprintln!("cadcraft-web only runs in the browser: build it with `trunk build --release` in apps/cadcraft-web");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{document_title, push_title, title_to_send, wants_cpu};
|
||||
|
||||
/// Guards the `?cpu` URL flag: only a whole `cpu` query parameter (bare or with a value)
|
||||
/// selects the CPU canvas, so `?sample` and `?gpucpu` stay on the GPU path.
|
||||
#[test]
|
||||
fn cpu_flag_is_detected() {
|
||||
assert!(wants_cpu("?cpu"));
|
||||
assert!(wants_cpu("?cpu&sample"));
|
||||
assert!(wants_cpu("?sample&cpu"));
|
||||
assert!(!wants_cpu(""));
|
||||
assert!(!wants_cpu("?sample"));
|
||||
assert!(!wants_cpu("?webgl&sample"));
|
||||
assert!(!wants_cpu("?gpucpu"));
|
||||
}
|
||||
|
||||
/// Guards the tab title text: a drawing contributes its name and, when dirty, a trailing
|
||||
/// bullet; a blank or missing name leaves the app name alone and never shows a bullet.
|
||||
#[test]
|
||||
fn viewport_title_names_the_document_and_marks_unsaved_changes() {
|
||||
assert_eq!(document_title(Some("drawing.dwg"), false), "drawing.dwg — CADCraft");
|
||||
assert_eq!(document_title(Some("drawing.dwg"), true), "drawing.dwg • — CADCraft");
|
||||
assert_eq!(document_title(Some("Drawing1"), false), "Drawing1 — CADCraft");
|
||||
assert_eq!(document_title(None, false), "CADCraft");
|
||||
assert_eq!(document_title(None, true), "CADCraft");
|
||||
assert_eq!(document_title(Some(" "), true), "CADCraft");
|
||||
}
|
||||
|
||||
/// Guards the change signal: a title is sent on open, rename, dirty change and close, but
|
||||
/// never twice in a row, so an idle tab is not repainted by a repeated title command.
|
||||
#[test]
|
||||
fn viewport_title_follows_open_rename_and_dirty_changes_only() {
|
||||
let mut sent = None;
|
||||
assert_eq!(title_to_send(&mut sent, Some("drawing.dwg"), false).as_deref(), Some("drawing.dwg — CADCraft"));
|
||||
// The page already has it: send nothing.
|
||||
assert_eq!(title_to_send(&mut sent, Some("drawing.dwg"), false), None);
|
||||
// Unsaved changes add the bullet.
|
||||
assert_eq!(title_to_send(&mut sent, Some("drawing.dwg"), true).as_deref(), Some("drawing.dwg • — CADCraft"));
|
||||
// A rename shows the new name.
|
||||
assert_eq!(title_to_send(&mut sent, Some("plan.dwg"), true).as_deref(), Some("plan.dwg • — CADCraft"));
|
||||
// Saving clears it again.
|
||||
assert_eq!(title_to_send(&mut sent, Some("plan.dwg"), false).as_deref(), Some("plan.dwg — CADCraft"));
|
||||
// Closing the last drawing leaves the app name only.
|
||||
assert_eq!(title_to_send(&mut sent, None, false).as_deref(), Some("CADCraft"));
|
||||
assert_eq!(title_to_send(&mut sent, None, false), None);
|
||||
}
|
||||
|
||||
/// Guards the title write: every changed title reaches the writer (on the web, the browser
|
||||
/// document title) exactly once, and an unchanged one is not written again. Without the write
|
||||
/// the tab and an embedding page stay on `CADCraft` however the drawing changes.
|
||||
#[test]
|
||||
fn changed_title_reaches_the_document_title_writer_once() {
|
||||
let mut sent = None;
|
||||
let mut written: Vec<String> = Vec::new();
|
||||
assert!(push_title(&mut sent, Some("drawing.dwg"), false, |title| written.push(title.to_owned())));
|
||||
// Unchanged: nothing is written again.
|
||||
assert!(!push_title(&mut sent, Some("drawing.dwg"), false, |title| written.push(title.to_owned())));
|
||||
// Unsaved changes are written.
|
||||
assert!(push_title(&mut sent, Some("drawing.dwg"), true, |title| written.push(title.to_owned())));
|
||||
// Closing the drawing writes the app name.
|
||||
assert!(push_title(&mut sent, None, false, |title| written.push(title.to_owned())));
|
||||
assert_eq!(written, ["drawing.dwg — CADCraft", "drawing.dwg • — CADCraft", "CADCraft"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
//! Browser panel layout storage, independent of drawings and native workspace files.
|
||||
|
||||
use cadcraft_ui_egui::{UiState, docking};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub const KEY: &str = "cadcraft.panel-layout";
|
||||
const MAX_BYTES: usize = 64 * 1024;
|
||||
|
||||
/// Serialize only validated panel placement and visibility, with a bounded record size.
|
||||
pub fn save(ui: &UiState) -> Result<String, String> {
|
||||
docking::validate(&ui.docking)?;
|
||||
let saved = json!({
|
||||
"version": docking::LAYOUT_VERSION,
|
||||
"docking": ui.docking,
|
||||
"showToolsets": ui.show_toolsets,
|
||||
"showPalettes": ui.show_palettes,
|
||||
})
|
||||
.to_string();
|
||||
if saved.len() > MAX_BYTES {
|
||||
return Err("panel layout exceeds 64 KiB".into());
|
||||
}
|
||||
Ok(saved)
|
||||
}
|
||||
|
||||
/// Validate the entire optional record before changing any UI field.
|
||||
pub fn restore(ui: &mut UiState, saved: &str) -> Result<(), String> {
|
||||
if saved.len() > MAX_BYTES {
|
||||
return Err("panel layout exceeds 64 KiB".into());
|
||||
}
|
||||
let value: Value = serde_json::from_str(saved).map_err(|error| error.to_string())?;
|
||||
if value.get("version").and_then(Value::as_u64) != Some(u64::from(docking::LAYOUT_VERSION)) {
|
||||
return Err("unsupported panel layout version".into());
|
||||
}
|
||||
let workspace: docking::Workspace =
|
||||
serde_json::from_value(value.get("docking").cloned().ok_or("missing panel layout")?).map_err(|error| error.to_string())?;
|
||||
docking::validate(&workspace)?;
|
||||
let tools = value.get("showToolsets").and_then(Value::as_bool).ok_or("invalid Tool Sets visibility")?;
|
||||
let palettes = value.get("showPalettes").and_then(Value::as_bool).ok_or("invalid palette visibility")?;
|
||||
ui.docking = workspace;
|
||||
ui.show_toolsets = tools;
|
||||
ui.show_palettes = palettes;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_ui_egui::{CadApp, Services};
|
||||
|
||||
#[test]
|
||||
fn floating_and_hidden_panels_survive_storage_without_replacing_drawing_state() {
|
||||
let mut source = CadApp::new(Session::default(), Services::default());
|
||||
source.run("ui.dock", json!({"operation":"float","panel":"toolSets","rect":[120,90,340,410]})).unwrap();
|
||||
source.run("ui.dock", json!({"operation":"close","panel":"properties"})).unwrap();
|
||||
let saved = save(&source.ui).unwrap();
|
||||
let mut destination = CadApp::new(Session::default(), Services::default());
|
||||
destination.session.start("line").unwrap();
|
||||
let before = destination.session.doc().unwrap().clone();
|
||||
restore(&mut destination.ui, &saved).unwrap();
|
||||
assert_eq!(destination.ui.docking, source.ui.docking);
|
||||
assert_eq!(destination.ui.show_toolsets, source.ui.show_toolsets);
|
||||
assert_eq!(destination.ui.show_palettes, source.ui.show_palettes);
|
||||
assert_eq!(destination.session.running.as_ref().map(|command| command.id.as_str()), Some("line"));
|
||||
assert_eq!(destination.session.doc().unwrap(), &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_or_unsupported_storage_is_rejected_atomically() {
|
||||
let mut ui = UiState::default();
|
||||
let before = serde_json::to_value(&ui).unwrap();
|
||||
let valid: Value = serde_json::from_str(&save(&ui).unwrap()).unwrap();
|
||||
for (field, bad) in [
|
||||
("version", json!(0)),
|
||||
("version", json!(docking::LAYOUT_VERSION + 1)),
|
||||
("docking", json!({})),
|
||||
("showToolsets", json!("false")),
|
||||
("showPalettes", Value::Null),
|
||||
] {
|
||||
let mut invalid = valid.clone();
|
||||
invalid[field] = bad;
|
||||
assert!(restore(&mut ui, &invalid.to_string()).is_err());
|
||||
assert_eq!(serde_json::to_value(&ui).unwrap(), before);
|
||||
}
|
||||
for invalid in ["not json".to_string(), " ".repeat(MAX_BYTES + 1)] {
|
||||
assert!(restore(&mut ui, &invalid).is_err());
|
||||
assert_eq!(serde_json::to_value(&ui).unwrap(), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storage_cannot_remove_the_document_view() {
|
||||
let mut ui = UiState::default();
|
||||
let before = serde_json::to_value(&ui).unwrap();
|
||||
let mut invalid = ui.clone();
|
||||
invalid.docking.layout.root = None;
|
||||
assert!(save(&invalid).is_err());
|
||||
let mut record: Value = serde_json::from_str(&save(&ui).unwrap()).unwrap();
|
||||
record["docking"] = serde_json::to_value(&invalid.docking).unwrap();
|
||||
assert!(restore(&mut ui, &record.to_string()).is_err());
|
||||
assert_eq!(serde_json::to_value(&ui).unwrap(), before);
|
||||
}
|
||||
}
|
||||
@@ -68,9 +68,8 @@ impl Inbox {
|
||||
}
|
||||
|
||||
/// Read the files dropped on the page this frame (eframe hands them over unread on the web).
|
||||
/// The list is taken, not cloned: a later pass or hidden tick must not read the same drop.
|
||||
pub fn take_dropped(&self, ctx: &egui::Context) {
|
||||
for f in ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files)) {
|
||||
for f in ctx.input(|i| i.raw.dropped_files.clone()) {
|
||||
if let Some(file) = f.web_file() {
|
||||
self.read(file.clone());
|
||||
}
|
||||
@@ -97,20 +96,6 @@ impl Inbox {
|
||||
});
|
||||
}
|
||||
|
||||
/// A file read elsewhere (sent by the page embedding CADCraft): opened on the next frame like
|
||||
/// a picked file, or refused when too large to open.
|
||||
pub fn receive(&self, name: String, bytes: Vec<u8>) {
|
||||
match CadApp::open_size_error(&name, bytes.len() as u64) {
|
||||
Some(e) => self.push(Err(e)),
|
||||
None => self.push(Ok((name, bytes))),
|
||||
}
|
||||
}
|
||||
|
||||
/// A file from elsewhere refused before it was read (`error` says why).
|
||||
pub fn refuse(&self, error: String) {
|
||||
self.push(Err(error));
|
||||
}
|
||||
|
||||
fn push(&self, upload: Upload) {
|
||||
let mut files = self.files.take();
|
||||
files.push(upload);
|
||||
|
||||
@@ -4,7 +4,6 @@ description = "CADCraft desktop app"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version = "1.95"
|
||||
publish = false
|
||||
default-run = "cadcraft"
|
||||
|
||||
@@ -15,54 +14,20 @@ workspace = true
|
||||
cadcraft-engine = { workspace = true }
|
||||
cadcraft-ui-egui = { workspace = true }
|
||||
cadcraft-io = { workspace = true }
|
||||
# For the shared per-session control-token helper (control_server.rs authentication).
|
||||
cadcraft-mcp = { workspace = true }
|
||||
eframe = { workspace = true, features = ["clipboard", "wgpu", "accesskit", "wayland", "x11", "links", "persistence"] }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links", "persistence"] }
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
# Reads eframe's app.ron to tell whether a window state was saved (main.rs `saved_window`).
|
||||
# Already in Cargo.lock through eframe; same version so nothing is built twice.
|
||||
ron = "0.12"
|
||||
|
||||
[build-dependencies]
|
||||
winresource = "0.1"
|
||||
|
||||
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
|
||||
winit = { version = "0.30", default-features = false, features = ["x11"] }
|
||||
# View ▸ Theme ▸ System: the desktop's light/dark choice from the XDG desktop portal
|
||||
# (desktop_theme.rs). Already in Cargo.lock through accesskit; pinned to that version so nothing
|
||||
# is built twice.
|
||||
zbus = { version = "=5.19.0", default-features = false, features = ["async-io", "blocking-api"] }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
# Floating panels open in their own OS windows on the desktop; the web build keeps them in the page.
|
||||
craft-ui = { workspace = true, features = ["native-windows"] }
|
||||
craft-update = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
sys-locale = "0.3"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# `title_bar::sync_movable`: AppKit's title-band drag leaves the window still under the row's
|
||||
# controls and under floating panels.
|
||||
craft-window = { workspace = true }
|
||||
muda = { version = "0.21", default-features = false }
|
||||
# View ▸ Accessibility ▸ Animation Effects ▸ Use System Setting: the system's Reduce motion
|
||||
# (system_animations.rs). Already in Cargo.lock through muda.
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSWorkspace", "NSAccessibility"] }
|
||||
# The same winit eframe uses (the storytold fork, patched in the workspace): its opt-in that turns
|
||||
# AppKit's terminate (Cmd+Q, Dock Quit) into a close request, so unsaved changes are asked about.
|
||||
winit = { version = "0.30", default-features = false }
|
||||
|
||||
# Rust HID backends: no proprietary SDK or C HID library.
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
hidapi = { version = "2.6.7", default-features = false, features = ["windows-native"] }
|
||||
# View ▸ Accessibility ▸ Animation Effects ▸ Use System Setting: Windows' Animation effects
|
||||
# setting (system_animations.rs). Already in Cargo.lock through wgpu's DirectX 12 backend.
|
||||
windows = { version = "0.62", features = ["UI_ViewManagement"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
hidapi = { version = "2.6.7", default-features = false, features = ["linux-native-basic-udev"] }
|
||||
|
||||
@@ -1,583 +1,64 @@
|
||||
//! Loopback JSON-lines control server: one request per line, one reply per line.
|
||||
//! This is the transport the MCP server (`cadcraft-cli mcp --connect`) wraps.
|
||||
//!
|
||||
//! The server only reads requests: a line that is not a JSON object with a string `method` (an
|
||||
//! HTTP request line, a stray `nc`, binary data) gets one error reply and the connection is
|
||||
//! closed, so nothing sent after it runs. Every resource a client can hold is bounded
|
||||
//! ([`Limits`]): simultaneous connections, request line length, queued requests, and how long a
|
||||
//! socket may sit idle or blocked on a write.
|
||||
|
||||
use std::io::{BufRead, BufReader, ErrorKind, Write};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{Receiver, SyncSender, TrySendError, sync_channel};
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
use cadcraft_ui_egui::ControlRequest;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Resource bounds for the control server.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Limits {
|
||||
/// Connections served at once; more get an error line and are closed.
|
||||
max_clients: usize,
|
||||
/// Longest request line in bytes (newline excluded); a longer one closes the connection.
|
||||
max_line: usize,
|
||||
/// Request-line bytes buffered at once across all connections; a request that would pass it
|
||||
/// is refused with an error reply.
|
||||
total_bytes: usize,
|
||||
/// Requests waiting for the app at once; more are answered `busy`.
|
||||
queue: usize,
|
||||
/// How long a connection may go without sending a byte before it is closed.
|
||||
idle_timeout: Duration,
|
||||
/// How long a reply write may block before the connection is closed.
|
||||
write_timeout: Duration,
|
||||
/// How long a request waits for the app's answer.
|
||||
reply_timeout: Duration,
|
||||
}
|
||||
|
||||
impl Default for Limits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_clients: 16,
|
||||
// Generous, so requests that carry whole drawings or images as base64 still fit.
|
||||
max_line: 256 * 1024 * 1024,
|
||||
// Room for one maximal request plus smaller ones, not `max_clients` maximal ones.
|
||||
total_bytes: 512 * 1024 * 1024,
|
||||
queue: 64,
|
||||
idle_timeout: Duration::from_secs(600),
|
||||
write_timeout: Duration::from_secs(30),
|
||||
reply_timeout: Duration::from_secs(60),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the loopback server with a fresh session token and a bounded request queue.
|
||||
/// Binding or token-issuance failure returns a disconnected receiver.
|
||||
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
match TcpListener::bind(("127.0.0.1", port)) {
|
||||
Ok(l) => {
|
||||
// Loopback TCP cannot tell local users apart, so issue an unguessable per-session
|
||||
// token in an owner-only file and require it on every request (CWE-306). A client
|
||||
// (the MCP bridge) reads it from the file, which needs the desktop user's filesystem
|
||||
// access; another local account cannot.
|
||||
let token = match cadcraft_mcp::control_token::issue(port) {
|
||||
Ok(t) => Arc::new(t),
|
||||
Err(e) => {
|
||||
log::error!("control server not started: could not write the control token: {e}");
|
||||
return sync_channel::<ControlRequest>(0).1;
|
||||
}
|
||||
};
|
||||
if let Ok(path) = cadcraft_mcp::control_token::token_path(port) {
|
||||
log::info!("control server listening on 127.0.0.1:{port} (token in {})", path.display());
|
||||
}
|
||||
serve_listener(l, Limits::default(), ctx, token)
|
||||
}
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
log::error!("control server failed to bind 127.0.0.1:{port}: {e}");
|
||||
sync_channel::<ControlRequest>(0).1
|
||||
return rx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How long the accepting thread waits after a failed accept. Accepting can keep failing, for
|
||||
/// example while the process has no file descriptors left, and retrying at once would keep a
|
||||
/// core busy until it recovers.
|
||||
const ACCEPT_RETRY: Duration = Duration::from_millis(100);
|
||||
|
||||
/// The connections `incoming` accepts, calling `wait` after each failed accept.
|
||||
fn accepted<T>(incoming: impl Iterator<Item = std::io::Result<T>>, mut wait: impl FnMut()) -> impl Iterator<Item = T> {
|
||||
incoming.filter_map(move |stream| stream.map_err(|_| wait()).ok())
|
||||
}
|
||||
|
||||
/// Accept connections on `listener` under `limits`; requests arrive on the returned receiver.
|
||||
fn serve_listener(listener: TcpListener, limits: Limits, ctx: egui::Context, token: Arc<String>) -> Receiver<ControlRequest> {
|
||||
let (tx, rx) = sync_channel::<ControlRequest>(limits.queue.max(1));
|
||||
std::thread::spawn(move || accept_loop(listener, tx, ctx, limits, token));
|
||||
};
|
||||
log::info!("control server listening on 127.0.0.1:{port}");
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
std::thread::spawn(move || serve(stream, tx, ctx));
|
||||
}
|
||||
});
|
||||
rx
|
||||
}
|
||||
|
||||
/// A connection's place among [`Limits::max_clients`], given back when the connection ends (also
|
||||
/// when its thread can't be started, or panics).
|
||||
struct Slot(Arc<AtomicUsize>);
|
||||
|
||||
impl Drop for Slot {
|
||||
fn drop(&mut self) {
|
||||
self.0.fetch_sub(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
/// Accept clients within the connection limit and give each worker the same session token.
|
||||
fn accept_loop(listener: TcpListener, tx: SyncSender<ControlRequest>, ctx: egui::Context, limits: Limits, token: Arc<String>) {
|
||||
let open = Arc::new(AtomicUsize::new(0));
|
||||
let budget = Budget::new(limits.total_bytes);
|
||||
for mut stream in accepted(listener.incoming(), || std::thread::sleep(ACCEPT_RETRY)) {
|
||||
let _ = stream.set_write_timeout(Some(limits.write_timeout));
|
||||
if open.fetch_add(1, Ordering::SeqCst) >= limits.max_clients {
|
||||
open.fetch_sub(1, Ordering::SeqCst);
|
||||
let _ = writeln!(stream, "{}", json!({"ok": false, "error": "too many control connections"}));
|
||||
continue;
|
||||
}
|
||||
let slot = Slot(Arc::clone(&open));
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
let token = Arc::clone(&token);
|
||||
let budget = budget.clone();
|
||||
let _ = std::thread::Builder::new().spawn(move || {
|
||||
let _slot = slot;
|
||||
serve(stream, &tx, &ctx, limits, &token, budget);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// A total-bytes budget for request lines buffered across all connections, so
|
||||
/// [`Limits::max_clients`] peers can't each hold a [`Limits::max_line`] line at once (#1925).
|
||||
#[derive(Clone, Debug)]
|
||||
struct Budget {
|
||||
used: Arc<AtomicUsize>,
|
||||
total: usize,
|
||||
}
|
||||
|
||||
impl Budget {
|
||||
fn new(total: usize) -> Self {
|
||||
Self { used: Arc::new(AtomicUsize::new(0)), total }
|
||||
}
|
||||
|
||||
/// Reserve `n` more bytes, or `false` (nothing reserved) when that would pass the total.
|
||||
fn reserve(&self, n: usize) -> bool {
|
||||
self.used.try_update(Ordering::SeqCst, Ordering::SeqCst, |u| u.checked_add(n).filter(|&t| t <= self.total)).is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes a connection holds from the [`Budget`] for its current request line; given back when the
|
||||
/// next line starts or the connection ends.
|
||||
struct Held {
|
||||
budget: Budget,
|
||||
bytes: usize,
|
||||
}
|
||||
|
||||
impl Held {
|
||||
fn new(budget: Budget) -> Self {
|
||||
Self { budget, bytes: 0 }
|
||||
}
|
||||
|
||||
fn release(&mut self) {
|
||||
self.budget.used.fetch_sub(self.bytes, Ordering::SeqCst);
|
||||
self.bytes = 0;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Held {
|
||||
fn drop(&mut self) {
|
||||
self.release();
|
||||
}
|
||||
}
|
||||
|
||||
/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped), charging
|
||||
/// the bytes buffered to `held`. `Ok(false)` at end of stream; `Err` when the line is too long,
|
||||
/// the shared budget is used up, or the read fails.
|
||||
fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize, held: &mut Held) -> Result<bool, String> {
|
||||
buf.clear();
|
||||
held.release();
|
||||
// A line is at most `max` bytes plus its newline; never buffer more than that.
|
||||
let cap = max.saturating_add(1);
|
||||
loop {
|
||||
let chunk = match reader.fill_buf() {
|
||||
Ok(c) => c,
|
||||
Err(e) if e.kind() == ErrorKind::Interrupted => continue,
|
||||
Err(e) => {
|
||||
return Err(match e.kind() {
|
||||
// The idle timeout ran out (Windows reports it as TimedOut, Unix as WouldBlock).
|
||||
ErrorKind::TimedOut | ErrorKind::WouldBlock => "request line not completed in time; closing the connection".to_string(),
|
||||
_ => e.to_string(),
|
||||
});
|
||||
}
|
||||
};
|
||||
if chunk.is_empty() {
|
||||
// End of stream: nothing read, or a last line without a newline.
|
||||
return Ok(!buf.is_empty());
|
||||
}
|
||||
let line_end = chunk.iter().position(|&b| b == b'\n').map(|i| i + 1);
|
||||
let n = line_end.unwrap_or(chunk.len()).min(cap.saturating_sub(buf.len()));
|
||||
buf.extend_from_slice(chunk.get(..n).unwrap_or(chunk));
|
||||
reader.consume(n);
|
||||
// The chunk is already buffered (a few KiB), so it is added before it is charged: a
|
||||
// refusal still leaves `buf` non-empty and the connection gets an error reply.
|
||||
if !held.budget.reserve(n) {
|
||||
return Err("too many large requests in flight; try again later".to_string());
|
||||
}
|
||||
held.bytes += n;
|
||||
if buf.last() == Some(&b'\n') {
|
||||
buf.pop();
|
||||
if buf.last() == Some(&b'\r') {
|
||||
buf.pop();
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
if buf.len() > max {
|
||||
return Err(format!("request line longer than {max} bytes; closing the connection"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One request line, parsed.
|
||||
enum Line {
|
||||
Request {
|
||||
id: Value,
|
||||
method: String,
|
||||
params: Value,
|
||||
token: Option<String>,
|
||||
},
|
||||
Blank,
|
||||
/// Not a request: reply with this error and close the connection.
|
||||
Reject(String),
|
||||
}
|
||||
|
||||
/// Parse a UTF-8 JSON request and its optional token, distinguishing blank lines from rejected input.
|
||||
fn parse_line(bytes: &[u8]) -> Line {
|
||||
let Ok(text) = std::str::from_utf8(bytes) else {
|
||||
return Line::Reject("not a control request (invalid UTF-8); closing the connection".into());
|
||||
};
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return Line::Blank;
|
||||
}
|
||||
if is_http_request_line(text) {
|
||||
return Line::Reject("HTTP is not served here: this port takes JSON lines; closing the connection".into());
|
||||
}
|
||||
let msg = match serde_json::from_str::<Value>(text) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return Line::Reject(format!("bad JSON ({e}); expected one JSON request object per line; closing the connection")),
|
||||
};
|
||||
let Some(obj) = msg.as_object() else {
|
||||
return Line::Reject("not a control request (expected a JSON object); closing the connection".into());
|
||||
};
|
||||
let Some(method) = obj.get("method").and_then(Value::as_str) else {
|
||||
return Line::Reject("not a control request (missing string `method`); closing the connection".into());
|
||||
};
|
||||
Line::Request {
|
||||
id: obj.get("id").cloned().unwrap_or(Value::Null),
|
||||
method: method.to_string(),
|
||||
params: obj.get("params").cloned().unwrap_or(json!({})),
|
||||
token: obj.get("token").and_then(Value::as_str).map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
/// An HTTP/1 request line (`POST / HTTP/1.1`) or the HTTP/2 preface (`PRI * HTTP/2.0`).
|
||||
fn is_http_request_line(text: &str) -> bool {
|
||||
const METHODS: [&str; 10] = ["GET", "HEAD", "POST", "PUT", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PATCH", "PRI"];
|
||||
text.split_once(' ').is_some_and(|(method, _)| METHODS.contains(&method))
|
||||
|| text.rsplit_once(' ').is_some_and(|(_, version)| version.starts_with("HTTP/"))
|
||||
}
|
||||
|
||||
/// Serve bounded JSON lines, authenticating each request before enqueueing it for the UI.
|
||||
/// Invalid credentials close the connection; queue and reply limits report whether a request ran.
|
||||
fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context, limits: Limits, token: &str, budget: Budget) {
|
||||
if stream.set_read_timeout(Some(limits.idle_timeout)).is_err() || stream.set_write_timeout(Some(limits.write_timeout)).is_err() {
|
||||
return;
|
||||
}
|
||||
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
let mut reader = BufReader::new(read);
|
||||
let mut buf = Vec::new();
|
||||
let mut held = Held::new(budget);
|
||||
loop {
|
||||
let reject = match read_bounded_line(&mut reader, &mut buf, limits.max_line, &mut held) {
|
||||
Ok(false) => break,
|
||||
// Nothing of a line arrived (idle timeout, or the peer is gone): close without a word.
|
||||
// A line nobody asked for would be read as the reply to the client's next request.
|
||||
Err(_) if buf.is_empty() => break,
|
||||
Err(e) => e,
|
||||
Ok(true) => match parse_line(&buf) {
|
||||
Line::Blank => continue,
|
||||
Line::Reject(e) => e,
|
||||
Line::Request { id, method, params, token: given } => {
|
||||
// Authenticate the peer before dispatching: loopback TCP does not identify the
|
||||
// OS user, so only a client holding the owner-only session token may drive the
|
||||
// desktop user's commands (CWE-306).
|
||||
if !given.map(|g| cadcraft_mcp::control_token::matches(token, &g)).unwrap_or(false) {
|
||||
let mut r = json!({"ok": false, "error": "unauthorized: missing or invalid control token", "state": "not-run"});
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
let _ = writeln!(out, "{r}");
|
||||
break;
|
||||
}
|
||||
let (req, pending) = ControlRequest::new(method, params);
|
||||
let mut r = match tx.try_send(req) {
|
||||
Ok(()) => {
|
||||
ctx.request_repaint();
|
||||
// On timeout the request is cancelled if the app hasn't started it, and
|
||||
// the reply's `state` says whether it can still have run.
|
||||
pending.wait(limits.reply_timeout)
|
||||
}
|
||||
Err(TrySendError::Full(_)) => json!({"ok": false, "error": "busy: too many queued requests", "state": "not-run"}),
|
||||
Err(TrySendError::Disconnected(_)) => break,
|
||||
};
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
if writeln!(out, "{r}").is_err() {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
for line in BufReader::new(read).lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let reply = match serde_json::from_str::<Value>(&line) {
|
||||
Ok(msg) => {
|
||||
let id = msg.get("id").cloned().unwrap_or(Value::Null);
|
||||
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
||||
let (req, pending) = ControlRequest::new(method, params);
|
||||
if tx.send(req).is_err() {
|
||||
break;
|
||||
}
|
||||
},
|
||||
ctx.request_repaint();
|
||||
// On timeout the request is cancelled if the app hasn't started it, and the reply's
|
||||
// `state` says whether it can still have run.
|
||||
let mut r = pending.wait(Duration::from_secs(60));
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
r
|
||||
}
|
||||
Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}),
|
||||
};
|
||||
// The connection closes either way; a peer that can't read the reason loses nothing.
|
||||
let _ = writeln!(out, "{}", json!({"ok": false, "error": reject}));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Read;
|
||||
|
||||
/// A failed accept is followed by a wait before the next one, so a listener that keeps
|
||||
/// failing does not spin, and the connections around the failures still come through.
|
||||
#[test]
|
||||
fn a_failed_accept_waits_before_the_next() {
|
||||
let failed = || Err(std::io::Error::other("no file descriptors left"));
|
||||
let incoming = vec![failed(), Ok(1), failed(), failed(), Ok(2)];
|
||||
let mut waits = 0;
|
||||
let connections: Vec<i32> = super::accepted(incoming.into_iter(), || waits += 1).collect();
|
||||
assert_eq!(connections, [1, 2]);
|
||||
assert_eq!(waits, 3, "one wait per failed accept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_are_read_up_to_the_limit() {
|
||||
let mut buf = Vec::new();
|
||||
let mut input: &[u8] = b"aaaaaaaaaa\nbb\r\ncc";
|
||||
assert_eq!(read_bounded_line(&mut input, &mut buf, 10, &mut held()), Ok(true));
|
||||
assert_eq!(buf, b"aaaaaaaaaa", "a line exactly at the limit is accepted");
|
||||
assert_eq!(read_bounded_line(&mut input, &mut buf, 10, &mut held()), Ok(true));
|
||||
assert_eq!(buf, b"bb", "CRLF is stripped");
|
||||
assert_eq!(read_bounded_line(&mut input, &mut buf, 10, &mut held()), Ok(true));
|
||||
assert_eq!(buf, b"cc", "a last line without a newline is still read");
|
||||
assert_eq!(read_bounded_line(&mut input, &mut buf, 10, &mut held()), Ok(false), "then end of stream");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_overlong_line_is_refused_without_reading_the_rest() {
|
||||
let mut buf = Vec::new();
|
||||
let long = [b'x'; 100];
|
||||
let mut input: &[u8] = &long;
|
||||
assert!(read_bounded_line(&mut input, &mut buf, 10, &mut held()).is_err());
|
||||
assert!(buf.len() <= 11, "at most limit + 1 bytes are buffered, got {}", buf.len());
|
||||
assert_eq!(input.len(), 100 - 11, "the rest of the line is left unread");
|
||||
}
|
||||
|
||||
fn held() -> Held {
|
||||
Held::new(Budget::new(usize::MAX))
|
||||
}
|
||||
|
||||
fn short() -> Limits {
|
||||
Limits {
|
||||
queue: 1,
|
||||
idle_timeout: Duration::from_millis(300),
|
||||
write_timeout: Duration::from_millis(300),
|
||||
reply_timeout: Duration::from_millis(300),
|
||||
..Limits::default()
|
||||
if writeln!(out, "{reply}").is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// A fixed token for tests; real tokens are random (see `cadcraft_mcp::control_token`).
|
||||
const TEST_TOKEN: &str = "test-control-token";
|
||||
|
||||
/// The control server on a loopback port under `limits`; requests arrive on the receiver.
|
||||
fn serve_with(limits: Limits) -> (std::net::SocketAddr, Receiver<ControlRequest>) {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
(addr, serve_listener(listener, limits, egui::Context::default(), Arc::new(TEST_TOKEN.to_string())))
|
||||
}
|
||||
|
||||
/// The control server on a loopback port, with a stand-in UI that echoes each method.
|
||||
fn serve_echo() -> std::net::SocketAddr {
|
||||
let (addr, rx) = serve_with(Limits::default());
|
||||
std::thread::spawn(move || {
|
||||
for req in rx {
|
||||
let _ = req.reply.send(json!({"ok": true, "method": req.method}));
|
||||
}
|
||||
});
|
||||
addr
|
||||
}
|
||||
|
||||
/// A request line carrying the test token, so it authenticates.
|
||||
fn req_line(id: i64, method: &str) -> String {
|
||||
json!({"id": id, "method": method, "token": TEST_TOKEN}).to_string() + "\n"
|
||||
}
|
||||
|
||||
/// Send `input` on a new connection and read reply lines until the server closes it.
|
||||
fn exchange(addr: std::net::SocketAddr, input: &[u8]) -> Vec<Value> {
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
c.write_all(input).unwrap();
|
||||
let mut replies = Vec::new();
|
||||
for line in BufReader::new(c).lines() {
|
||||
// A read timeout here means the server kept the connection open.
|
||||
let line = line.unwrap();
|
||||
replies.push(serde_json::from_str(&line).unwrap());
|
||||
}
|
||||
replies
|
||||
}
|
||||
|
||||
/// A browser can POST to the port: its request line closes the connection before the JSON
|
||||
/// line in its body is read, so nothing is dispatched.
|
||||
#[test]
|
||||
fn an_http_request_closes_the_connection_before_its_body_is_dispatched() {
|
||||
let (addr, rx) = serve_with(short());
|
||||
let http = b"POST / HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: text/plain\r\n\r\n\
|
||||
{\"id\":1,\"method\":\"cmdline.input\",\"params\":{\"text\":\"circle 0,0 5\"}}\n";
|
||||
let replies = exchange(addr, http);
|
||||
assert_eq!(replies.len(), 1, "{replies:?}");
|
||||
assert_eq!(replies[0]["ok"], false);
|
||||
assert!(replies[0]["error"].as_str().unwrap().contains("closing the connection"), "{replies:?}");
|
||||
assert!(rx.try_recv().is_err(), "the body was dispatched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_that_is_not_a_request_closes_the_connection() {
|
||||
for junk in [&b"hello\n"[..], b"[1,2]\n", b"{\"id\":1}\n", b"{\"method\":5}\n", b"\xff\xfe\n", b"GET /x HTTP/1.1\n"] {
|
||||
let addr = serve_echo();
|
||||
let mut input = junk.to_vec();
|
||||
input.extend_from_slice(b"{\"id\":2,\"method\":\"ping\"}\n");
|
||||
let replies = exchange(addr, &input);
|
||||
assert_eq!(replies.len(), 1, "{replies:?}");
|
||||
assert_eq!(replies[0]["ok"], false);
|
||||
}
|
||||
assert!(is_http_request_line("OPTIONS * HTTP/1.1"));
|
||||
assert!(is_http_request_line("PRI * HTTP/2.0"));
|
||||
assert!(!is_http_request_line("{\"method\":\"GET /\"}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_idle_connection_is_closed() {
|
||||
let (addr, _rx) = serve_with(short());
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
let t = std::time::Instant::now();
|
||||
let mut got = Vec::new();
|
||||
c.read_to_end(&mut got).unwrap();
|
||||
assert!(got.is_empty(), "{}", String::from_utf8_lossy(&got));
|
||||
assert!(t.elapsed() < Duration::from_secs(4));
|
||||
}
|
||||
|
||||
/// Authenticated requests rejected by a full queue retain their IDs and report that they did not run.
|
||||
#[test]
|
||||
fn a_full_queue_answers_busy() {
|
||||
let (addr, rx) = serve_with(short());
|
||||
// No UI drains the one-place queue: the first request fills it, the second finds it full.
|
||||
let first = std::thread::spawn(move || exchange(addr, req_line(1, "m").as_bytes()));
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(4);
|
||||
let busy = loop {
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
c.write_all(req_line(2, "m").as_bytes()).unwrap();
|
||||
let mut line = String::new();
|
||||
BufReader::new(c).read_line(&mut line).unwrap();
|
||||
let v: Value = serde_json::from_str(&line).unwrap();
|
||||
if v["error"].as_str().is_some_and(|e| e.starts_with("busy")) || std::time::Instant::now() > deadline {
|
||||
break v;
|
||||
}
|
||||
};
|
||||
assert_eq!((busy["id"].as_i64(), busy["state"].as_str()), (Some(2), Some("not-run")), "{busy}");
|
||||
assert_eq!(first.join().unwrap()[0]["state"], "not-run");
|
||||
drop(rx);
|
||||
}
|
||||
|
||||
/// Connections can't together buffer more request-line bytes than the budget: a second large
|
||||
/// request is refused with an error reply while the first still holds its bytes (#1925).
|
||||
#[test]
|
||||
fn requests_past_the_total_budget_are_refused() {
|
||||
let (addr, _rx) = serve_with(Limits { max_line: 1000, total_bytes: 300, idle_timeout: Duration::from_secs(5), ..short() });
|
||||
// The first peer sends 200 bytes of an unfinished line and keeps the connection open.
|
||||
let mut first = TcpStream::connect(addr).unwrap();
|
||||
first.write_all(&[b'a'; 200]).unwrap();
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
// The second would take 200 more, past the 300 byte total.
|
||||
let mut second = TcpStream::connect(addr).unwrap();
|
||||
second.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
second.write_all(&[b'b'; 200]).unwrap();
|
||||
let mut line = String::new();
|
||||
BufReader::new(second).read_line(&mut line).unwrap();
|
||||
let v: Value = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!(v["ok"], json!(false));
|
||||
assert!(v["error"].as_str().unwrap_or("").contains("too many large requests"), "{line}");
|
||||
drop(first);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_budget_is_given_back_when_the_connection_ends() {
|
||||
let budget = Budget::new(100);
|
||||
{
|
||||
let mut h = Held::new(budget.clone());
|
||||
let mut buf = Vec::new();
|
||||
let mut input: &[u8] = &[b'x'; 80];
|
||||
assert_eq!(read_bounded_line(&mut input, &mut buf, 1000, &mut h), Ok(true));
|
||||
assert!(!budget.reserve(30), "80 of 100 bytes are held");
|
||||
}
|
||||
assert!(budget.reserve(100), "all given back on drop");
|
||||
}
|
||||
|
||||
/// Replies on one authenticated connection preserve the ID of each request.
|
||||
#[test]
|
||||
fn requests_are_answered_with_their_ids() {
|
||||
let addr = serve_echo();
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
let input = format!("{}\n{}", req_line(3, "ping").trim_end(), req_line(4, "pong"));
|
||||
c.write_all(input.as_bytes()).unwrap();
|
||||
let mut r = BufReader::new(c);
|
||||
for (id, method) in [(3, "ping"), (4, "pong")] {
|
||||
let mut line = String::new();
|
||||
r.read_line(&mut line).unwrap();
|
||||
let v: Value = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!((v["id"].as_i64(), v["method"].as_str()), (Some(id), Some(method)), "{line}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Missing and incorrect session tokens are rejected before dispatch.
|
||||
#[test]
|
||||
fn a_request_without_the_token_is_unauthorized() {
|
||||
// A local peer that does not hold the session token cannot drive the app (CWE-306).
|
||||
let addr = serve_echo();
|
||||
let replies = exchange(addr, b"{\"id\":9,\"method\":\"ping\"}\n");
|
||||
assert_eq!(replies.len(), 1, "{replies:?}");
|
||||
assert_eq!(replies[0]["ok"], false);
|
||||
assert!(replies[0]["error"].as_str().unwrap().contains("unauthorized"), "{replies:?}");
|
||||
// A wrong token is also refused.
|
||||
let wrong = exchange(addr, b"{\"id\":9,\"method\":\"ping\",\"token\":\"nope\"}\n");
|
||||
assert_eq!(wrong[0]["ok"], false);
|
||||
assert!(wrong[0]["error"].as_str().unwrap().contains("unauthorized"), "{wrong:?}");
|
||||
}
|
||||
|
||||
/// A request carrying the session token reaches the control dispatcher.
|
||||
#[test]
|
||||
fn a_request_with_the_token_is_dispatched() {
|
||||
let addr = serve_echo();
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
c.write_all(req_line(10, "ping").as_bytes()).unwrap();
|
||||
let mut line = String::new();
|
||||
BufReader::new(c).read_line(&mut line).unwrap();
|
||||
let v: Value = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!((v["ok"].as_bool(), v["method"].as_str()), (Some(true), Some("ping")), "{line}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_connections_are_refused() {
|
||||
let addr = serve_echo();
|
||||
// Idle connections are accepted in order and held open.
|
||||
let held: Vec<TcpStream> = (0..Limits::default().max_clients).map(|_| TcpStream::connect(addr).unwrap()).collect();
|
||||
let mut extra = TcpStream::connect(addr).unwrap();
|
||||
extra.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
let mut line = String::new();
|
||||
BufReader::new(&mut extra).read_line(&mut line).unwrap();
|
||||
assert!(line.contains("too many control connections"), "{line}");
|
||||
drop(held);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
//! The desktop's own light/dark choice, for View ▸ Theme ▸ System.
|
||||
//!
|
||||
//! winit reports no system theme on Linux and the BSDs (`RawInput::system_theme` stays `None`),
|
||||
//! so the System choice fell back to dark whatever the desktop said. This asks the desktop itself:
|
||||
//! the XDG Desktop Portal's Settings interface, which answers on GNOME, KDE and XFCE alike, over
|
||||
//! X11 and Wayland alike. The answer is handed to egui as the system theme
|
||||
//! (`App::raw_input_hook`), so the app and egui's own widgets follow it as they follow macOS and
|
||||
//! Windows.
|
||||
//!
|
||||
//! Ported from PdfCraft (storytold/pdfcraft#881).
|
||||
|
||||
use std::sync::mpsc::{Receiver, TryRecvError, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
/// How often the watcher re-reads the desktop's answer. The portal also publishes a change
|
||||
/// signal, but subscribing needs a connection kept alive for the life of the window; one round
|
||||
/// trip every few seconds is cheap and reacts soon enough after a theme switch.
|
||||
const POLL: Duration = Duration::from_secs(4);
|
||||
|
||||
/// Watches the desktop and reports its choice when it changes.
|
||||
#[derive(Default)]
|
||||
pub struct DesktopTheme {
|
||||
/// `None` when no watcher thread could be started (or on a platform where winit answers).
|
||||
changes: Option<Receiver<Option<egui::Theme>>>,
|
||||
/// The desktop's last answer.
|
||||
current: Option<egui::Theme>,
|
||||
}
|
||||
|
||||
impl DesktopTheme {
|
||||
/// Start watching. The first answer is sent straight away; `wake` is called after each
|
||||
/// answer, so an idle window repaints and picks it up.
|
||||
pub fn start(wake: impl Fn() + Send + 'static) -> Self {
|
||||
if !ASKS_THE_DESKTOP {
|
||||
return Self::default();
|
||||
}
|
||||
let (tx, rx) = channel();
|
||||
let started = std::thread::Builder::new().name("cadcraft-desktop-theme".into()).spawn(move || {
|
||||
let mut last = read();
|
||||
if tx.send(last).is_err() {
|
||||
return;
|
||||
}
|
||||
wake();
|
||||
loop {
|
||||
std::thread::sleep(POLL);
|
||||
let now = read();
|
||||
if now != last {
|
||||
last = now;
|
||||
if tx.send(now).is_err() {
|
||||
return;
|
||||
}
|
||||
wake();
|
||||
}
|
||||
}
|
||||
});
|
||||
Self { changes: started.ok().map(|_| rx), current: None }
|
||||
}
|
||||
|
||||
/// The desktop's choice, `None` when it states none or there is nobody to ask (no session
|
||||
/// bus, no portal). Polled once a frame; a desktop that stops answering keeps the last answer.
|
||||
pub fn current(&mut self) -> Option<egui::Theme> {
|
||||
if let Some(changes) = &self.changes {
|
||||
loop {
|
||||
match changes.try_recv() {
|
||||
Ok(Some(theme)) => self.current = Some(theme),
|
||||
Ok(None) => {}
|
||||
Err(TryRecvError::Empty) | Err(TryRecvError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
self.current
|
||||
}
|
||||
}
|
||||
|
||||
/// Where winit has no answer and the portal does.
|
||||
const ASKS_THE_DESKTOP: bool = cfg!(all(unix, not(target_os = "macos")));
|
||||
|
||||
/// Ask the portal once. D-Bus calls block, so this belongs off the UI thread: the watcher's
|
||||
/// thread is its only caller.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn read() -> Option<egui::Theme> {
|
||||
const PORTAL: &str = "org.freedesktop.portal.Desktop";
|
||||
const PATH: &str = "/org/freedesktop/portal/desktop";
|
||||
const SETTINGS: &str = "org.freedesktop.portal.Settings";
|
||||
// The standard key first (GNOME and KDE answer it: 0 no preference, 1 dark, 2 light); then
|
||||
// GNOME's own schema, which XFCE's portal proxies without the standard key.
|
||||
const KEYS: [(&str, &str); 2] = [("org.freedesktop.appearance", "color-scheme"), ("org.gnome.desktop.interface", "color-scheme")];
|
||||
let connection = zbus::blocking::Connection::session().ok()?;
|
||||
for (namespace, key) in KEYS {
|
||||
// `ReadOne` (Settings version 2) answers the value; older portals only have `Read`, which
|
||||
// wraps it in one more variant. `parse` unwraps either.
|
||||
let reply = connection
|
||||
.call_method(Some(PORTAL), PATH, Some(SETTINGS), "ReadOne", &(namespace, key))
|
||||
.or_else(|_| connection.call_method(Some(PORTAL), PATH, Some(SETTINGS), "Read", &(namespace, key)));
|
||||
let Ok(reply) = reply else { continue };
|
||||
let Ok((answer,)) = reply.body().deserialize::<(zbus::zvariant::OwnedValue,)>() else { continue };
|
||||
if let Some(scheme) = parse(&answer, 0) {
|
||||
return scheme;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The portal's `color-scheme` answer: `Some(choice)` when it read as an answer (`Some(None)`
|
||||
/// for "no preference"), `None` when it didn't. Variants are unwrapped a few levels deep.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn parse(value: &zbus::zvariant::Value<'_>, depth: u8) -> Option<Option<egui::Theme>> {
|
||||
use zbus::zvariant::Value;
|
||||
match value {
|
||||
Value::U32(1) => Some(Some(egui::Theme::Dark)),
|
||||
Value::U32(2) => Some(Some(egui::Theme::Light)),
|
||||
Value::U32(_) => Some(None),
|
||||
Value::Str(s) => Some(match s.as_str() {
|
||||
"prefer-dark" => Some(egui::Theme::Dark),
|
||||
"prefer-light" => Some(egui::Theme::Light),
|
||||
_ => None,
|
||||
}),
|
||||
Value::Value(inner) if depth < 4 => parse(inner, depth + 1),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(all(unix, not(target_os = "macos"))))]
|
||||
fn read() -> Option<egui::Theme> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The standard key answers a number, GNOME's schema a string; either may come wrapped in
|
||||
/// variants (the older `Read`). Only "dark" and "light" are a choice.
|
||||
#[test]
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn the_portal_answer_reads_as_a_choice_or_as_none() {
|
||||
use zbus::zvariant::Value;
|
||||
assert_eq!(parse(&Value::U32(1), 0), Some(Some(egui::Theme::Dark)));
|
||||
assert_eq!(parse(&Value::U32(2), 0), Some(Some(egui::Theme::Light)));
|
||||
assert_eq!(parse(&Value::U32(0), 0), Some(None));
|
||||
assert_eq!(parse(&Value::U32(7), 0), Some(None));
|
||||
assert_eq!(parse(&Value::from("prefer-dark"), 0), Some(Some(egui::Theme::Dark)));
|
||||
assert_eq!(parse(&Value::from("prefer-light"), 0), Some(Some(egui::Theme::Light)));
|
||||
assert_eq!(parse(&Value::from("default"), 0), Some(None));
|
||||
let wrapped = Value::Value(Box::new(Value::Value(Box::new(Value::U32(1)))));
|
||||
assert_eq!(parse(&wrapped, 0), Some(Some(egui::Theme::Dark)));
|
||||
assert_eq!(parse(&Value::Bool(true), 0), None);
|
||||
let mut deep = Value::U32(1);
|
||||
for _ in 0..10 {
|
||||
deep = Value::Value(Box::new(deep));
|
||||
}
|
||||
assert_eq!(parse(&deep, 0), None, "nesting is bounded");
|
||||
}
|
||||
|
||||
/// A watcher that was never started is inert rather than a panic.
|
||||
#[test]
|
||||
fn a_watcher_without_a_thread_is_inert() {
|
||||
assert_eq!(DesktopTheme::default().current(), None);
|
||||
}
|
||||
|
||||
/// Answers arrive as changes; "no preference" or silence keeps the last real answer.
|
||||
#[test]
|
||||
fn the_last_answer_stands_until_a_new_one() {
|
||||
let (tx, rx) = channel();
|
||||
let mut watcher = DesktopTheme { changes: Some(rx), current: None };
|
||||
assert_eq!(watcher.current(), None);
|
||||
tx.send(Some(egui::Theme::Light)).unwrap();
|
||||
assert_eq!(watcher.current(), Some(egui::Theme::Light));
|
||||
tx.send(Some(egui::Theme::Dark)).unwrap();
|
||||
tx.send(None).unwrap();
|
||||
assert_eq!(watcher.current(), Some(egui::Theme::Dark));
|
||||
drop(tx);
|
||||
assert_eq!(watcher.current(), Some(egui::Theme::Dark));
|
||||
}
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
//! The `CADCRAFT_*` variables of the desktop app, read once at startup into [`Env`] and passed on
|
||||
//! from there; no library crate reads them. `--help` lists them from [`VARS`]. `--control`'s
|
||||
//! fallback, `CADCRAFT_CONTROL_PORT`, is clap's (see `launch_args`).
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::fmt::Write as _;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use cadcraft_ui_egui::switch::Switch;
|
||||
|
||||
const CONFIG_DIR: &str = "CADCRAFT_CONFIG_DIR";
|
||||
const LOCALE: &str = "CADCRAFT_LOCALE";
|
||||
const VSYNC: &str = "CADCRAFT_VSYNC";
|
||||
const WAYLAND: &str = "CADCRAFT_WAYLAND";
|
||||
const NO_NATIVE_MENU: &str = "CADCRAFT_NO_NATIVE_MENU";
|
||||
const SYSTEM_CURSOR: &str = "CADCRAFT_SYSTEM_CURSOR";
|
||||
const FONTALT: &str = "CADCRAFT_FONTALT";
|
||||
const FONTFALLBACK: &str = "CADCRAFT_FONTFALLBACK";
|
||||
|
||||
/// Every variable [`Env`] reads, with its `--help` line.
|
||||
pub(crate) const VARS: &[(&str, &str)] = &[
|
||||
(CONFIG_DIR, "the settings folder (logs, autosave, workspace, plotters); empty means no settings folder"),
|
||||
(LOCALE, "the interface language, instead of the system's"),
|
||||
(VSYNC, "1 presents with vsync, 0 without it (default: without on Linux and BSD)"),
|
||||
(WAYLAND, "1 keeps the native Wayland backend instead of XWayland; 0 or unset is off"),
|
||||
(NO_NATIVE_MENU, "macOS: 1 keeps the menus inside the window; 0 or unset is off"),
|
||||
(SYSTEM_CURSOR, "1 uses the system cursor instead of the drawn crosshair; 0 or unset is off"),
|
||||
(FONTALT, "the font that replaces a missing one"),
|
||||
(FONTFALLBACK, "the fonts tried, in order, for characters a font lacks"),
|
||||
];
|
||||
|
||||
/// The process environment, read once at startup. A blank variable counts as unset, except
|
||||
/// `CADCRAFT_CONFIG_DIR`.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub(crate) struct Env {
|
||||
/// The settings folder (logs, autosave, workspace, plotters): `CADCRAFT_CONFIG_DIR`; else, when
|
||||
/// that is unset, the platform's (`cadcraft_engine::settings_dir::platform`). `None`: no
|
||||
/// folder, because the variable is empty or blank (the way to run without one) or the
|
||||
/// platform has none.
|
||||
pub(crate) config_dir: Option<PathBuf>,
|
||||
/// The interface language, instead of the system's.
|
||||
pub(crate) locale: Option<String>,
|
||||
/// Present with vsync (default: off on Linux and BSD, on elsewhere).
|
||||
pub(crate) vsync: Switch,
|
||||
/// Keep the native Wayland backend instead of XWayland.
|
||||
#[cfg_attr(not(all(unix, not(target_os = "macos"))), allow(dead_code))]
|
||||
pub(crate) wayland: Switch,
|
||||
/// macOS: keep the menus inside the window.
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
pub(crate) no_native_menu: Switch,
|
||||
/// Use the system cursor instead of the drawn crosshair.
|
||||
pub(crate) system_cursor: Switch,
|
||||
/// FONTALT at startup.
|
||||
pub(crate) font_alt: Option<String>,
|
||||
/// FONTFALLBACK at startup.
|
||||
pub(crate) font_fallback: Option<String>,
|
||||
}
|
||||
|
||||
impl Env {
|
||||
pub(crate) fn read() -> Env {
|
||||
Env::from_vars(|k| std::env::var_os(k))
|
||||
}
|
||||
|
||||
/// [`Env::read`] with `var` looking a variable up.
|
||||
pub(crate) fn from_vars(var: impl Fn(&str) -> Option<OsString>) -> Env {
|
||||
let text = |k: &str| var(k).and_then(|v| v.into_string().ok()).filter(|v| !v.trim().is_empty());
|
||||
let switch = |k: &str| Switch::parse(k, var(k).map(|v| v.to_string_lossy().into_owned()).as_deref());
|
||||
Env {
|
||||
config_dir: match var(CONFIG_DIR) {
|
||||
Some(dir) if dir.to_str().is_some_and(|d| d.trim().is_empty()) => None,
|
||||
Some(dir) => Some(PathBuf::from(dir)),
|
||||
None => cadcraft_engine::settings_dir::platform(&var),
|
||||
},
|
||||
locale: text(LOCALE),
|
||||
vsync: switch(VSYNC),
|
||||
wayland: switch(WAYLAND),
|
||||
no_native_menu: switch(NO_NATIVE_MENU),
|
||||
system_cursor: switch(SYSTEM_CURSOR),
|
||||
font_alt: text(FONTALT),
|
||||
font_fallback: text(FONTFALLBACK),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The `--help` section that lists [`VARS`].
|
||||
pub(crate) fn help() -> String {
|
||||
let width = VARS.iter().map(|(name, _)| name.len()).max().unwrap_or(0) + 3;
|
||||
VARS.iter().fold(String::from("Environment:"), |mut help, (name, line)| {
|
||||
let _ = write!(help, "\n {name:<width$}{line}");
|
||||
help
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::cell::RefCell;
|
||||
|
||||
fn env<'a>(vars: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<OsString> + 'a {
|
||||
move |k| vars.iter().find(|(n, _)| *n == k).map(|(_, v)| OsString::from(v))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_variable_read_is_in_the_help_and_every_listed_one_is_read() {
|
||||
let asked = RefCell::new(Vec::new());
|
||||
Env::from_vars(|k| {
|
||||
asked.borrow_mut().push(k.to_owned());
|
||||
None
|
||||
});
|
||||
let ours: Vec<String> = asked.into_inner().into_iter().filter(|k| k.starts_with("CADCRAFT_")).collect();
|
||||
let help = help();
|
||||
let unset = Env::from_vars(|_| None);
|
||||
for k in &ours {
|
||||
assert!(VARS.iter().any(|(name, _)| name == k), "{k} is read but not in VARS");
|
||||
}
|
||||
for (name, line) in VARS {
|
||||
assert!(ours.iter().any(|k| k == name), "{name} is in VARS but not read");
|
||||
assert_ne!(Env::from_vars(env(&[(name, "1")])), unset, "{name} sets no field");
|
||||
assert!(help.lines().any(|l| l.trim_start().starts_with(name) && l.ends_with(line)), "{help}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_are_read_and_blank_ones_are_unset() {
|
||||
let vars = [
|
||||
(CONFIG_DIR, "/private/cc"),
|
||||
("HOME", "/home/u"),
|
||||
("XDG_CONFIG_HOME", "/x"),
|
||||
("APPDATA", "C:\\a"),
|
||||
(LOCALE, "cs-CZ"),
|
||||
(VSYNC, " On "),
|
||||
(WAYLAND, "0"),
|
||||
(NO_NATIVE_MENU, "yes"),
|
||||
(SYSTEM_CURSOR, "maybe"),
|
||||
(FONTALT, "Noto Sans"),
|
||||
(FONTFALLBACK, "Vazirmatn, msjh.ttc"),
|
||||
];
|
||||
let read = Env::from_vars(env(&vars));
|
||||
assert_eq!(
|
||||
read,
|
||||
Env {
|
||||
config_dir: Some(PathBuf::from("/private/cc")),
|
||||
locale: Some("cs-CZ".into()),
|
||||
vsync: Switch::On,
|
||||
wayland: Switch::Off,
|
||||
no_native_menu: Switch::On,
|
||||
system_cursor: Switch::Default,
|
||||
font_alt: Some("Noto Sans".into()),
|
||||
font_fallback: Some("Vazirmatn, msjh.ttc".into()),
|
||||
}
|
||||
);
|
||||
let blank = vars.map(|(k, _)| (k, if k == FONTALT { " " } else { "" }));
|
||||
let read = Env::from_vars(env(&blank));
|
||||
assert_eq!(read.config_dir, None, "an empty variable means no directory, not the working directory");
|
||||
assert_eq!((read.locale, read.font_alt, read.font_fallback), (None, None, None));
|
||||
assert_eq!([read.vsync, read.wayland, read.no_native_menu, read.system_cursor], [Switch::Default; 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_dir_unset_is_the_platform_folder_set_is_that_folder_blank_is_none() {
|
||||
let platform = [("HOME", "/home/u"), ("XDG_CONFIG_HOME", "/x"), ("APPDATA", "C:\\a")];
|
||||
let expected = cadcraft_engine::settings_dir::platform(env(&platform));
|
||||
assert!(expected.is_some());
|
||||
assert_eq!(Env::from_vars(env(&platform)).config_dir, expected);
|
||||
let set = [platform.as_slice(), &[(CONFIG_DIR, "/private/cc")]].concat();
|
||||
assert_eq!(Env::from_vars(env(&set)).config_dir, Some(PathBuf::from("/private/cc")));
|
||||
for blank in ["", " "] {
|
||||
let vars = [platform.as_slice(), &[(CONFIG_DIR, blank)]].concat();
|
||||
assert_eq!(Env::from_vars(env(&vars)).config_dir, None, "{blank:?} means no settings folder, not the platform's");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,10 @@
|
||||
//! Windows graphics instance configuration, applied before wgpu can initialize any driver.
|
||||
|
||||
use eframe::{
|
||||
NativeOptions,
|
||||
egui_wgpu::WgpuSetup,
|
||||
wgpu::{Backends, Dx12Compiler},
|
||||
};
|
||||
use eframe::{NativeOptions, egui_wgpu::WgpuSetup, wgpu::Backends};
|
||||
|
||||
/// Default to DirectX 12 with FXC before creating the wgpu instance. Explicit
|
||||
/// `WGPU_BACKEND` and `WGPU_DX12_COMPILER` choices, read by the caller, take precedence.
|
||||
pub fn configure(options: &mut NativeOptions, backend_override: Option<Backends>, compiler_override: Option<Dx12Compiler>) {
|
||||
/// Restrict the wgpu instance to DirectX 12 unless `backend_override` (normally
|
||||
/// `Backends::from_env()`, i.e. `WGPU_BACKEND`) names other backends.
|
||||
pub fn configure(options: &mut NativeOptions, backend_override: Option<Backends>) {
|
||||
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
|
||||
// eframe's default includes GL. Creating that backend can crash inside AMD's
|
||||
// atio6axx.dll before adapter selection or any Rust error handling runs, so the
|
||||
@@ -16,15 +12,6 @@ pub fn configure(options: &mut NativeOptions, backend_override: Option<Backends>
|
||||
// out of the instance avoids it. This matches the verified WGPU_BACKEND=dx12
|
||||
// workaround without mutating process environment variables.
|
||||
create.instance_descriptor.backends = backend_override.unwrap_or(Backends::DX12);
|
||||
// Auto can load another program's dxcompiler.dll without a matching dxil.dll.
|
||||
// That fails wgpu's indirect-validation pipeline and loses the device before
|
||||
// the first frame (issue #608). FXC uses the Windows-provided compiler runtime.
|
||||
create.instance_descriptor.backend_options.dx12.shader_compiler = compiler_override.unwrap_or(Dx12Compiler::Fxc);
|
||||
log::info!(
|
||||
"Windows graphics: backends={:?}, DX12 compiler={:?}",
|
||||
create.instance_descriptor.backends,
|
||||
create.instance_descriptor.backend_options.dx12.shader_compiler
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +21,7 @@ mod tests {
|
||||
|
||||
fn configured_backends(backend_override: Option<Backends>) -> Backends {
|
||||
let mut options = NativeOptions::default();
|
||||
configure(&mut options, backend_override, None);
|
||||
configure(&mut options, backend_override);
|
||||
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
|
||||
panic!("default native options must create a wgpu instance");
|
||||
};
|
||||
@@ -51,44 +38,6 @@ mod tests {
|
||||
assert_eq!(configured_backends(None), Backends::DX12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_default_uses_fxc_instead_of_searching_for_dxc() {
|
||||
let mut options = NativeOptions::default();
|
||||
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
|
||||
// Auto can find an unrelated DXC without its matching DXIL runtime (issue #608).
|
||||
create.instance_descriptor.backend_options.dx12.shader_compiler = Dx12Compiler::Auto;
|
||||
}
|
||||
configure(&mut options, None, None);
|
||||
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
|
||||
panic!("default native options must create a wgpu instance");
|
||||
};
|
||||
assert!(matches!(create.instance_descriptor.backend_options.dx12.shader_compiler, Dx12Compiler::Fxc));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_compiler_and_backend_overrides_are_preserved() {
|
||||
let backends = Backends::VULKAN | Backends::DX12;
|
||||
for compiler in
|
||||
[Dx12Compiler::Fxc, Dx12Compiler::Auto, Dx12Compiler::StaticDxc, Dx12Compiler::DynamicDxc { dxc_path: "custom/dxcompiler.dll".into() }]
|
||||
{
|
||||
let mut options = NativeOptions::default();
|
||||
configure(&mut options, Some(backends), Some(compiler.clone()));
|
||||
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
|
||||
panic!("default native options must create a wgpu instance");
|
||||
};
|
||||
assert_eq!(create.instance_descriptor.backends, backends);
|
||||
match (compiler, create.instance_descriptor.backend_options.dx12.shader_compiler) {
|
||||
(Dx12Compiler::Fxc, Dx12Compiler::Fxc)
|
||||
| (Dx12Compiler::Auto, Dx12Compiler::Auto)
|
||||
| (Dx12Compiler::StaticDxc, Dx12Compiler::StaticDxc) => {}
|
||||
(Dx12Compiler::DynamicDxc { dxc_path: expected }, Dx12Compiler::DynamicDxc { dxc_path: actual }) => {
|
||||
assert_eq!(actual, expected);
|
||||
}
|
||||
(expected, actual) => panic!("compiler override changed from {expected:?} to {actual:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_backend_override_is_preserved() {
|
||||
for backends in [Backends::VULKAN, Backends::GL, Backends::DX12, Backends::VULKAN | Backends::DX12, Backends::empty()] {
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
//! The desktop app's command line, as clap types: `cadcraft --help` comes from these doc comments
|
||||
//! and its Environment section from [`crate::env::VARS`], so the help and the parsers can't disagree. Anything they don't declare is a usage error naming
|
||||
//! it (exit status 2), so a typo such as `--sampel` doesn't open the window and then fail to open
|
||||
//! the flag as a file; so is a flag without its value, a repeated flag and a bad port, from the
|
||||
//! flag or from `CADCRAFT_CONTROL_PORT`. A value never starts with `-`, and a file whose name
|
||||
//! starts with `-` goes after `--`.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use cadcraft_mcp::args::{blank_env_is_unset, name_env_vars};
|
||||
use clap::{Command, CommandFactory, FromArgMatches, Parser};
|
||||
|
||||
/// Opens CADCraft with the given files
|
||||
#[derive(Parser)]
|
||||
#[command(name = "cadcraft", version = cadcraft_engine::build_version(), after_help = crate::env::help())]
|
||||
pub(crate) struct LaunchArgs {
|
||||
/// Files to open. Put `--` before a name that starts with `-`
|
||||
pub(crate) files: Vec<PathBuf>,
|
||||
/// Start the localhost JSON-lines control server on this port
|
||||
// allow_negative_numbers makes `--control -1` a bad value rather than a missing one.
|
||||
#[arg(long, value_name = "PORT", env = "CADCRAFT_CONTROL_PORT", value_parser = control_port, allow_negative_numbers = true)]
|
||||
pub(crate) control: Option<u16>,
|
||||
/// Open with the sample drawing
|
||||
#[arg(long)]
|
||||
pub(crate) sample: bool,
|
||||
}
|
||||
|
||||
/// The command line. An empty or blank variable counts as unset.
|
||||
pub(crate) fn command() -> Command {
|
||||
LaunchArgs::command().mut_args(|a| blank_env_is_unset(a, |k| std::env::var_os(k)))
|
||||
}
|
||||
|
||||
/// Parse the launch arguments (without the binary name) with `cmd`, from [`command`]. Old macOS
|
||||
/// passes a process serial number (`-psn_…`) when launched from Finder: it is dropped first, unless
|
||||
/// it comes after `--`. A bad value from a variable is a usage error naming the variable.
|
||||
pub(crate) fn parse(mut cmd: Command, args: impl IntoIterator<Item = OsString>) -> Result<LaunchArgs, clap::Error> {
|
||||
let mut options = true;
|
||||
let args: Vec<OsString> = args
|
||||
.into_iter()
|
||||
.filter(|a| {
|
||||
options &= a != "--";
|
||||
!(options && a.to_str().is_some_and(|s| s.starts_with("-psn_")))
|
||||
})
|
||||
.collect();
|
||||
let argv = std::iter::once(OsString::from("cadcraft")).chain(args.iter().cloned());
|
||||
let matches = cmd.try_get_matches_from_mut(argv).map_err(|e| name_env_vars(e, &cmd, &args))?;
|
||||
LaunchArgs::from_arg_matches(&matches)
|
||||
}
|
||||
|
||||
/// A control port, for clap's `value_parser`: `--control` and `CADCRAFT_CONTROL_PORT`, which may
|
||||
/// carry spaces.
|
||||
fn control_port(s: &str) -> Result<u16, NotAPort> {
|
||||
s.trim().parse().map_err(|_| NotAPort)
|
||||
}
|
||||
|
||||
/// A [`control_port`] value that isn't a port.
|
||||
#[derive(Debug, PartialEq, Eq, thiserror::Error)]
|
||||
#[error("expected a port, 0-65535")]
|
||||
struct NotAPort;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use clap::error::ErrorKind;
|
||||
|
||||
/// Parse without the variables, so one exported where the tests run can't change them.
|
||||
fn parse_args(args: &[&str]) -> Result<LaunchArgs, clap::Error> {
|
||||
parse(command().mut_args(|a| a.env(None::<&str>)), args.iter().map(OsString::from))
|
||||
}
|
||||
|
||||
fn usage_error(args: &[&str]) -> String {
|
||||
let Err(e) = parse_args(args) else { panic!("{args:?} parsed") };
|
||||
assert_eq!(e.exit_code(), 2, "{args:?}: {e}");
|
||||
e.to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_are_parsed() {
|
||||
let parsed = parse_args(&["--control", " 50494 ", "--sample", "a.dxf", "b.dwg"]).unwrap();
|
||||
assert_eq!((parsed.control, parsed.sample), (Some(50494), true));
|
||||
assert_eq!(parsed.files, [PathBuf::from("a.dxf"), PathBuf::from("b.dwg")]);
|
||||
let parsed = parse_args(&[]).unwrap();
|
||||
assert_eq!((parsed.control, parsed.sample, parsed.files.len()), (None, false, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_port_rejects_unparseable_values_naming_them() {
|
||||
for bad in ["78787", "-1", "abc", "99999999999999999999"] {
|
||||
assert!(usage_error(&["--control", bad]).contains(&format!("invalid value '{bad}' for '--control <PORT>'")), "{bad}");
|
||||
}
|
||||
assert!(usage_error(&["--control="]).contains("invalid value '' for '--control <PORT>'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_errors_exit_2() {
|
||||
assert!(usage_error(&["--sampel"]).contains("unexpected argument '--sampel'"));
|
||||
assert!(usage_error(&["--control"]).contains("a value is required for '--control <PORT>'"));
|
||||
// A following flag is never taken as the value.
|
||||
assert!(usage_error(&["--control", "--sample"]).contains("a value is required for '--control <PORT>'"));
|
||||
assert!(usage_error(&["--sample", "--sample"]).contains("cannot be used multiple times"));
|
||||
assert!(usage_error(&["--control", "1", "--control", "2"]).contains("cannot be used multiple times"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_stops_parsing() {
|
||||
let e = parse_args(&["-V", "--control"]).err().unwrap();
|
||||
assert_eq!((e.kind(), e.exit_code()), (ErrorKind::DisplayVersion, 0));
|
||||
assert_eq!(e.to_string(), format!("{}\n", crate::version_string()));
|
||||
let e = parse_args(&["--version", "--bogus"]).err().unwrap();
|
||||
assert_eq!(e.kind(), ErrorKind::DisplayVersion);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_stops_parsing_and_double_dash_ends_options() {
|
||||
let e = parse_args(&["-h", "--sampel", "--control"]).err().unwrap();
|
||||
assert_eq!((e.kind(), e.exit_code()), (ErrorKind::DisplayHelp, 0));
|
||||
let help = parse_args(&["--help"]).err().unwrap().to_string();
|
||||
assert!(help.contains("--control <PORT>") && help.contains("CADCRAFT_CONFIG_DIR"), "{help}");
|
||||
|
||||
assert!(usage_error(&["--sampel", "--", "--help"]).contains("unexpected argument '--sampel'"));
|
||||
let parsed = parse_args(&["a.dxf", "--", "--help", "-x"]).unwrap();
|
||||
assert_eq!(parsed.files, ["a.dxf", "--help", "-x"].map(PathBuf::from));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_finder_process_serial_number_is_dropped() {
|
||||
let parsed = parse_args(&["-psn_0_12345", "a.dxf"]).unwrap();
|
||||
assert_eq!(parsed.files, [PathBuf::from("a.dxf")]);
|
||||
let parsed = parse_args(&["--", "-psn_x.dxf"]).unwrap();
|
||||
assert_eq!(parsed.files, [PathBuf::from("-psn_x.dxf")]);
|
||||
}
|
||||
|
||||
/// A name that isn't valid Unicode is kept as given.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn non_unicode_file_names_are_kept() {
|
||||
use std::os::unix::ffi::OsStringExt as _;
|
||||
let latin1 = OsString::from_vec(b"caf\xe9.dxf".to_vec());
|
||||
let parsed = parse(command().mut_args(|a| a.env(None::<&str>)), [latin1.clone(), OsString::from("b.dxf")]).unwrap();
|
||||
assert_eq!(parsed.files, [PathBuf::from(&latin1), PathBuf::from("b.dxf")]);
|
||||
}
|
||||
|
||||
/// `--control` falls back to `PATH` here instead of `CADCRAFT_CONTROL_PORT`: `PATH` is set
|
||||
/// wherever the tests run and is never a port, and tests can't set a variable (`set_var` is
|
||||
/// unsafe).
|
||||
#[test]
|
||||
fn the_flag_wins_over_its_variable_and_a_bad_value_names_the_variable() {
|
||||
let Some(path) = std::env::var_os("PATH").filter(|p| !p.is_empty()) else { return };
|
||||
let path_fallback = || command().mut_args(|a| a.env(None::<&str>)).mut_arg("control", |a| a.env("PATH"));
|
||||
let e = parse(path_fallback(), []).err().unwrap();
|
||||
assert_eq!(e.exit_code(), 2);
|
||||
let text = e.to_string();
|
||||
assert!(text.contains(&format!("invalid value '{}' for 'PATH'", path.to_string_lossy())), "{text}");
|
||||
assert!(!text.contains("--control <PORT>'"), "{text}");
|
||||
assert_eq!(parse(path_fallback(), ["--control", "7"].map(OsString::from)).unwrap().control, Some(7));
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
//! Without a logger every `log::warn!`/`log::error!`/`log::info!` in the workspace (a broken app
|
||||
//! icon, the GPU canvas set-up, the control server) vanished. A launch from a desktop menu has no
|
||||
//! terminal, so the file is what a bug report can attach: `<settings dir>/logs/cadcraft.log`
|
||||
//! (`CADCRAFT_CONFIG_DIR`, or the platform settings directory; empty means none; see `env.rs`).
|
||||
//! (`CADCRAFT_CONFIG_DIR`, or the platform settings directory; see `config_dir` in `main.rs`).
|
||||
//! Each start moves the previous log to `cadcraft.1.log` (and that one to `.2`), so the log of a
|
||||
//! run that crashed survives the next launch.
|
||||
//!
|
||||
@@ -93,10 +93,7 @@ fn matches(name: &str, target: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch (the
|
||||
/// `duration_since` clamp); past the last date chrono can represent (year 262143) the proleptic
|
||||
/// Gregorian date is kept via the 400-year cycle rather than falling back to the epoch, so the
|
||||
/// output never changes for a value the old formatter accepted and logging never panics.
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
|
||||
pub fn timestamp(t: SystemTime) -> String {
|
||||
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
|
||||
let secs = since.as_secs();
|
||||
@@ -105,25 +102,20 @@ pub fn timestamp(t: SystemTime) -> String {
|
||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 → proleptic Gregorian (year, month, day).
|
||||
///
|
||||
/// `timestamp` clamps a pre-1970 clock to the epoch, so `days` is an unsigned count that can run
|
||||
/// past `NaiveDate`'s ±262143-year range. The Gregorian calendar repeats every 400 years
|
||||
/// (146_097 days), so chrono converts the day within one cycle and whole cycles go back into the
|
||||
/// year (an `i128`, then saturated into `i64`), keeping chrono as the only calendar logic and
|
||||
/// reproducing the old saturating `civil_from_days` result exactly without hand-rolled day math.
|
||||
/// 1970-01-01 is `num_days_from_ce` 719_163.
|
||||
fn civil_from_days(days: u64) -> (i64, u32, u32) {
|
||||
use chrono::Datelike;
|
||||
let (cycles, rest) = (days / 146_097, days % 146_097);
|
||||
// `rest + 719_163` is 719_163..865_259, always a valid CE day.
|
||||
match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
|
||||
Some(d) => {
|
||||
let y = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||
(i64::try_from(y).unwrap_or(i64::MAX), d.month(), d.day())
|
||||
}
|
||||
None => (1970, 1, 1), // unreachable
|
||||
}
|
||||
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
|
||||
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch; saturating so an absurd
|
||||
/// clock can't overflow).
|
||||
fn civil_from_days(days: u64) -> (u64, u64, u64) {
|
||||
let z = days.saturating_add(719_468);
|
||||
let era = z / 146_097;
|
||||
let doe = z % 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
|
||||
(y, m, d)
|
||||
}
|
||||
|
||||
fn numbered(dir: &Path, n: usize) -> PathBuf {
|
||||
@@ -393,20 +385,8 @@ mod tests {
|
||||
// 2000 is a leap year (divisible by 400), 2100 is not.
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
|
||||
// Before the epoch (a clock set wrong) clamps to the epoch instead of panicking.
|
||||
// Before the epoch (a clock set wrong) clamps instead of panicking.
|
||||
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
|
||||
// Past chrono's range (a clock set absurdly far ahead) keeps the proleptic Gregorian date
|
||||
// the old formatter gave, rather than falling back to the epoch.
|
||||
// Windows' SystemTime holds only up to about year 30828, so this is checked where it fits.
|
||||
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
|
||||
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
|
||||
}
|
||||
// Windows' SystemTime can't hold u64::MAX / 2 seconds, so it is only checked where it exists.
|
||||
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
|
||||
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
|
||||
}
|
||||
// Years past 9999 keep the plain zero-padded year (no `+`).
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+129
-290
@@ -1,6 +1,6 @@
|
||||
//! CADCraft desktop app.
|
||||
//!
|
||||
//! Usage: `cadcraft [--control <port>] [--sample] [files…]` (`cadcraft --help` lists the rest; see [`launch_args`])
|
||||
//! Usage: `cadcraft [--control <port>] [--sample] [files…]`
|
||||
//!
|
||||
//! `--control <port>` (or `CADCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
|
||||
//! `{"id":1,"method":"cmdline.input","params":{"text":"circle 0,0 5"}}` → `{"id":1,"ok":true,…}`.
|
||||
@@ -10,129 +10,48 @@
|
||||
//! present mode (`CADCRAFT_VSYNC=0` selects that elsewhere); see `cadcraft_ui_egui::gpu::surface_config`.
|
||||
//!
|
||||
//! `log` records go to standard error and `<settings dir>/logs/cadcraft.log` (`RUST_LOG` sets the
|
||||
//! levels); see [`logging`]. The `CADCRAFT_*` variables are read once, at startup; see [`env`].
|
||||
//! levels); see [`logging`] and [`config_dir`].
|
||||
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod control_server;
|
||||
mod desktop_theme;
|
||||
mod env;
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
mod graphics;
|
||||
mod launch_args;
|
||||
mod logging;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
mod spacemouse;
|
||||
mod system_animations;
|
||||
mod workspace;
|
||||
|
||||
use cadcraft_engine::Session;
|
||||
use cadcraft_ui_egui::switch::Switch;
|
||||
use cadcraft_ui_egui::{CadApp, Services, i18n};
|
||||
|
||||
struct App {
|
||||
cad: CadApp,
|
||||
spacemouse: spacemouse::SpaceMouse,
|
||||
/// The desktop's light/dark choice where winit reports none (Linux, the BSDs).
|
||||
desktop_theme: desktop_theme::DesktopTheme,
|
||||
/// Whether the OS shows animations, for the System choice of View ▸ Accessibility ▸ Animation Effects.
|
||||
system_animations: system_animations::SystemAnimations,
|
||||
workspace: workspace::Workspace,
|
||||
workspace_error: Option<String>,
|
||||
#[cfg(target_os = "macos")]
|
||||
native_menu: Option<native_menu::NativeMenu>,
|
||||
/// `CADCRAFT_NO_NATIVE_MENU`: the menus stay inside the window.
|
||||
#[cfg(target_os = "macos")]
|
||||
menus_in_window: bool,
|
||||
/// The `movable` flag last set on the main window.
|
||||
#[cfg(target_os = "macos")]
|
||||
movable: Option<bool>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// `ctx` is `None` only for the final attempt at exit.
|
||||
fn persist_workspace(&mut self, ctx: Option<&egui::Context>) {
|
||||
let result = match ctx {
|
||||
None => self.workspace.save_on_exit(&self.cad.ui),
|
||||
Some(ctx) => self.workspace.save_if_changed(&self.cad.ui, std::time::Instant::now()).map(|wait| {
|
||||
if let Some(wait) = wait {
|
||||
ctx.request_repaint_after(wait);
|
||||
}
|
||||
}),
|
||||
};
|
||||
if let Err(error) = result {
|
||||
if self.workspace_error.as_ref() != Some(&error) {
|
||||
eprintln!("CADCraft workspace: {error}");
|
||||
self.cad.set_status(format!("Workspace could not be saved: {error}"));
|
||||
self.workspace_error = Some(error);
|
||||
}
|
||||
} else {
|
||||
self.workspace_error = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// View ▸ Theme ▸ System resolves against `raw.system_theme`, which winit leaves empty on Linux
|
||||
/// and the BSDs: the desktop's answer, where it gives one, stands in for it.
|
||||
fn follow_desktop(raw: &mut egui::RawInput, desktop: Option<egui::Theme>) {
|
||||
if desktop.is_some() {
|
||||
raw.system_theme = desktop;
|
||||
}
|
||||
}
|
||||
struct App(CadApp, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>);
|
||||
|
||||
impl eframe::App for App {
|
||||
/// eframe logic callback: syncs the OS animation setting and runs `CadApp::logic`; when the pass index is 0 (the first pass or a hidden tick) it also installs/polls the macOS native menu, polls the spacemouse and persists the workspace; a quit request closes the viewport.
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.cad.system_animations = self.system_animations.current();
|
||||
// Consume control/open/screenshot queues and time-based autosave, including hidden ticks.
|
||||
self.cad.logic(ctx);
|
||||
// Host frame work runs here, once per frame, and so also on a hidden logic-only tick (which
|
||||
// never reaches `App::ui`): native menus stay current, the spacemouse still navigates and a
|
||||
// workspace changed while hidden is persisted. `current_pass_index()` is zero on the first
|
||||
// pass and on every hidden tick, and non-zero on later passes of a multi-pass frame.
|
||||
if ctx.current_pass_index() == 0 {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if self.native_menu.is_none() && !self.menus_in_window {
|
||||
self.native_menu = Some(native_menu::NativeMenu::install(&mut self.cad, ctx));
|
||||
}
|
||||
if let Some(m) = &mut self.native_menu {
|
||||
m.poll(&mut self.cad, ctx);
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if self.1.is_none() && std::env::var_os("CADCRAFT_NO_NATIVE_MENU").is_none() {
|
||||
self.1 = Some(native_menu::NativeMenu::install(&mut self.0));
|
||||
}
|
||||
if let Some(m) = &mut self.1 {
|
||||
m.poll(&mut self.0, ctx);
|
||||
}
|
||||
self.spacemouse.poll(&mut self.cad, ctx);
|
||||
// After `cad.logic`, so a control command's workspace change is saved in the same frame.
|
||||
self.persist_workspace(Some(ctx));
|
||||
}
|
||||
if std::mem::take(&mut self.cad.quit_requested) {
|
||||
self.0.logic(ctx);
|
||||
if std::mem::take(&mut self.0.quit_requested) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
}
|
||||
/// Apply the desktop-theme fallback, forward raw input to `CadApp`, and capture Tab at the command line.
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
follow_desktop(raw, self.desktop_theme.current());
|
||||
self.cad.raw_input_hook(ctx, raw);
|
||||
self.0.raw_input_hook(raw);
|
||||
cadcraft_ui_egui::cmdline::capture_tab(ctx, raw);
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.cad.ui(ui);
|
||||
#[cfg(target_os = "macos")]
|
||||
craft_window::title_bar::sync_movable(ui.ctx(), &mut self.movable);
|
||||
self.0.ui(ui);
|
||||
}
|
||||
/// A normal exit: the workspace is saved, and this run's autosave files are not needed (a
|
||||
/// crash leaves them for recovery).
|
||||
fn on_exit(&mut self) {
|
||||
self.persist_workspace(None);
|
||||
self.cad.cancel_screenshots();
|
||||
self.cad.shutdown_autosave();
|
||||
}
|
||||
/// Preferences (the interface theme) survive restarts; eframe saves the window state itself
|
||||
/// (`persist_window`); egui state is not kept.
|
||||
/// Preferences (the interface theme) survive restarts; window and egui state are not kept.
|
||||
fn save(&mut self, storage: &mut dyn eframe::Storage) {
|
||||
storage.set_string(cadcraft_ui_egui::options::PROFILE_KEY, self.cad.profile_json());
|
||||
storage.set_string(cadcraft_ui_egui::PREFS_KEY, self.cad.prefs_json());
|
||||
storage.set_string(cadcraft_ui_egui::autosave::PREFS_KEY, cadcraft_ui_egui::autosave::prefs_json());
|
||||
storage.set_string(cadcraft_ui_egui::PREFS_KEY, self.0.prefs_json());
|
||||
}
|
||||
fn persist_egui_memory(&self) -> bool {
|
||||
false
|
||||
@@ -144,15 +63,7 @@ pub fn install_io() {
|
||||
cadcraft_engine::cmd::file::set_io(cadcraft_engine::cmd::file::IoHooks {
|
||||
read: |b, name| cadcraft_io::read(b, name).map_err(|e| e.to_string()),
|
||||
write: |d, name| cadcraft_io::write(d, name).map_err(|e| e.to_string()),
|
||||
plot: Some(|d, space, opts| cadcraft_io::plotter::plot(d, space, opts).map_err(|e| e.to_string())),
|
||||
});
|
||||
cadcraft_engine::cmd::file::set_write_as(|d, name, o| {
|
||||
cadcraft_io::write_with(d, name, &cadcraft_io::SaveOptions { version: o.version, binary: o.binary }).map_err(|e| e.to_string())
|
||||
});
|
||||
// AutoLISP's entget, entmake and entmod see entities as DXF groups.
|
||||
cadcraft_engine::cmd::file::set_entity_codec(cadcraft_engine::cmd::file::EntityCodec {
|
||||
groups: cadcraft_io::entity_groups,
|
||||
entity: cadcraft_io::entity_from_groups,
|
||||
plot: Some(|d, space, opts| cadcraft_io::plot(d, space, opts).map_err(|e| e.to_string())),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -160,8 +71,7 @@ fn services() -> Services {
|
||||
Services {
|
||||
pick_open: Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter(i18n::t("Drawings (DWG, DXF)"), &["dwg", "dxf", "dwt"])
|
||||
.add_filter(i18n::t("SVG drawings (SVG, SVGZ)"), &["svg", "svgz"])
|
||||
.add_filter(i18n::t("Drawings (DWG, DXF)"), &["dwg", "dxf"])
|
||||
.add_filter(i18n::t("All files"), &["*"])
|
||||
.pick_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
@@ -173,13 +83,6 @@ fn services() -> Services {
|
||||
.save_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
// APPLOAD: AutoLISP routines only, starting where the last one was loaded from.
|
||||
pick_lisp: Some(Box::new(|dir: &str| {
|
||||
let dialog = rfd::FileDialog::new().add_filter(i18n::t("AutoLISP (LSP)"), &["lsp"]).add_filter(i18n::t("All files"), &["*"]);
|
||||
let dialog = if dir.is_empty() { dialog } else { dialog.set_directory(dir) };
|
||||
dialog.pick_file().map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
no_file_system: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,62 +90,39 @@ fn services() -> Services {
|
||||
/// filter's extension, and macOS accepts only listed extensions, replacing any other with the
|
||||
/// first one. DWG must be listed for a `.dwg` name to survive, and a drawing opened from DWG
|
||||
/// then defaults to DWG; other names keep DXF first.
|
||||
fn filters_for_save(name: &str) -> [(&'static str, &'static str); 6] {
|
||||
let mut filters =
|
||||
[("Drawing (DXF)", "dxf"), ("Drawing (DWG)", "dwg"), ("Drawing Template (DWT)", "dwt"), ("SVG", "svg"), ("PNG", "png"), ("PDF", "pdf")];
|
||||
fn filters_for_save(name: &str) -> [(&'static str, &'static str); 5] {
|
||||
let mut filters = [("Drawing (DXF)", "dxf"), ("Drawing (DWG)", "dwg"), ("SVG", "svg"), ("PNG", "png"), ("PDF", "pdf")];
|
||||
let lower = name.to_ascii_lowercase();
|
||||
filters.sort_by_key(|(_, ext)| !lower.ends_with(&format!(".{ext}")));
|
||||
filters
|
||||
}
|
||||
|
||||
/// Whether eframe's storage file (`app.ron`, a map of keys to strings) holds a saved main window
|
||||
/// state. An unreadable or malformed file counts as none, as it does for eframe.
|
||||
fn saved_window(app_ron: &str) -> bool {
|
||||
ron::from_str::<std::collections::HashMap<String, String>>(app_ron).is_ok_and(|kv| kv.contains_key(WINDOW_KEY))
|
||||
/// The per-user settings directory (today it holds `logs/`): `CADCRAFT_CONFIG_DIR` if set, else
|
||||
/// `$XDG_CONFIG_HOME/cadcraft` or `~/.config/cadcraft` on Linux and the BSDs,
|
||||
/// `~/Library/Application Support/CADCraft` on macOS, `%APPDATA%\CADCraft` on Windows.
|
||||
fn config_dir() -> Option<std::path::PathBuf> {
|
||||
config_dir_from(|k| std::env::var_os(k))
|
||||
}
|
||||
|
||||
/// The window state from the last session is saved and will be restored (#2595).
|
||||
fn restores_window() -> bool {
|
||||
eframe::storage_dir(APP_ID).and_then(|dir| std::fs::read_to_string(dir.join("app.ron")).ok()).is_some_and(|ron| saved_window(&ron))
|
||||
}
|
||||
|
||||
/// The main window: its size, title bar, placement and rendering needs. `restoring`: a window state
|
||||
/// saved by the last session is restored instead of opening centred.
|
||||
fn window_options(restoring: bool) -> eframe::NativeOptions {
|
||||
eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("CADCraft")
|
||||
.with_inner_size([1600.0, 1000.0])
|
||||
.with_min_inner_size([900.0, 560.0])
|
||||
.with_drag_and_drop(true)
|
||||
.with_fullsize_content_view(true)
|
||||
.with_titlebar_shown(false)
|
||||
.with_title_shown(false)
|
||||
.with_app_id(APP_ID),
|
||||
// The size, position and maximized state are kept between sessions (#2595); eframe keeps a
|
||||
// restored window on the monitors that are connected now.
|
||||
persist_window: true,
|
||||
// The first launch opens centred on its screen, not cascaded from the top-left corner
|
||||
// (#1129); later launches go back where the window was (eframe would centre over that).
|
||||
// Wayland compositors place windows themselves.
|
||||
centered: !restoring,
|
||||
// CADCraft installs its own capped font chain (`theme::install_fonts`); never run
|
||||
// system-font discovery as a hidden fallback.
|
||||
system_font_fallback: false,
|
||||
// A stencil buffer clips the contents of clipped layout viewports on the GPU.
|
||||
stencil_buffer: STENCIL_BITS,
|
||||
..Default::default()
|
||||
/// [`config_dir`] with the environment passed in (`var` looks a variable up). An empty variable
|
||||
/// counts as unset, so a blank value never means the working directory.
|
||||
fn config_dir_from(var: impl Fn(&str) -> Option<std::ffi::OsString>) -> Option<std::path::PathBuf> {
|
||||
let set = |k: &str| var(k).filter(|v| !v.is_empty()).map(std::path::PathBuf::from);
|
||||
if let Some(dir) = set("CADCRAFT_CONFIG_DIR") {
|
||||
return Some(dir);
|
||||
}
|
||||
if cfg!(target_os = "macos") {
|
||||
set("HOME").map(|h| h.join("Library/Application Support/CADCraft"))
|
||||
} else if cfg!(windows) {
|
||||
set("APPDATA").map(|a| a.join("CADCraft"))
|
||||
} else {
|
||||
// The XDG spec ignores a relative XDG_CONFIG_HOME.
|
||||
set("XDG_CONFIG_HOME").filter(|p| p.is_absolute()).or_else(|| set("HOME").map(|h| h.join(".config"))).map(|c| c.join("cadcraft"))
|
||||
}
|
||||
}
|
||||
|
||||
const APP_ID: &str = "ai.storyteller.cadcraft";
|
||||
|
||||
/// The key eframe saves the main window state under in its storage.
|
||||
const WINDOW_KEY: &str = "window";
|
||||
|
||||
/// Stencil bits of the window's render pass (see `NativeOptions::stencil_buffer`).
|
||||
const STENCIL_BITS: u8 = 8;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
const APP_ICON_PNG: &[u8] = include_bytes!("../../../assets/app-icon/cadcraft-macos-512.png");
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
@@ -253,135 +133,102 @@ fn app_icon() -> Option<egui::IconData> {
|
||||
}
|
||||
|
||||
fn version_string() -> String {
|
||||
format!("cadcraft {}", cadcraft_engine::build_version())
|
||||
let sha = option_env!("CADCRAFT_BUILD_SHA").unwrap_or("dev");
|
||||
format!("cadcraft {} ({sha})", env!("CARGO_PKG_VERSION"))
|
||||
}
|
||||
|
||||
/// The control port from `--control <port>` or `CADCRAFT_CONTROL_PORT` (`source`); a value that is
|
||||
/// not a port is logged and ignored instead of silently starting no control channel.
|
||||
fn control_port_from(source: &str, value: Option<String>) -> Option<u16> {
|
||||
let value = value?;
|
||||
let port = value.trim().parse().ok();
|
||||
if port.is_none() {
|
||||
log::warn!("{source}: {value:?} is not a port number; the control channel is off");
|
||||
}
|
||||
port
|
||||
}
|
||||
|
||||
/// Parse CLI options (`--control`, `--sample`, files, `--version`), install logging and I/O hooks, build the services and app, and run eframe.
|
||||
fn main() -> eframe::Result {
|
||||
// First, so every start-up record is captured; see `logging`.
|
||||
let logger = logging::install();
|
||||
// The update helper relaunches this binary after a self-update; it runs and exits here.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(done) = craft_update::self_update::finish(&cadcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
if let Err(e) = done {
|
||||
eprintln!("CADCraft update helper failed: {e}");
|
||||
std::process::exit(1);
|
||||
// lossy, so a value that is not Unicode is reported like any other bad port instead of ignored
|
||||
let mut control_port =
|
||||
control_port_from("CADCRAFT_CONTROL_PORT", std::env::var_os("CADCRAFT_CONTROL_PORT").map(|p| p.to_string_lossy().into_owned()));
|
||||
let mut files = Vec::new();
|
||||
let mut sample = false;
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
"--control" => control_port = control_port_from("--control", args.next()),
|
||||
"--sample" => sample = true,
|
||||
"--version" | "-V" => {
|
||||
println!("{}", version_string());
|
||||
return Ok(());
|
||||
}
|
||||
_ => files.push(a),
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
// `--help` and `--version` print to standard output and exit 0. A usage error (an unknown or
|
||||
// repeated flag, a missing or bad value, a bad CADCRAFT_CONTROL_PORT) names the argument on
|
||||
// standard error and exits 2 before the window opens.
|
||||
let launch = launch_args::parse(launch_args::command(), std::env::args_os().skip(1)).unwrap_or_else(|e| e.exit());
|
||||
let vars = env::Env::read();
|
||||
let (control_port, sample, files) = (launch.control, launch.sample, launch.files);
|
||||
// The log file lives under the settings directory; opened after the arguments, so `--version`
|
||||
// leaves no file behind. Records logged until now are written to it first.
|
||||
if let Some(logger) = logger {
|
||||
match vars.config_dir.as_ref().map(|d| logger.attach_dir(&d.join(logging::LOG_DIR))) {
|
||||
match config_dir().map(|d| logger.attach_dir(&d.join(logging::LOG_DIR))) {
|
||||
Some(Ok(path)) => log::info!("{} ({} {}), log file {}", version_string(), std::env::consts::OS, std::env::consts::ARCH, path.display()),
|
||||
Some(Err(e)) => log::warn!("no log file: {e}"),
|
||||
None => {
|
||||
logger.stderr_only();
|
||||
log::warn!("no log file: no settings directory (an empty CADCRAFT_CONFIG_DIR turns it off)");
|
||||
log::warn!("no log file: no settings directory (set CADCRAFT_CONFIG_DIR)");
|
||||
}
|
||||
}
|
||||
}
|
||||
install_io();
|
||||
cadcraft_engine::settings_dir::set(vars.config_dir.clone());
|
||||
if let Some(name) = &vars.font_alt {
|
||||
cadcraft_engine::fonts::ttf::set_font_alt(name);
|
||||
}
|
||||
if let Some(list) = &vars.font_fallback {
|
||||
cadcraft_engine::fonts::ttf::set_fallback_fonts(list);
|
||||
}
|
||||
let surface = cadcraft_ui_egui::gpu::surface_config(vars.vsync, cfg!(all(unix, not(target_os = "macos"))));
|
||||
let surface = cadcraft_ui_egui::gpu::surface_config(std::env::var("CADCRAFT_VSYNC").ok().as_deref(), cfg!(all(unix, not(target_os = "macos"))));
|
||||
let mut options = eframe::NativeOptions {
|
||||
// The crosshair is drawn by the app: present without a queue on Linux/BSD, where X11
|
||||
// swapchains hold two frames back under vsync (`CADCRAFT_VSYNC=1` restores vsync).
|
||||
wgpu_options: eframe::egui_wgpu::WgpuConfiguration::default().with_surface_config(surface),
|
||||
..window_options(restores_window())
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("CADCraft")
|
||||
.with_inner_size([1600.0, 1000.0])
|
||||
.with_min_inner_size([900.0, 560.0])
|
||||
.with_drag_and_drop(true)
|
||||
.with_fullsize_content_view(true)
|
||||
.with_titlebar_shown(false)
|
||||
.with_title_shown(false)
|
||||
.with_app_id(APP_ID),
|
||||
persist_window: false,
|
||||
..Default::default()
|
||||
};
|
||||
if let Some(icon) = app_icon() {
|
||||
options.viewport = options.viewport.with_icon(icon);
|
||||
}
|
||||
// Before eframe creates the wgpu instance: default Windows to DirectX 12 with FXC (see graphics.rs).
|
||||
// Before eframe creates the wgpu instance: default Windows to DirectX 12 only (see graphics.rs).
|
||||
#[cfg(target_os = "windows")]
|
||||
graphics::configure(&mut options, eframe::wgpu::Backends::from_env(), eframe::wgpu::Dx12Compiler::from_env());
|
||||
// Command previews (COPY, MOVE...) of large selections are tessellated by egui, whose index
|
||||
// buffer outgrew wgpu's default 256 MiB cap; the validation error is fatal. Ask for what the
|
||||
// adapter actually supports.
|
||||
if let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
|
||||
let base = std::sync::Arc::clone(&create.device_descriptor);
|
||||
create.device_descriptor = std::sync::Arc::new(move |adapter| {
|
||||
let mut d = base(adapter);
|
||||
d.required_limits.max_buffer_size = d.required_limits.max_buffer_size.max(adapter.limits().max_buffer_size);
|
||||
d
|
||||
});
|
||||
}
|
||||
graphics::configure(&mut options, eframe::wgpu::Backends::from_env());
|
||||
// winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file
|
||||
// manager showed a "no" cursor. Run through XWayland when it's there; CADCRAFT_WAYLAND=1
|
||||
// keeps the native Wayland backend.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
if std::env::var_os("DISPLAY").is_some() && vars.wayland != Switch::On {
|
||||
if std::env::var_os("DISPLAY").is_some() && std::env::var_os("CADCRAFT_WAYLAND").is_none() {
|
||||
options.event_loop_builder = Some(Box::new(|b| {
|
||||
use winit::platform::x11::EventLoopBuilderExtX11;
|
||||
b.with_x11();
|
||||
}));
|
||||
}
|
||||
// macOS: Quit CADCraft (Cmd+Q), the Dock's Quit and logout end the app through AppKit's
|
||||
// terminate, which would skip the unsaved-changes prompt (#2157). winit turns each into a
|
||||
// close request for the window instead, so it asks first, as the close button does.
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
options.event_loop_builder = Some(Box::new(|b| {
|
||||
use winit::platform::macos::EventLoopBuilderExtMacOS;
|
||||
b.with_close_requested_on_terminate(true);
|
||||
}));
|
||||
}
|
||||
eframe::run_native(
|
||||
"CADCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
let mut app = CadApp::new(Session::empty(), services());
|
||||
let (workspace, restored, workspace_error) = workspace::Workspace::load(vars.config_dir.as_deref());
|
||||
if let Some(ui) = restored {
|
||||
app.ui = ui;
|
||||
}
|
||||
app.set_system_cursor_default(vars.system_cursor == Switch::On);
|
||||
if let Some(error) = &workspace_error {
|
||||
eprintln!("CADCraft workspace: {error}");
|
||||
app.set_status(error);
|
||||
}
|
||||
app.system_languages = vars.locale.map(|s| vec![s]).unwrap_or_else(|| sys_locale::get_locales().collect());
|
||||
// The user profile (Options): drafting settings, selection sizes, drawing-area colours.
|
||||
if let Some(profile) = cc.storage.and_then(|s| s.get_string(cadcraft_ui_egui::options::PROFILE_KEY)) {
|
||||
app.load_profile(&profile);
|
||||
}
|
||||
app.system_languages = std::env::var("CADCRAFT_LOCALE").map(|s| vec![s]).unwrap_or_else(|_| sys_locale::get_locales().collect());
|
||||
if let Some(prefs) = cc.storage.and_then(|s| s.get_string(cadcraft_ui_egui::PREFS_KEY)) {
|
||||
app.load_prefs(&prefs);
|
||||
}
|
||||
if let Some(prefs) = cc.storage.and_then(|s| s.get_string(cadcraft_ui_egui::autosave::PREFS_KEY)) {
|
||||
cadcraft_ui_egui::autosave::load_prefs(&prefs);
|
||||
}
|
||||
// Autosave files (SAVEFILEPATH) default to `<settings dir>/autosave`.
|
||||
if let Some(dir) = &vars.config_dir {
|
||||
app.enable_autosave(&dir.join("autosave"));
|
||||
app.enable_update_check();
|
||||
// Startup LISP (acad.lsp, acaddoc.lsp) is read from `<settings>/support`; the
|
||||
// folder is made so it is there to put them in.
|
||||
let support = dir.join("support");
|
||||
if let Err(e) = std::fs::create_dir_all(&support) {
|
||||
log::warn!("no LISP support folder {}: {e}", support.display());
|
||||
}
|
||||
app.session.set_lisp_support_dir(Some(support));
|
||||
}
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
if surface.present_mode == eframe::wgpu::PresentMode::AutoNoVsync {
|
||||
app.frame_cap = Some(cadcraft_ui_egui::gpu::FrameCap::new(240));
|
||||
}
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
app.set_wgpu(rs, eframe::egui_wgpu::depth_format_from_bits(0, STENCIL_BITS));
|
||||
app.set_wgpu(rs);
|
||||
}
|
||||
if let Some(port) = control_port {
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
@@ -394,35 +241,26 @@ fn main() -> eframe::Result {
|
||||
app.open_path(f);
|
||||
}
|
||||
if app.session.docs.is_empty() {
|
||||
// In the units the profile asks for (MEASUREINIT, restored above).
|
||||
let metric = app.session.settings.measureinit;
|
||||
app.session.new_drawing(metric);
|
||||
app.session.new_drawing(false);
|
||||
}
|
||||
let ctx = cc.egui_ctx.clone();
|
||||
let desktop_theme = desktop_theme::DesktopTheme::start(move || ctx.request_repaint());
|
||||
let ctx = cc.egui_ctx.clone();
|
||||
let system_animations = system_animations::SystemAnimations::start(move || ctx.request_repaint());
|
||||
Ok(Box::new(App {
|
||||
desktop_theme,
|
||||
system_animations,
|
||||
cad: app,
|
||||
spacemouse: spacemouse::SpaceMouse::start(cc.egui_ctx.clone()),
|
||||
workspace,
|
||||
workspace_error,
|
||||
Ok(Box::new(App(
|
||||
app,
|
||||
#[cfg(target_os = "macos")]
|
||||
native_menu: None,
|
||||
#[cfg(target_os = "macos")]
|
||||
menus_in_window: vars.no_native_menu == Switch::On,
|
||||
#[cfg(target_os = "macos")]
|
||||
movable: None,
|
||||
}))
|
||||
None,
|
||||
)))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{filters_for_save, follow_desktop, saved_window, window_options};
|
||||
use super::{config_dir_from, control_port_from, filters_for_save};
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn env<'a>(vars: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<OsString> + 'a {
|
||||
move |k| vars.iter().find(|(n, _)| *n == k).map(|(_, v)| OsString::from(v))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_dialog_offers_dwg_with_the_drawing_format_first() {
|
||||
@@ -433,51 +271,52 @@ mod tests {
|
||||
assert_eq!(first("Drawing1.dxf"), "dxf");
|
||||
assert_eq!(first("Drawing1"), "dxf");
|
||||
assert_eq!(first("plan.pdf"), "pdf");
|
||||
assert_eq!(first("office.dwt"), "dwt");
|
||||
for name in ["a.dwg", "b.dxf", "c"] {
|
||||
let exts: Vec<_> = filters_for_save(name).iter().map(|(_, e)| *e).collect();
|
||||
assert!(exts.contains(&"dwg") && exts.contains(&"dxf"), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #1129: the first launch opens centred, not cascaded from the top-left corner.
|
||||
#[test]
|
||||
fn the_first_launch_opens_centred() {
|
||||
let o = window_options(false);
|
||||
assert!(o.centered, "centred on launch");
|
||||
assert!(o.persist_window, "the window state is saved for the next launch");
|
||||
fn control_port_values_that_are_not_ports_are_ignored() {
|
||||
assert_eq!(control_port_from("--control", Some("7979".into())), Some(7979));
|
||||
assert_eq!(control_port_from("--control", Some(" 7979 ".into())), Some(7979));
|
||||
assert_eq!(control_port_from("--control", None), None);
|
||||
for bad in ["", "abc", "-1", "65536", "99999999999999999999", "\u{0}"] {
|
||||
assert_eq!(control_port_from("--control", Some(bad.into())), None, "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #2595: a saved window state (size, position, maximized) is restored, not centred over.
|
||||
#[test]
|
||||
fn a_saved_window_reopens_where_it_was() {
|
||||
let o = window_options(true);
|
||||
assert!(!o.centered, "centring would move a restored window");
|
||||
assert!(o.persist_window);
|
||||
fn the_override_variable_wins_and_empty_values_are_unset() {
|
||||
let vars = [("CADCRAFT_CONFIG_DIR", "/tmp/cc"), ("HOME", "/home/u"), ("XDG_CONFIG_HOME", "/x"), ("APPDATA", "C:\\a")];
|
||||
assert_eq!(config_dir_from(env(&vars)), Some(PathBuf::from("/tmp/cc")));
|
||||
let empty = [("CADCRAFT_CONFIG_DIR", ""), ("HOME", ""), ("XDG_CONFIG_HOME", ""), ("APPDATA", "")];
|
||||
assert_eq!(config_dir_from(env(&empty)), None, "no usable variable means no directory, not the working directory");
|
||||
assert_eq!(config_dir_from(env(&[])), None);
|
||||
}
|
||||
|
||||
/// Only a readable `app.ron` with a window entry counts as a saved window state.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
#[test]
|
||||
fn a_saved_window_is_read_from_app_ron() {
|
||||
assert!(saved_window(r#"{"cadcraft.prefs": "{}", "window": "(maximized: true)"}"#));
|
||||
assert!(!saved_window(r#"{"cadcraft.prefs": "{\"window\":1}"}"#), "a value mentioning it is not the key");
|
||||
assert!(!saved_window(""));
|
||||
assert!(!saved_window("{\"window\": "), "truncated");
|
||||
assert!(!saved_window("not ron at all"));
|
||||
fn linux_and_bsd_follow_xdg_config_home() {
|
||||
assert_eq!(config_dir_from(env(&[("XDG_CONFIG_HOME", "/x"), ("HOME", "/home/u")])), Some(PathBuf::from("/x/cadcraft")));
|
||||
assert_eq!(config_dir_from(env(&[("HOME", "/home/u")])), Some(PathBuf::from("/home/u/.config/cadcraft")));
|
||||
// The XDG spec says a relative XDG_CONFIG_HOME is ignored.
|
||||
assert_eq!(config_dir_from(env(&[("XDG_CONFIG_HOME", "rel"), ("HOME", "/home/u")])), Some(PathBuf::from("/home/u/.config/cadcraft")));
|
||||
}
|
||||
|
||||
/// View ▸ Theme ▸ System on Linux: winit reports no theme, so the desktop's answer stands in;
|
||||
/// without one (or where winit answers and the desktop doesn't) the input is left alone.
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn the_desktop_answers_for_the_system_theme() {
|
||||
let mut raw = egui::RawInput::default();
|
||||
follow_desktop(&mut raw, Some(egui::Theme::Light));
|
||||
assert_eq!(raw.system_theme, Some(egui::Theme::Light));
|
||||
let mut raw = egui::RawInput { system_theme: Some(egui::Theme::Dark), ..Default::default() };
|
||||
follow_desktop(&mut raw, None);
|
||||
assert_eq!(raw.system_theme, Some(egui::Theme::Dark));
|
||||
let mut raw = egui::RawInput::default();
|
||||
follow_desktop(&mut raw, None);
|
||||
assert_eq!(raw.system_theme, None);
|
||||
fn macos_uses_application_support() {
|
||||
assert_eq!(config_dir_from(env(&[("HOME", "/Users/u")])), Some(PathBuf::from("/Users/u/Library/Application Support/CADCraft")));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_uses_appdata() {
|
||||
assert_eq!(
|
||||
config_dir_from(env(&[("APPDATA", "C:\\Users\\u\\AppData\\Roaming")])),
|
||||
Some(PathBuf::from("C:\\Users\\u\\AppData\\Roaming").join("CADCraft"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::str::FromStr;
|
||||
use std::sync::mpsc;
|
||||
|
||||
use cadcraft_ui_egui::menus::{self, Entry};
|
||||
use cadcraft_ui_egui::{CadApp, i18n};
|
||||
@@ -16,14 +15,7 @@ pub struct NativeMenu {
|
||||
submenus: Vec<(String, Submenu)>,
|
||||
predefined: Vec<(&'static str, PredefinedMenuItem)>,
|
||||
language: &'static str,
|
||||
/// Monotonic time of the last enablement refresh. `NEG_INFINITY` makes the first poll refresh
|
||||
/// immediately: `now_ms` starts near zero, so the old `0.0` sentinel suppressed the first
|
||||
/// ~300 ms of menu enablement.
|
||||
last_refresh: f64,
|
||||
/// The recent files File ▸ Open Recent lists now (it is rebuilt when they change).
|
||||
recent: Vec<String>,
|
||||
counter: usize,
|
||||
events: mpsc::Receiver<MenuEvent>,
|
||||
}
|
||||
|
||||
/// Accelerator for "Cmd+Shift+S" (modifier-less shortcuts stay in the app so typing works).
|
||||
@@ -35,16 +27,7 @@ fn accel(sc: &str) -> Option<Accelerator> {
|
||||
}
|
||||
|
||||
impl NativeMenu {
|
||||
/// Build and register the macOS menu (app menu, Settings and the translated command tree); returns the state holding item ids and submenus.
|
||||
pub fn install(app: &mut CadApp, ctx: &egui::Context) -> Self {
|
||||
// A menu choice (or its shortcut, such as Cmd+W) is no window event, so winit would not
|
||||
// wake the app for it: the handler does, and the choice runs in the next frame.
|
||||
let (sender, events) = mpsc::channel();
|
||||
let wake = ctx.clone();
|
||||
MenuEvent::set_event_handler(Some(move |event| {
|
||||
let _ = sender.send(event);
|
||||
wake.request_repaint();
|
||||
}));
|
||||
pub fn install(app: &mut CadApp) -> Self {
|
||||
let menu = Menu::new();
|
||||
let mut items = HashMap::new();
|
||||
let language = app.language();
|
||||
@@ -60,9 +43,7 @@ impl NativeMenu {
|
||||
("Show All", PredefinedMenuItem::show_all(Some(i18n::tr(language, "Show All")))),
|
||||
("Quit CADCraft", PredefinedMenuItem::quit(Some(i18n::tr(language, "Quit CADCraft")))),
|
||||
];
|
||||
// The Mac place for preferences: CADCraft ▸ Settings… (⌘,) opens the Options dialog.
|
||||
let settings = MenuItem::with_id("cc-settings", i18n::tr(language, "Settings..."), true, accel("Cmd+Comma"));
|
||||
let _ = app_menu.append_items(&[&about, &discord, &PredefinedMenuItem::separator(), &settings, &PredefinedMenuItem::separator()]);
|
||||
let _ = app_menu.append_items(&[&about, &discord, &PredefinedMenuItem::separator()]);
|
||||
for (label, item) in &predefined {
|
||||
let _ = app_menu.append(item);
|
||||
if ["Services", "Show All"].contains(label) {
|
||||
@@ -71,59 +52,28 @@ impl NativeMenu {
|
||||
}
|
||||
items.insert("cc-about".into(), ("ui.dialog.about".into(), "About CADCraft".into(), about));
|
||||
items.insert("cc-discord".into(), ("ui.discord".into(), "Join the ArtCraft Discord…".into(), discord));
|
||||
items.insert("cc-settings".into(), ("options".into(), "Settings...".into(), settings));
|
||||
let _ = menu.append(&app_menu);
|
||||
for (title, entries) in menus::tree(app) {
|
||||
let sub = Submenu::new(i18n::tr_menu(language, &title), true);
|
||||
let sub = Submenu::new(i18n::tr(language, &title), true);
|
||||
build(&sub, &entries, &mut items, &mut submenus, &mut counter, language);
|
||||
let _ = menu.append(&sub);
|
||||
submenus.push((title, sub));
|
||||
}
|
||||
menu.init_for_nsapp();
|
||||
Self {
|
||||
_menu: menu,
|
||||
items,
|
||||
submenus,
|
||||
predefined,
|
||||
language,
|
||||
last_refresh: f64::NEG_INFINITY,
|
||||
recent: app.ui.recent_files.clone(),
|
||||
counter,
|
||||
events,
|
||||
}
|
||||
Self { _menu: menu, items, submenus, predefined, language, last_refresh: 0.0 }
|
||||
}
|
||||
|
||||
/// Rebuild File ▸ Open Recent after a drawing was opened or the list was cleared: the native
|
||||
/// menu is built once, and its items name the files by position.
|
||||
fn refresh_recent(&mut self, app: &CadApp) {
|
||||
if self.recent == app.ui.recent_files {
|
||||
return;
|
||||
}
|
||||
self.recent = app.ui.recent_files.clone();
|
||||
let Some(sub) = self.submenus.iter().find(|(label, _)| label == menus::OPEN_RECENT).map(|(_, s)| s.clone()) else { return };
|
||||
for _ in sub.items() {
|
||||
sub.remove_at(0);
|
||||
}
|
||||
let ids: Vec<String> = self.items.iter().filter(|(_, (cmd, _, _))| cmd.starts_with("ui.recent.")).map(|(id, _)| id.clone()).collect();
|
||||
for id in ids {
|
||||
self.items.remove(&id);
|
||||
}
|
||||
build(&sub, &menus::recent_entries(app), &mut self.items, &mut self.submenus, &mut self.counter, self.language);
|
||||
}
|
||||
|
||||
/// Drain queued menu events into commands (Discord opens externally), rebuild Open Recent, retranslate on a language change and refresh enablement when 300 ms have elapsed.
|
||||
pub fn poll(&mut self, app: &mut CadApp, ctx: &egui::Context) {
|
||||
while let Ok(ev) = self.events.try_recv() {
|
||||
while let Ok(ev) = MenuEvent::receiver().try_recv() {
|
||||
if let Some((cmd, _, _)) = self.items.get(ev.id.as_ref()) {
|
||||
if cmd == "ui.discord" {
|
||||
ctx.open_url(egui::OpenUrl::new_tab("https://discord.gg/artcraft"));
|
||||
} else {
|
||||
menus::activate_native(app, ctx, &cmd.clone());
|
||||
menus::activate(app, &cmd.clone());
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
self.refresh_recent(app);
|
||||
let language = app.language();
|
||||
if self.language != language {
|
||||
self.language = language;
|
||||
@@ -134,29 +84,21 @@ impl NativeMenu {
|
||||
item.set_text(i18n::tr(language, label));
|
||||
}
|
||||
for (label, submenu) in &self.submenus {
|
||||
submenu.set_text(i18n::tr_menu(language, label));
|
||||
submenu.set_text(i18n::tr(language, label));
|
||||
}
|
||||
}
|
||||
let now = cadcraft_ui_egui::now_ms();
|
||||
if !refresh_due(now, self.last_refresh) {
|
||||
if now - self.last_refresh < 300.0 {
|
||||
return;
|
||||
}
|
||||
self.last_refresh = now;
|
||||
let typing = menus::text_field_window(ctx).is_some();
|
||||
for (cmd, _, item) in self.items.values() {
|
||||
let en = (typing && menus::edits_text(cmd)) || cadcraft_engine::find_command(cmd).is_none_or(|c| (c.enabled)(&app.session).is_ok());
|
||||
let en = cadcraft_engine::find_command(cmd).is_none_or(|c| (c.enabled)(&app.session).is_ok());
|
||||
item.set_enabled(en);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The periodic enablement refresh is due every 300 ms. Split out so the first-poll sentinel is
|
||||
/// testable without a macOS menu: `now_ms` starts near zero, so a `0.0` initial value would
|
||||
/// suppress the first refresh for 300 ms.
|
||||
fn refresh_due(now: f64, last_refresh: f64) -> bool {
|
||||
now - last_refresh >= 300.0
|
||||
}
|
||||
|
||||
fn build(
|
||||
parent: &Submenu,
|
||||
entries: &[Entry],
|
||||
@@ -168,7 +110,7 @@ fn build(
|
||||
for e in entries {
|
||||
match e {
|
||||
Entry::Sub { label, children } => {
|
||||
let sub = Submenu::new(i18n::tr_menu(language, label), true);
|
||||
let sub = Submenu::new(i18n::tr(language, label), true);
|
||||
build(&sub, children, items, submenus, counter, language);
|
||||
let _ = parent.append(&sub);
|
||||
submenus.push((label.clone(), sub));
|
||||
@@ -183,17 +125,3 @@ fn build(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::refresh_due;
|
||||
|
||||
/// `refresh_due` treats `NEG_INFINITY` as due at any finite time and `0.0` as due once 300 ms have elapsed; `install` initializes a fresh menu with `last_refresh = NEG_INFINITY`.
|
||||
#[test]
|
||||
fn the_first_poll_refreshes_immediately() {
|
||||
assert!(refresh_due(0.5, f64::NEG_INFINITY), "a fresh menu refreshes on its first poll");
|
||||
assert!(!refresh_due(0.5, 0.0), "the old 0.0 sentinel delayed the first refresh");
|
||||
assert!(!refresh_due(299.9, 0.0));
|
||||
assert!(refresh_due(300.0, 0.0));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
//! Native 3Dconnexion input. The reader publishes one latest sample, never an input queue;
|
||||
//! only movement starting/stopping or status/buttons wake egui. Active motion runs at 60 Hz.
|
||||
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
mod motion;
|
||||
|
||||
use cadcraft_ui_egui::{CadApp, spacemouse::Status};
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
use std::time::Duration;
|
||||
use std::{
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Shared {
|
||||
status: Status,
|
||||
device: Option<String>,
|
||||
detail: Option<String>,
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
motion: motion::Motion,
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
last_report: Option<Instant>,
|
||||
buttons: u8,
|
||||
recalibrate: bool,
|
||||
}
|
||||
|
||||
pub struct SpaceMouse {
|
||||
shared: Arc<Mutex<Shared>>,
|
||||
stop: Arc<AtomicBool>,
|
||||
last_frame: Instant,
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
moving: bool,
|
||||
}
|
||||
|
||||
impl SpaceMouse {
|
||||
pub fn start(ctx: egui::Context) -> Self {
|
||||
let shared = Arc::new(Mutex::new(Shared::default()));
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
{
|
||||
let (input, quit, context) = (Arc::clone(&shared), Arc::clone(&stop), ctx.clone());
|
||||
if let Err(error) = std::thread::Builder::new().name("spacemouse-hid".into()).spawn(move || read(input, quit, context)) {
|
||||
set_status(&shared, &ctx, Status::Error, None, Some(error.to_string()));
|
||||
}
|
||||
}
|
||||
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
|
||||
let _ = ctx;
|
||||
Self {
|
||||
shared,
|
||||
stop,
|
||||
last_frame: Instant::now(),
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
moving: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn poll(&mut self, app: &mut CadApp, ctx: &egui::Context) {
|
||||
let now = Instant::now();
|
||||
let elapsed = now.saturating_duration_since(self.last_frame).as_secs_f64().min(0.05);
|
||||
self.last_frame = now;
|
||||
let Ok(mut shared) = self.shared.lock() else { return };
|
||||
if std::mem::take(&mut app.spacemouse.calibrate_requested) {
|
||||
shared.recalibrate = true;
|
||||
}
|
||||
let changed = app.spacemouse.status != shared.status || app.spacemouse.device != shared.device || app.spacemouse.detail != shared.detail;
|
||||
app.spacemouse.status = shared.status;
|
||||
app.spacemouse.device.clone_from(&shared.device);
|
||||
app.spacemouse.detail.clone_from(&shared.detail);
|
||||
if changed && !matches!(shared.status, Status::Unavailable | Status::Searching) {
|
||||
app.set_status(cadcraft_ui_egui::i18n::tr(app.language(), shared.status.message()));
|
||||
}
|
||||
let buttons = std::mem::take(&mut shared.buttons);
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
let motion = if shared.last_report.is_some_and(|at| now.saturating_duration_since(at) < Duration::from_millis(250)) {
|
||||
shared.motion
|
||||
} else {
|
||||
motion::Motion::default()
|
||||
};
|
||||
drop(shared);
|
||||
let allowed = app.spacemouse.settings.enabled
|
||||
&& ctx.input(|i| i.focused)
|
||||
&& !app.ui.start_tab
|
||||
&& app.ui.dialog.is_none()
|
||||
&& app.closing.is_none()
|
||||
&& app.canvas.rect.is_some()
|
||||
&& !egui::Popup::is_any_open(ctx);
|
||||
if allowed && buttons & 1 != 0 {
|
||||
let _ = app.run("zoom", serde_json::json!({"mode": "extents"}));
|
||||
}
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
{
|
||||
let moving = allowed && motion.active();
|
||||
if moving {
|
||||
if self.moving {
|
||||
let settings = app.spacemouse.settings;
|
||||
let rate = if settings.reverse_zoom { -settings.zoom_speed } else { settings.zoom_speed };
|
||||
if let Err(error) = app.run(
|
||||
"ui.spacemouse.navigate",
|
||||
serde_json::json!({
|
||||
"pan": motion.pan.map(|v| v * settings.pan_speed * elapsed),
|
||||
"zoom": (motion.zoom * rate * elapsed).exp()
|
||||
}),
|
||||
) {
|
||||
app.set_status(error);
|
||||
}
|
||||
}
|
||||
ctx.request_repaint_after(Duration::from_millis(16));
|
||||
}
|
||||
self.moving = moving;
|
||||
}
|
||||
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
|
||||
let _ = elapsed;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SpaceMouse {
|
||||
fn drop(&mut self) {
|
||||
self.stop.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
fn set_status(shared: &Mutex<Shared>, ctx: &egui::Context, status: Status, device: Option<String>, detail: Option<String>) {
|
||||
let Ok(mut state) = shared.lock() else { return };
|
||||
let changed = state.status != status || state.device != device || state.detail != detail;
|
||||
if !changed {
|
||||
return;
|
||||
}
|
||||
if let Some(error) = &detail {
|
||||
log::warn!("3D mouse: {error}");
|
||||
}
|
||||
state.status = status;
|
||||
state.device = device;
|
||||
state.detail = detail;
|
||||
state.motion = motion::Motion::default();
|
||||
state.last_report = None;
|
||||
state.buttons = 0;
|
||||
drop(state);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
fn retry(stop: &AtomicBool) {
|
||||
for _ in 0..40 {
|
||||
if stop.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
fn read(shared: Arc<Mutex<Shared>>, stop: Arc<AtomicBool>, ctx: egui::Context) {
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
let mut api = match hidapi::HidApi::new() {
|
||||
Ok(api) => api,
|
||||
Err(error) => {
|
||||
set_status(&shared, &ctx, Status::Error, None, Some(error.to_string()));
|
||||
retry(&stop);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
if let Err(error) = api.refresh_devices() {
|
||||
set_status(&shared, &ctx, Status::Error, None, Some(error.to_string()));
|
||||
break;
|
||||
}
|
||||
let device =
|
||||
api.device_list().find(|device| matches!(device.vendor_id(), 0x046d | 0x256f) && device.usage_page() == 1 && device.usage() == 8);
|
||||
let Some(info) = device else {
|
||||
set_status(&shared, &ctx, Status::Searching, None, None);
|
||||
retry(&stop);
|
||||
continue;
|
||||
};
|
||||
let name = info.product_string().unwrap_or("3Dconnexion SpaceMouse").to_string();
|
||||
match info.open_device(&api) {
|
||||
Ok(device) => {
|
||||
set_status(&shared, &ctx, Status::Calibrating, Some(name.clone()), None);
|
||||
if let Err(error) = read_device(&device, &shared, &stop, &ctx, &name) {
|
||||
set_status(&shared, &ctx, Status::Disconnected, Some(name), Some(error.to_string()));
|
||||
}
|
||||
}
|
||||
Err(error) => set_status(&shared, &ctx, Status::OpenFailed, Some(name), Some(error.to_string())),
|
||||
}
|
||||
retry(&stop);
|
||||
}
|
||||
retry(&stop);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
fn read_device(device: &hidapi::HidDevice, shared: &Mutex<Shared>, stop: &AtomicBool, ctx: &egui::Context, name: &str) -> hidapi::HidResult<()> {
|
||||
let mut filter = motion::Filter::default();
|
||||
let mut buffer = [0; 64];
|
||||
let mut buttons = 0;
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
let recalibrate = if let Ok(mut state) = shared.lock() { std::mem::take(&mut state.recalibrate) } else { return Ok(()) };
|
||||
if recalibrate {
|
||||
filter.recalibrate();
|
||||
set_status(shared, ctx, Status::Calibrating, Some(name.into()), None);
|
||||
}
|
||||
let len = device.read_timeout(&mut buffer, 50)?;
|
||||
let now = Instant::now();
|
||||
let report = buffer.get(..len).and_then(motion::decode);
|
||||
let Ok(mut state) = shared.lock() else { return Ok(()) };
|
||||
let mut wake = false;
|
||||
match report {
|
||||
Some(motion::Report::Buttons(next)) => {
|
||||
let pressed = next & !buttons;
|
||||
buttons = next;
|
||||
state.buttons |= pressed & 1;
|
||||
state.recalibrate |= pressed & 2 != 0;
|
||||
wake = pressed != 0;
|
||||
}
|
||||
Some(motion::Report::Axes { values, mask }) => {
|
||||
let motion = filter.update(values, mask, now);
|
||||
wake = state.motion.active() != motion.active();
|
||||
state.motion = motion;
|
||||
state.last_report = Some(now);
|
||||
if filter.calibrated() && state.status != Status::Connected {
|
||||
state.status = Status::Connected;
|
||||
wake = true;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if state.motion.active() && state.last_report.is_some_and(|at| now.saturating_duration_since(at) >= Duration::from_millis(250)) {
|
||||
state.motion = motion::Motion::default();
|
||||
wake = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(state);
|
||||
if wake {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
//! Bounded, dependency-free decoding and rest calibration of 3Dconnexion HID input reports.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const FULL_SCALE: f64 = 500.0;
|
||||
const DEADZONE: f64 = 0.04;
|
||||
const REST_LIMIT: f64 = 60.0;
|
||||
const REST_RANGE: f64 = 10.0;
|
||||
const CALIBRATION_TIME: Duration = Duration::from_millis(400);
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Report {
|
||||
/// Mask 1 = translation, 2 = rotation, 3 = a combined six-axis report.
|
||||
Axes {
|
||||
values: [i16; 6],
|
||||
mask: u8,
|
||||
},
|
||||
Buttons(u8),
|
||||
}
|
||||
|
||||
/// hidapi includes the report ID. A legacy motion report has six payload bytes; combined
|
||||
/// report 1 has twelve. Windows may pad the short button report to the input-report size.
|
||||
pub fn decode(bytes: &[u8]) -> Option<Report> {
|
||||
let (&id, payload) = bytes.split_first()?;
|
||||
if id == 3 {
|
||||
return payload.first().copied().map(Report::Buttons);
|
||||
}
|
||||
let (mask, offset, count) = match id {
|
||||
1 if payload.len() >= 12 => (3, 0, 6),
|
||||
1 if payload.len() >= 6 => (1, 0, 3),
|
||||
2 if payload.len() >= 6 => (2, 3, 3),
|
||||
_ => return None,
|
||||
};
|
||||
let mut values = [0; 6];
|
||||
for (out, pair) in values.iter_mut().skip(offset).take(count).zip(payload.as_chunks::<2>().0.iter()) {
|
||||
*out = i16::from_le_bytes([*pair.first()?, *pair.get(1)?]);
|
||||
}
|
||||
Some(Report::Axes { values, mask })
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Motion {
|
||||
pub pan: [f64; 2],
|
||||
pub zoom: f64,
|
||||
}
|
||||
|
||||
impl Motion {
|
||||
pub fn active(self) -> bool {
|
||||
self.pan.iter().any(|v| *v != 0.0) || self.zoom != 0.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Rest {
|
||||
since: Option<Instant>,
|
||||
count: u32,
|
||||
sum: [f64; 6],
|
||||
min: [f64; 6],
|
||||
max: [f64; 6],
|
||||
}
|
||||
|
||||
impl Rest {
|
||||
fn observe(&mut self, raw: [f64; 6], now: Instant) -> Option<[f64; 6]> {
|
||||
if raw.iter().any(|v| v.abs() > REST_LIMIT) {
|
||||
*self = Self::default();
|
||||
return None;
|
||||
}
|
||||
if self.since.is_none() || raw.iter().zip(self.min.iter().zip(&self.max)).any(|(v, (min, max))| v.max(*max) - v.min(*min) > REST_RANGE) {
|
||||
*self = Self { since: Some(now), count: 1, sum: raw, min: raw, max: raw };
|
||||
return None;
|
||||
}
|
||||
for ((min, max), value) in self.min.iter_mut().zip(&mut self.max).zip(raw) {
|
||||
*min = min.min(value);
|
||||
*max = max.max(value);
|
||||
}
|
||||
// A fixed bound even if a device sends far more than the expected 125 reports/sec.
|
||||
if self.count < 128 {
|
||||
self.count += 1;
|
||||
for (sum, value) in self.sum.iter_mut().zip(raw) {
|
||||
*sum += value;
|
||||
}
|
||||
}
|
||||
if self.count >= 16 && self.since.is_some_and(|since| now.saturating_duration_since(since) >= CALIBRATION_TIME) {
|
||||
Some(self.sum.map(|sum| sum / f64::from(self.count)))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Filter {
|
||||
raw: [f64; 6],
|
||||
bias: Option<[f64; 6]>,
|
||||
rest: Rest,
|
||||
parts: u8,
|
||||
seen: [Option<Instant>; 2],
|
||||
idle_since: Option<Instant>,
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
pub fn calibrated(&self) -> bool {
|
||||
self.bias.is_some()
|
||||
}
|
||||
|
||||
pub fn recalibrate(&mut self) {
|
||||
*self = Self::default();
|
||||
}
|
||||
|
||||
fn update_drift(&mut self, complete: bool, now: Instant, bias: &mut [f64; 6]) {
|
||||
if !complete {
|
||||
return;
|
||||
}
|
||||
if self.raw.iter().zip(*bias).all(|(raw, b)| (*raw - b).abs() <= FULL_SCALE * DEADZONE) {
|
||||
let since = *self.idle_since.get_or_insert(now);
|
||||
if now.saturating_duration_since(since) >= Duration::from_secs(2) {
|
||||
for (b, raw) in bias.iter_mut().zip(self.raw) {
|
||||
*b += (raw - *b) * 0.01;
|
||||
}
|
||||
self.bias = Some(*bias);
|
||||
}
|
||||
} else {
|
||||
self.idle_since = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update(&mut self, values: [i16; 6], mask: u8, now: Instant) -> Motion {
|
||||
for (index, seen) in self.seen.iter_mut().enumerate() {
|
||||
if mask & (1 << index) != 0 {
|
||||
*seen = Some(now);
|
||||
}
|
||||
}
|
||||
for (index, (raw, value)) in self.raw.iter_mut().zip(values).enumerate() {
|
||||
if mask & if index < 3 { 1 } else { 2 } != 0 {
|
||||
*raw = f64::from(value);
|
||||
}
|
||||
}
|
||||
self.parts |= mask & 3;
|
||||
let complete = self.parts == 3;
|
||||
if complete {
|
||||
self.parts = 0;
|
||||
}
|
||||
if self.bias.is_none() && complete {
|
||||
self.bias = self.rest.observe(self.raw, now);
|
||||
}
|
||||
let Some(mut bias) = self.bias else { return Motion::default() };
|
||||
// Follow small resting drift only after two seconds entirely inside the deadzone.
|
||||
self.update_drift(complete, now, &mut bias);
|
||||
let mut axes = [0.0; 6];
|
||||
for ((axis, raw), bias) in axes.iter_mut().zip(self.raw).zip(bias) {
|
||||
let value = ((raw - bias) / FULL_SCALE).clamp(-1.0, 1.0);
|
||||
*axis = if value.abs() <= DEADZONE { 0.0 } else { value.signum() * (value.abs() - DEADZONE) / (1.0 - DEADZONE) };
|
||||
}
|
||||
// A missing rotation report must not keep zooming while translations still arrive.
|
||||
for (group, seen) in axes.as_chunks_mut::<3>().0.iter_mut().zip(self.seen) {
|
||||
if seen.is_none_or(|at| now.saturating_duration_since(at) >= Duration::from_millis(250)) {
|
||||
group.fill(0.0);
|
||||
}
|
||||
}
|
||||
let [tx, ty, tz, _rx, _ry, rz] = axes;
|
||||
Motion { pan: [tx, ty], zoom: (tz + rz).clamp(-1.0, 1.0) }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn decode_reports() {
|
||||
assert_eq!(decode(&[]), None);
|
||||
assert_eq!(decode(&[3]), None);
|
||||
assert_eq!(decode(&[3, 5]), Some(Report::Buttons(5)));
|
||||
let values = [1_i16, -2, 3, 4, -5, 6];
|
||||
let mut bytes = vec![1_u8];
|
||||
for v in values {
|
||||
bytes.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
assert_eq!(decode(&bytes), Some(Report::Axes { values, mask: 3 }));
|
||||
bytes[0] = 2;
|
||||
assert_eq!(decode(&bytes), Some(Report::Axes { values: [0, 0, 0, 1, -2, 3], mask: 2 }));
|
||||
assert_eq!(decode(&[2, 1, 2, 3]), None, "too short for a rotation report");
|
||||
}
|
||||
|
||||
/// Sixteen complete reports at rest, spread over more than CALIBRATION_TIME, all stamped
|
||||
/// on a made-up point in time big enough that they sit in the past of later updates.
|
||||
fn calibrated(t0: Instant) -> Filter {
|
||||
let mut f = Filter::default();
|
||||
for i in 0..17 {
|
||||
let at = t0 + Duration::from_millis(50 * i);
|
||||
assert_eq!(f.update([0; 6], 1, at), Motion::default());
|
||||
assert_eq!(f.update([0; 6], 2, at), Motion::default());
|
||||
}
|
||||
assert!(f.calibrated());
|
||||
f
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn axes_deflect_and_expire() {
|
||||
let t0 = Instant::now();
|
||||
let mut f = calibrated(t0);
|
||||
let at = t0 + Duration::from_secs(2);
|
||||
let ms = Duration::from_millis;
|
||||
let m = f.update([500, 500, 0, 0, 0, 0], 1, at);
|
||||
assert!((m.pan[0] - 1.0).abs() < 1e-9);
|
||||
assert!((m.pan[1] - 1.0).abs() < 1e-9);
|
||||
assert_eq!(m.zoom, 0.0, "no rotation input");
|
||||
// A fresh rotation report drives the zoom.
|
||||
let m = f.update([0, 0, 0, 0, 0, 500], 2, at + ms(100));
|
||||
assert!((m.zoom - 1.0).abs() < 1e-9);
|
||||
// The rotation axes went quiet for over 250 ms while translations still arrive.
|
||||
let m = f.update([500, 500, 0, 0, 0, 0], 1, at + ms(400));
|
||||
assert_eq!(m.zoom, 0.0);
|
||||
// A fresh rotation report puts the zoom back.
|
||||
let m = f.update([500, 500, 0, 0, 0, 500], 2, at + ms(500));
|
||||
assert!((m.zoom - 1.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drift_tracks_rest_after_two_idle_seconds() {
|
||||
// Five axes units of drift sit inside the deadzone (FULL_SCALE * DEADZONE = 20).
|
||||
let mut f = Filter { raw: [5.0; 6], bias: Some([0.0; 6]), ..Filter::default() };
|
||||
let t0 = Instant::now();
|
||||
let mut b = [0.0; 6];
|
||||
f.update_drift(true, t0, &mut b); // starts the idle window: no shift yet
|
||||
assert_eq!(b, [0.0; 6]);
|
||||
f.update_drift(true, t0 + Duration::from_secs(3), &mut b);
|
||||
assert!((b[0] - 0.05).abs() < 1e-12, "bias follows resting drift");
|
||||
assert_eq!(f.bias, Some(b));
|
||||
// Motion outside the deadzone resets the idle window.
|
||||
f.raw = [100.0; 6];
|
||||
f.update_drift(true, t0 + Duration::from_secs(4), &mut b);
|
||||
assert_eq!(f.idle_since, None);
|
||||
f.update_drift(true, t0 + Duration::from_secs(5), &mut b);
|
||||
assert!((b[0] - 0.05).abs() < 1e-12, "no shift before two seconds at rest");
|
||||
}
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
//! Whether the operating system shows animations, for View ▸ Accessibility ▸ Animation Effects ▸
|
||||
//! Use System Setting.
|
||||
//!
|
||||
//! egui animates whatever the OS says (windows and menus fade in, wheel scrolling glides), and
|
||||
//! winit reports no such setting. This asks the OS itself:
|
||||
//!
|
||||
//! - Windows: Settings ▸ Accessibility ▸ Visual effects ▸ Animation effects.
|
||||
//! - macOS: System Settings ▸ Accessibility ▸ Reduce motion.
|
||||
//! - Linux and the BSDs: the XDG Desktop Portal's Settings interface, as for the theme
|
||||
//! (`desktop_theme.rs`): its `reduced-motion`, or GNOME's own `enable-animations`.
|
||||
//!
|
||||
//! The answer goes to `CadApp::system_animations` once a frame.
|
||||
|
||||
use std::sync::mpsc::{Receiver, TryRecvError, channel};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// How often the OS is asked again, so a change to its setting is followed while CADCraft runs.
|
||||
const POLL: Duration = Duration::from_secs(3);
|
||||
|
||||
/// Watches the OS setting.
|
||||
#[derive(Default)]
|
||||
pub struct SystemAnimations {
|
||||
/// The watcher thread's answers, where asking can block (the portal); `None` elsewhere, or
|
||||
/// when no thread could be started.
|
||||
changes: Option<Receiver<Option<bool>>>,
|
||||
/// The OS's last answer.
|
||||
current: Option<bool>,
|
||||
/// When the OS was last asked from the UI thread, where asking is one quick call.
|
||||
asked: Option<Instant>,
|
||||
}
|
||||
|
||||
impl SystemAnimations {
|
||||
/// Start watching. Where a thread does the asking, `wake` is called after each answer, so an
|
||||
/// idle window repaints and picks it up.
|
||||
pub fn start(wake: impl Fn() + Send + 'static) -> Self {
|
||||
if !ASKS_THE_PORTAL {
|
||||
return Self::default();
|
||||
}
|
||||
let (tx, rx) = channel();
|
||||
let started = std::thread::Builder::new().name("cadcraft-system-animations".into()).spawn(move || {
|
||||
let mut last = read();
|
||||
if tx.send(last).is_err() {
|
||||
return;
|
||||
}
|
||||
wake();
|
||||
loop {
|
||||
std::thread::sleep(POLL);
|
||||
let now = read();
|
||||
if now != last {
|
||||
last = now;
|
||||
if tx.send(now).is_err() {
|
||||
return;
|
||||
}
|
||||
wake();
|
||||
}
|
||||
}
|
||||
});
|
||||
Self { changes: started.ok().map(|_| rx), ..Self::default() }
|
||||
}
|
||||
|
||||
/// Whether the OS shows animations; `None` when it states nothing or there is nobody to ask.
|
||||
/// Polled once a frame; an OS that stops answering keeps its last answer.
|
||||
pub fn current(&mut self) -> Option<bool> {
|
||||
if let Some(changes) = &self.changes {
|
||||
loop {
|
||||
match changes.try_recv() {
|
||||
Ok(Some(animate)) => self.current = Some(animate),
|
||||
Ok(None) => {}
|
||||
Err(TryRecvError::Empty) | Err(TryRecvError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
} else if !ASKS_THE_PORTAL && self.asked.is_none_or(|at| at.elapsed() >= POLL) {
|
||||
self.asked = Some(Instant::now());
|
||||
if let Some(animate) = read() {
|
||||
self.current = Some(animate);
|
||||
}
|
||||
}
|
||||
self.current
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the answer comes over D-Bus, which can block: a thread asks. Elsewhere the UI thread asks.
|
||||
const ASKS_THE_PORTAL: bool = cfg!(all(unix, not(target_os = "macos")));
|
||||
|
||||
/// Ask Windows once: `UISettings::AnimationsEnabled`, the setting behind
|
||||
/// `SPI_GETCLIENTAREAANIMATION`.
|
||||
#[cfg(windows)]
|
||||
fn read() -> Option<bool> {
|
||||
windows::UI::ViewManagement::UISettings::new().and_then(|settings| settings.AnimationsEnabled()).ok()
|
||||
}
|
||||
|
||||
/// Ask macOS once. Called on the main thread (`SystemAnimations::current`).
|
||||
#[cfg(target_os = "macos")]
|
||||
fn read() -> Option<bool> {
|
||||
Some(!objc2_app_kit::NSWorkspace::sharedWorkspace().accessibilityDisplayShouldReduceMotion())
|
||||
}
|
||||
|
||||
/// Ask the portal once. D-Bus calls block, so this belongs off the UI thread: the watcher's
|
||||
/// thread is its only caller.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn read() -> Option<bool> {
|
||||
const PORTAL: &str = "org.freedesktop.portal.Desktop";
|
||||
const PATH: &str = "/org/freedesktop/portal/desktop";
|
||||
const SETTINGS: &str = "org.freedesktop.portal.Settings";
|
||||
// The standard key first (0 no preference, 1 reduced motion); then GNOME's own switch, which
|
||||
// its portal answered before the standard key existed.
|
||||
const KEYS: [(&str, &str); 2] = [("org.freedesktop.appearance", "reduced-motion"), ("org.gnome.desktop.interface", "enable-animations")];
|
||||
let connection = zbus::blocking::Connection::session().ok()?;
|
||||
for (namespace, key) in KEYS {
|
||||
// `ReadOne` (Settings version 2) answers the value; older portals only have `Read`, which
|
||||
// wraps it in one more variant. `parse` unwraps either.
|
||||
let reply = connection
|
||||
.call_method(Some(PORTAL), PATH, Some(SETTINGS), "ReadOne", &(namespace, key))
|
||||
.or_else(|_| connection.call_method(Some(PORTAL), PATH, Some(SETTINGS), "Read", &(namespace, key)));
|
||||
let Ok(reply) = reply else { continue };
|
||||
let Ok((answer,)) = reply.body().deserialize::<(zbus::zvariant::OwnedValue,)>() else { continue };
|
||||
if let Some(animate) = parse(&answer, 0) {
|
||||
return Some(animate);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The portal's answer as "show animations": `reduced-motion` is a number (1 asks for less
|
||||
/// motion; 0 and values from a later version state no preference), `enable-animations` a
|
||||
/// boolean. `None` when it reads as neither. Variants are unwrapped a few levels deep.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn parse(value: &zbus::zvariant::Value<'_>, depth: u8) -> Option<bool> {
|
||||
use zbus::zvariant::Value;
|
||||
match value {
|
||||
Value::U32(reduced) => Some(*reduced != 1),
|
||||
Value::Bool(enabled) => Some(*enabled),
|
||||
Value::Value(inner) if depth < 4 => parse(inner, depth + 1),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn read() -> Option<bool> {
|
||||
None
|
||||
}
|
||||
|
||||
// macOS is asked on the main thread only, so there is nothing to test off it.
|
||||
#[cfg(all(test, not(target_os = "macos")))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Windows is asked on the first frame, then not again until the poll interval has passed.
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn the_os_is_asked_on_the_first_frame_and_its_answer_kept() {
|
||||
let mut watch = SystemAnimations::start(|| {});
|
||||
assert!(watch.changes.is_none(), "no thread: asking is one quick call");
|
||||
let first = watch.current();
|
||||
assert_eq!(first, read());
|
||||
let asked = watch.asked;
|
||||
assert!(asked.is_some());
|
||||
assert_eq!(watch.current(), first);
|
||||
assert_eq!(watch.asked, asked, "not asked again within the poll interval");
|
||||
}
|
||||
|
||||
/// The standard key answers a number, GNOME's switch a boolean; either may come wrapped in
|
||||
/// variants (the older `Read`).
|
||||
#[test]
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn the_portal_answer_reads_as_show_animations_or_as_none() {
|
||||
use zbus::zvariant::Value;
|
||||
assert_eq!(parse(&Value::U32(1), 0), Some(false));
|
||||
assert_eq!(parse(&Value::U32(0), 0), Some(true));
|
||||
assert_eq!(parse(&Value::U32(7), 0), Some(true), "unknown values are no preference");
|
||||
assert_eq!(parse(&Value::Bool(false), 0), Some(false));
|
||||
assert_eq!(parse(&Value::Bool(true), 0), Some(true));
|
||||
assert_eq!(parse(&Value::Value(Box::new(Value::U32(1))), 0), Some(false));
|
||||
assert_eq!(parse(&Value::Str("off".into()), 0), None);
|
||||
// A hostile answer nested without end is given up on.
|
||||
let mut nested = Value::U32(1);
|
||||
for _ in 0..16 {
|
||||
nested = Value::Value(Box::new(nested));
|
||||
}
|
||||
assert_eq!(parse(&nested, 0), None);
|
||||
}
|
||||
}
|
||||
@@ -1,404 +0,0 @@
|
||||
//! Native workspace preferences. Drawing data and transient dialogs never enter this file.
|
||||
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use cadcraft_ui_egui::UiState;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const MAX_BYTES: u64 = 64 * 1024;
|
||||
/// Dragging a floating window changes its rect every frame; writes (each with an fsync) are
|
||||
/// spaced at least this far apart, and the newest state is written when the interval ends.
|
||||
const MIN_WRITE_INTERVAL: Duration = Duration::from_millis(500);
|
||||
static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
pub struct Workspace {
|
||||
path: Option<PathBuf>,
|
||||
last_attempt: Option<Value>,
|
||||
read_failed: bool,
|
||||
pending_write_error: Option<String>,
|
||||
last_write: Option<Instant>,
|
||||
}
|
||||
|
||||
fn snapshot(ui: &UiState) -> Result<Value, String> {
|
||||
cadcraft_ui_egui::docking::validate(&ui.docking)?;
|
||||
if !matches!(ui.toolset_tab.as_str(), "Drafting" | "Modeling") {
|
||||
return Err("toolsetTab must be Drafting or Modeling".into());
|
||||
}
|
||||
if ui.collapsed_groups.len() > 64 || ui.collapsed_groups.iter().any(|name| name.len() > 256) {
|
||||
return Err("collapsedGroups exceeds the workspace size limit".into());
|
||||
}
|
||||
if ui.history_lines > 12 {
|
||||
return Err("historyLines must be between 0 and 12".into());
|
||||
}
|
||||
Ok(json!({
|
||||
"docking": ui.docking,
|
||||
"dockingVersion": cadcraft_ui_egui::docking::LAYOUT_VERSION,
|
||||
"showToolsets": ui.show_toolsets,
|
||||
"showPalettes": ui.show_palettes,
|
||||
"showToolbar": ui.show_toolbar,
|
||||
"showFileTabs": ui.show_file_tabs,
|
||||
"showStatusBar": ui.show_status_bar,
|
||||
"showCommandLine": ui.show_command_line,
|
||||
"showViewcube": ui.show_viewcube,
|
||||
"showUcsIcon": ui.show_ucs_icon,
|
||||
"showLayerList": ui.show_layer_list,
|
||||
"toolsetTab": ui.toolset_tab,
|
||||
"collapsedGroups": ui.collapsed_groups,
|
||||
"propertiesAll": ui.properties_all,
|
||||
"inWindowMenu": ui.in_window_menu,
|
||||
"historyLines": ui.history_lines,
|
||||
}))
|
||||
}
|
||||
|
||||
fn read(path: &Path) -> Result<Option<UiState>, String> {
|
||||
let file = match File::open(path) {
|
||||
Ok(file) => file,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(format!("{}: {error}", path.display())),
|
||||
};
|
||||
let mut bytes = Vec::new();
|
||||
file.take(MAX_BYTES + 1).read_to_end(&mut bytes).map_err(|error| format!("{}: {error}", path.display()))?;
|
||||
if bytes.len() as u64 > MAX_BYTES {
|
||||
return Err(format!("{}: workspace file exceeds 64 KiB", path.display()));
|
||||
}
|
||||
let value: Value = serde_json::from_slice(&bytes).map_err(|error| format!("{}: invalid workspace JSON: {error}", path.display()))?;
|
||||
if value.get("version").and_then(Value::as_u64) != Some(1) {
|
||||
return Err(format!("{}: unsupported workspace version", path.display()));
|
||||
}
|
||||
let workspace =
|
||||
value.get("workspace").filter(|value| value.is_object()).ok_or_else(|| format!("{}: missing workspace object", path.display()))?;
|
||||
// Dock schemas can evolve independently of the other saved workspace preferences.
|
||||
// Recover only this optional field; runtime UiState deserialization remains strict.
|
||||
// A layout from before the current shape (split frames, saved without `dockingVersion`)
|
||||
// gives way to the default, its hidden panels with it.
|
||||
let mut fields = workspace.clone();
|
||||
let (docking, version) = match fields.as_object_mut() {
|
||||
Some(fields) => (fields.remove("docking"), fields.remove("dockingVersion")),
|
||||
None => (None, None),
|
||||
};
|
||||
let current = version.and_then(|version| version.as_u64()).is_some_and(|version| version >= u64::from(cadcraft_ui_egui::docking::LAYOUT_VERSION));
|
||||
let mut ui: UiState = serde_json::from_value(fields).map_err(|error| format!("{}: invalid workspace: {error}", path.display()))?;
|
||||
if let Some(docking) = docking.filter(|_| current)
|
||||
&& let Ok(docking) = serde_json::from_value::<cadcraft_ui_egui::docking::Workspace>(docking)
|
||||
&& cadcraft_ui_egui::docking::validate(&docking).is_ok()
|
||||
{
|
||||
ui.docking = docking;
|
||||
}
|
||||
// Old or hand-edited files cannot reopen a dialog or replace the active drawing view.
|
||||
ui.dialog = None;
|
||||
ui.start_tab = false;
|
||||
snapshot(&ui).map_err(|error| format!("{}: {error}", path.display()))?;
|
||||
Ok(Some(ui))
|
||||
}
|
||||
|
||||
fn write(path: &Path, workspace: &Value) -> Result<(), String> {
|
||||
let bytes = serde_json::to_vec_pretty(&json!({"version": 1, "workspace": workspace})).map_err(|error| error.to_string())?;
|
||||
if bytes.len() as u64 > MAX_BYTES {
|
||||
return Err("workspace exceeds 64 KiB".into());
|
||||
}
|
||||
let parent = path.parent().ok_or("workspace path has no parent")?;
|
||||
fs::create_dir_all(parent).map_err(|error| format!("{}: {error}", parent.display()))?;
|
||||
let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
|
||||
let temporary = parent.join(format!(".workspace-{}-{sequence}.tmp", std::process::id()));
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600);
|
||||
}
|
||||
let mut file = options.open(&temporary).map_err(|error| format!("{}: {error}", temporary.display()))?;
|
||||
let result = (|| {
|
||||
file.write_all(&bytes)?;
|
||||
file.sync_all()?;
|
||||
drop(file);
|
||||
fs::rename(&temporary, path)
|
||||
})();
|
||||
if let Err(error) = result {
|
||||
let _ = fs::remove_file(&temporary);
|
||||
return Err(format!("{}: could not save workspace: {error}", path.display()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl Workspace {
|
||||
/// The workspace file in the settings directory `config_dir`; without one nothing is kept.
|
||||
pub fn load(config_dir: Option<&Path>) -> (Self, Option<UiState>, Option<String>) {
|
||||
Self::at(config_dir.map(|root| root.join("workspace.json")))
|
||||
}
|
||||
|
||||
fn at(path: Option<PathBuf>) -> (Self, Option<UiState>, Option<String>) {
|
||||
let result = path.as_deref().map(read).transpose().map(Option::flatten);
|
||||
match result {
|
||||
Ok(ui) => {
|
||||
let initial = ui.as_ref().cloned().unwrap_or_default();
|
||||
(Self { path, last_attempt: snapshot(&initial).ok(), read_failed: false, pending_write_error: None, last_write: None }, ui, None)
|
||||
}
|
||||
Err(error) => (
|
||||
Self { path, last_attempt: None, read_failed: true, pending_write_error: None, last_write: None },
|
||||
None,
|
||||
Some(format!("Workspace could not be restored; its file was left unchanged. {error}")),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes `ui` if it differs from the last state written. `Ok(Some(wait))` means a change
|
||||
/// is held back by [`MIN_WRITE_INTERVAL`]; call again after `wait` to write it.
|
||||
pub fn save_if_changed(&mut self, ui: &UiState, now: Instant) -> Result<Option<Duration>, String> {
|
||||
let Some(path) = &self.path else { return Ok(None) };
|
||||
if self.read_failed {
|
||||
// Preserve a corrupt, oversized or newer-version file until the user repairs it.
|
||||
return Ok(None);
|
||||
}
|
||||
let value = snapshot(ui)?;
|
||||
if self.last_attempt.as_ref() == Some(&value) {
|
||||
return self.pending_write_error.clone().map_or(Ok(None), Err);
|
||||
}
|
||||
if let Some(wait) = self.last_write.and_then(|last| MIN_WRITE_INTERVAL.checked_sub(now.saturating_duration_since(last)))
|
||||
&& !wait.is_zero()
|
||||
{
|
||||
return Ok(Some(wait));
|
||||
}
|
||||
// Keep a failed state's warning without retrying disk I/O every rendered frame.
|
||||
self.last_attempt = Some(value.clone());
|
||||
self.last_write = Some(now);
|
||||
let result = write(path, &value);
|
||||
self.pending_write_error = result.as_ref().err().cloned();
|
||||
result.map(|()| None)
|
||||
}
|
||||
|
||||
pub fn save_on_exit(&mut self, ui: &UiState) -> Result<(), String> {
|
||||
if self.pending_write_error.is_some() {
|
||||
self.last_attempt = None;
|
||||
}
|
||||
self.last_write = None;
|
||||
self.save_if_changed(ui, Instant::now()).map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::ffi::OsString;
|
||||
|
||||
fn temp_dir() -> PathBuf {
|
||||
let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
|
||||
let path = std::env::temp_dir().join(format!("cadcraft-workspace-test-{}-{sequence}", std::process::id()));
|
||||
fs::create_dir_all(&path).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_override_isolated_or_explicitly_disabled() {
|
||||
let vars = |config: Option<&'static str>| {
|
||||
move |key: &str| match key {
|
||||
"CADCRAFT_CONFIG_DIR" => config.map(OsString::from),
|
||||
"HOME" | "APPDATA" | "XDG_CONFIG_HOME" => Some(OsString::from("/unrelated-user-profile")),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
let dir = |config| crate::env::Env::from_vars(vars(config)).config_dir;
|
||||
assert_eq!(dir(Some("/private/test-state")), Some(PathBuf::from("/private/test-state")));
|
||||
assert_eq!(dir(Some("")), None);
|
||||
let (_, restored, error) = Workspace::load(dir(Some("")).as_deref());
|
||||
assert!(restored.is_none() && error.is_none(), "no settings folder, no workspace file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_survives_new_store_without_transient_or_document_state() {
|
||||
let directory = temp_dir();
|
||||
let path = directory.join("workspace.json");
|
||||
let (mut first, restored, error) = Workspace::at(Some(path.clone()));
|
||||
assert!(restored.is_none() && error.is_none());
|
||||
let ui = UiState {
|
||||
show_palettes: false,
|
||||
show_command_line: false,
|
||||
toolset_tab: "Modeling".into(),
|
||||
collapsed_groups: vec!["Solid".into()],
|
||||
history_lines: 5,
|
||||
dialog: Some("layers".into()),
|
||||
start_tab: true,
|
||||
..Default::default()
|
||||
};
|
||||
first.save_if_changed(&ui, Instant::now()).unwrap();
|
||||
let bytes = fs::read(&path).unwrap();
|
||||
let value: Value = serde_json::from_slice(&bytes).unwrap();
|
||||
assert!(value["workspace"].get("dialog").is_none());
|
||||
assert!(value["workspace"].get("startTab").is_none());
|
||||
let (_, restored, error) = Workspace::at(Some(path.clone()));
|
||||
assert!(error.is_none());
|
||||
let restored = restored.unwrap();
|
||||
assert_eq!(snapshot(&restored).unwrap(), snapshot(&ui).unwrap());
|
||||
assert!(restored.dialog.is_none() && !restored.start_tab);
|
||||
let mut transient_only = ui.clone();
|
||||
transient_only.dialog = None;
|
||||
transient_only.start_tab = false;
|
||||
first.save_if_changed(&transient_only, Instant::now()).unwrap();
|
||||
assert_eq!(fs::read(&path).unwrap(), bytes);
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dock_columns_floating_and_hidden_panels_survive_native_workspace_file_reload() {
|
||||
use cadcraft_ui_egui::docking::{LAYOUT_VERSION, Panel, permissions, reveal_panel};
|
||||
let directory = temp_dir();
|
||||
let path = directory.join("workspace.json");
|
||||
let (mut store, _, _) = Workspace::at(Some(path.clone()));
|
||||
let mut ui = UiState::default();
|
||||
let mut app = cadcraft_ui_egui::CadApp::new(cadcraft_engine::Session::default(), Default::default());
|
||||
app.run("ui.dock", json!({"operation":"move", "panel":"layers", "target":"properties", "zone":"left"})).unwrap();
|
||||
app.run("ui.dock", json!({"operation":"float", "panel":"properties", "rect":[120,90,340,410]})).unwrap();
|
||||
ui.docking = app.ui.docking;
|
||||
ui.docking.set_visible(Panel::ToolSets, false, None, permissions).unwrap();
|
||||
ui.show_toolsets = false;
|
||||
store.save_if_changed(&ui, Instant::now()).unwrap();
|
||||
let saved: Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
|
||||
assert_eq!(saved["workspace"]["dockingVersion"], json!(LAYOUT_VERSION));
|
||||
let (_, reloaded, error) = Workspace::at(Some(path));
|
||||
assert!(error.is_none());
|
||||
let mut reloaded = reloaded.unwrap();
|
||||
assert_eq!(serde_json::to_value(&reloaded.docking).unwrap(), serde_json::to_value(&ui.docking).unwrap());
|
||||
assert!(!reloaded.show_toolsets);
|
||||
reveal_panel(&mut reloaded.docking, Panel::ToolSets).unwrap();
|
||||
let tools = reloaded.docking.layout.location(&Panel::ToolSets).unwrap();
|
||||
assert_eq!(serde_json::to_value(&tools.group).unwrap()["column"]["side"], json!("Start"), "{tools:?}");
|
||||
assert_eq!(reloaded.docking.layout.floating[0].rect, [120.0, 90.0, 340.0, 410.0]);
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_frame_layout_file_loads_as_the_dock_default_with_its_other_settings() {
|
||||
use cadcraft_ui_egui::docking::default_workspace;
|
||||
// Written as CADCraft 0.7 writes it: craft-ui's v1 layout with no docking version, a
|
||||
// floating window as one `{panels, active, rect}` group, Tool Sets closed.
|
||||
let file = r#"{"version":1,"workspace":{
|
||||
"docking":{
|
||||
"layout":{
|
||||
"root":{"Split":{"axis":"Horizontal","size":{"FixedSecond":300.0},
|
||||
"first":{"Tabs":{"panels":["canvas"],"active":0}},
|
||||
"second":{"Stack":{"entries":[{"panel":"properties","open":false,"height":null}]}}}},
|
||||
"floating":[{"panels":["layers","externalReferences"],"active":1,"rect":[120.0,90.0,340.0,410.0]}]},
|
||||
"hidden":[["toolSets",{"floating":null,"anchor":"canvas","placement":{"Split":"Left"},
|
||||
"size":{"FixedFirst":220.0},"stack":null,"sibling":["canvas","properties"]}]]},
|
||||
"showToolsets":false,"showPalettes":true,"showToolbar":false,"toolsetTab":"Modeling","historyLines":4}}"#;
|
||||
let directory = temp_dir();
|
||||
let path = directory.join("workspace.json");
|
||||
fs::write(&path, file).unwrap();
|
||||
let (_, restored, error) = Workspace::at(Some(path));
|
||||
assert!(error.is_none(), "{error:?}");
|
||||
let ui = restored.unwrap();
|
||||
assert_eq!(ui.toolset_tab, "Modeling");
|
||||
assert_eq!(ui.history_lines, 4);
|
||||
assert!(!ui.show_toolsets && ui.show_palettes && !ui.show_toolbar);
|
||||
assert_eq!(ui.docking.layout, default_workspace().layout);
|
||||
assert!(ui.docking.hidden.is_empty(), "{:?}", ui.docking.hidden);
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_oversized_and_newer_state_are_reported_and_preserved() {
|
||||
let directory = temp_dir();
|
||||
let path = directory.join("workspace.json");
|
||||
for bytes in [
|
||||
b"{invalid".to_vec(),
|
||||
vec![b' '; MAX_BYTES as usize + 1],
|
||||
br#"{"version":2,"workspace":{}}"#.to_vec(),
|
||||
br#"{"version":1,"workspace":{"toolsetTab":"unknown"}}"#.to_vec(),
|
||||
br#"{"version":1,"workspace":{"historyLines":18446744073709551615}}"#.to_vec(),
|
||||
] {
|
||||
fs::write(&path, &bytes).unwrap();
|
||||
let (mut store, ui, error) = Workspace::at(Some(path.clone()));
|
||||
assert!(ui.is_none());
|
||||
assert!(error.as_deref().is_some_and(|error| error.contains("left unchanged")));
|
||||
store.save_if_changed(&UiState { show_palettes: false, ..Default::default() }, Instant::now()).unwrap();
|
||||
assert_eq!(fs::read(&path).unwrap(), bytes);
|
||||
}
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_docking_preserves_other_workspace_preferences() {
|
||||
let directory = temp_dir();
|
||||
let path = directory.join("workspace.json");
|
||||
for docking in [
|
||||
json!({"layout":{"root":{"FuturePanelGroup":{}}},"hidden":[]}),
|
||||
json!({"layout":false,"hidden":[]}),
|
||||
json!({"layout":{"root":null,"floating":[]},"hidden":[["futurePanel",{}]]}),
|
||||
] {
|
||||
let workspace = json!({"showToolbar":false,"showPalettes":false,"toolsetTab":"Modeling", "collapsedGroups":["Draw"], "historyLines":7,"docking":docking,
|
||||
"dockingVersion":cadcraft_ui_egui::docking::LAYOUT_VERSION});
|
||||
assert!(serde_json::from_value::<UiState>(workspace.clone()).is_err(), "runtime state must stay strict");
|
||||
fs::write(&path, serde_json::to_vec(&json!({"version":1,"workspace":workspace})).unwrap()).unwrap();
|
||||
let (_, restored, error) = Workspace::at(Some(path.clone()));
|
||||
assert!(error.is_none());
|
||||
let restored = restored.unwrap();
|
||||
assert!(!restored.show_toolbar);
|
||||
assert!(!restored.show_palettes);
|
||||
assert_eq!(restored.toolset_tab, "Modeling");
|
||||
assert_eq!(restored.collapsed_groups, ["Draw"]);
|
||||
assert_eq!(restored.history_lines, 7);
|
||||
assert_eq!(serde_json::to_value(restored.docking).unwrap(), serde_json::to_value(UiState::default().docking).unwrap());
|
||||
}
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rapid_changes_are_written_once_per_interval_with_the_newest_state() {
|
||||
let directory = temp_dir();
|
||||
let path = directory.join("workspace.json");
|
||||
let (mut store, _, _) = Workspace::at(Some(path.clone()));
|
||||
let start = Instant::now();
|
||||
let mut ui = UiState { history_lines: 1, ..Default::default() };
|
||||
assert_eq!(store.save_if_changed(&ui, start).unwrap(), None);
|
||||
// A drag: a new state every frame, none of them written until the interval ends.
|
||||
for (frame, lines) in (2..=5).enumerate() {
|
||||
ui.history_lines = lines;
|
||||
let now = start + Duration::from_millis(16 * (frame as u64 + 1));
|
||||
let wait = store.save_if_changed(&ui, now).unwrap();
|
||||
assert_eq!(wait, Some(MIN_WRITE_INTERVAL - (now - start)));
|
||||
}
|
||||
assert_eq!(read(&path).unwrap().unwrap().history_lines, 1);
|
||||
assert_eq!(store.save_if_changed(&ui, start + MIN_WRITE_INTERVAL).unwrap(), None);
|
||||
assert_eq!(read(&path).unwrap().unwrap().history_lines, 5);
|
||||
ui.history_lines = 6;
|
||||
assert!(store.save_if_changed(&ui, start + MIN_WRITE_INTERVAL).unwrap().is_some());
|
||||
store.save_on_exit(&ui).unwrap();
|
||||
assert_eq!(read(&path).unwrap().unwrap().history_lines, 6);
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_update_cannot_replace_last_good_workspace() {
|
||||
let directory = temp_dir();
|
||||
let path = directory.join("workspace.json");
|
||||
let (mut store, _, _) = Workspace::at(Some(path.clone()));
|
||||
let mut ui = UiState { show_palettes: false, ..Default::default() };
|
||||
store.save_if_changed(&ui, Instant::now()).unwrap();
|
||||
let good = fs::read(&path).unwrap();
|
||||
ui.collapsed_groups = vec!["x".repeat(257)];
|
||||
assert!(store.save_if_changed(&ui, Instant::now()).is_err());
|
||||
assert_eq!(fs::read(&path).unwrap(), good);
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_write_keeps_warning_and_retries_once_on_exit() {
|
||||
let directory = temp_dir();
|
||||
let path = directory.join("workspace.json");
|
||||
let (mut store, _, _) = Workspace::at(Some(path.clone()));
|
||||
// Simulate a destination becoming unavailable after startup.
|
||||
fs::create_dir(&path).unwrap();
|
||||
let ui = UiState { show_palettes: false, ..Default::default() };
|
||||
let first_error = store.save_if_changed(&ui, Instant::now()).unwrap_err();
|
||||
fs::remove_dir(&path).unwrap();
|
||||
assert_eq!(store.save_if_changed(&ui, Instant::now()).unwrap_err(), first_error);
|
||||
assert!(!path.exists(), "ordinary frames must not retry disk writes");
|
||||
store.save_on_exit(&ui).unwrap();
|
||||
assert!(!read(&path).unwrap().unwrap().show_palettes);
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,3 @@ allow-unwrap-in-tests = true
|
||||
allow-expect-in-tests = true
|
||||
allow-panic-in-tests = true
|
||||
allow-indexing-slicing-in-tests = true
|
||||
disallowed-types = [
|
||||
{ path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" },
|
||||
]
|
||||
cognitive-complexity-threshold = 30
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
# Contributor names for the About window.
|
||||
# Contributor names for the About window (craftrules standards/contributors.md).
|
||||
#
|
||||
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
|
||||
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
|
||||
# maintainer may add a person only when that person asked, recording where in consent_source.
|
||||
#
|
||||
# Copy this block, replacing your-github-username (every field is optional):
|
||||
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
|
||||
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
|
||||
#
|
||||
# Or copy this block, replacing your-github-username (every field is optional):
|
||||
#
|
||||
# [people.your-github-username]
|
||||
# real_name = "Your Name" # shown when the viewer picks "Real name"
|
||||
@@ -19,86 +22,9 @@ real_name = "Brandon Thomas"
|
||||
display_name = "Brandon Thomas"
|
||||
sync_github_name = false
|
||||
|
||||
[people.switch87]
|
||||
real_name = "Gert Pellin"
|
||||
display_name = "Gert Pellin"
|
||||
sync_github_name = false
|
||||
|
||||
[people.rangoDJ]
|
||||
sync_github_name = true
|
||||
|
||||
[people.Tru10]
|
||||
display_name = "by Truitt"
|
||||
sync_github_name = false
|
||||
|
||||
[people.tsangiotis]
|
||||
real_name = "Tasos Sangiotis"
|
||||
display_name = "Tasos Sangiotis"
|
||||
sync_github_name = false
|
||||
|
||||
[people.vangel76]
|
||||
sync_github_name = true
|
||||
|
||||
[people.WladmirJunior]
|
||||
real_name = "Wladmir Júnior"
|
||||
sync_github_name = true
|
||||
|
||||
[people.Losminek]
|
||||
sync_github_name = true
|
||||
|
||||
[people.GJoe2]
|
||||
sync_github_name = true
|
||||
|
||||
[people.holmbom]
|
||||
real_name = "Erik Holmbom"
|
||||
display_name = "Erik Holmbom"
|
||||
sync_github_name = false
|
||||
|
||||
[people.cyfung1031]
|
||||
real_name = "Chester Fung"
|
||||
display_name = "Chester Fung"
|
||||
sync_github_name = true
|
||||
|
||||
[people.C00kieMonsta]
|
||||
real_name = "Antoine Boxho"
|
||||
display_name = "Antoine Boxho"
|
||||
sync_github_name = true
|
||||
|
||||
[people.Silvertree2010]
|
||||
display_name = "Netter"
|
||||
sync_github_name = false
|
||||
|
||||
[people.gurparvez]
|
||||
real_name = "Gurparvez"
|
||||
|
||||
[people.nicholasgower]
|
||||
sync_github_name = true
|
||||
|
||||
[people.ffishman]
|
||||
real_name = "Rodrigo Gressler"
|
||||
display_name = "Rodrigo"
|
||||
sync_github_name = false
|
||||
|
||||
[people.aussieboer61]
|
||||
real_name = "Richard Sauer"
|
||||
display_name = "Richard Sauer"
|
||||
sync_github_name = false
|
||||
|
||||
[people.dotCooCoo]
|
||||
real_name = "Robert Lee"
|
||||
sync_github_name = true
|
||||
|
||||
[people.2ez4gcx]
|
||||
real_name = "Doan Huynh Huu Khuong"
|
||||
display_name = "Doan Huynh Huu Khuong"
|
||||
sync_github_name = false
|
||||
|
||||
|
||||
[people.dylanmtaylor]
|
||||
sync_github_name = true
|
||||
|
||||
[people.GULERTOLGA]
|
||||
sync_github_name = true
|
||||
|
||||
[people.starise]
|
||||
real_name = "Andrea Brandi"
|
||||
|
||||
+9
-52
@@ -21,9 +21,8 @@ impl Rgb {
|
||||
format!("#{:02x}{:02x}{:02x}", self.0, self.1, self.2)
|
||||
}
|
||||
pub fn parse_hex(s: &str) -> Option<Rgb> {
|
||||
let s = s.trim();
|
||||
let s = s.strip_prefix('#').unwrap_or(s);
|
||||
if s.len() != 6 || !s.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
let s = s.trim().trim_start_matches('#');
|
||||
if s.len() != 6 || !s.is_ascii() {
|
||||
return None;
|
||||
}
|
||||
u32::from_str_radix(s, 16).ok().map(Rgb::from_u32)
|
||||
@@ -111,9 +110,9 @@ impl Color {
|
||||
_ => {}
|
||||
}
|
||||
if let Some(rest) = t.strip_prefix("color ") {
|
||||
return parse_digits::<u8>(rest.trim()).filter(|i| *i >= 1).map(Color::Index);
|
||||
return rest.trim().parse::<u8>().ok().filter(|i| *i >= 1).map(Color::Index);
|
||||
}
|
||||
if let Some(i) = parse_digits::<u16>(&t) {
|
||||
if let Ok(i) = t.parse::<u16>() {
|
||||
return match i {
|
||||
0 => Some(Color::ByBlock),
|
||||
256 => Some(Color::ByLayer),
|
||||
@@ -124,22 +123,14 @@ impl Color {
|
||||
if t.starts_with('#') {
|
||||
return Rgb::parse_hex(&t).map(Color::True);
|
||||
}
|
||||
let parts: Vec<_> = t.split(',').map(|p| parse_digits::<u8>(p.trim())).collect();
|
||||
if let [Some(r), Some(g), Some(b)] = parts.as_slice() {
|
||||
let parts: Vec<_> = t.split(',').map(|p| p.trim().parse::<u8>()).collect();
|
||||
if let [Ok(r), Ok(g), Ok(b)] = parts.as_slice() {
|
||||
return Some(Color::True(Rgb(*r, *g, *b)));
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse an unsigned integer made of ASCII digits only (no sign, unlike `str::parse`).
|
||||
fn parse_digits<T: std::str::FromStr>(s: &str) -> Option<T> {
|
||||
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
s.parse().ok()
|
||||
}
|
||||
|
||||
/// The RGB of ACI `i` (0 and out-of-range map to white/7).
|
||||
pub fn aci_rgb(i: u8) -> Rgb {
|
||||
PALETTE.get(usize::from(i)).copied().unwrap_or(Rgb(255, 255, 255))
|
||||
@@ -165,7 +156,7 @@ fn build_palette() -> [Rgb; 256] {
|
||||
for (slot, c) in p.iter_mut().zip(base) {
|
||||
*slot = c;
|
||||
}
|
||||
let values = [255.0, 204.0, 153.0, 127.0, 76.0];
|
||||
let values = [255.0, 165.0, 127.0, 76.0, 38.0];
|
||||
for i in 10..250usize {
|
||||
let hue = (i - 10) / 10; // 0..24, 15° each
|
||||
let sub = i % 10;
|
||||
@@ -234,47 +225,13 @@ pub fn display_rgb(c: Rgb, aci7: bool, background: Rgb) -> Rgb {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_hex_rejects_non_hex_digits() {
|
||||
assert_eq!(Rgb::parse_hex("+12345"), None);
|
||||
assert_eq!(Rgb::parse_hex("-12345"), None);
|
||||
assert_eq!(Color::parse("#+12345"), None);
|
||||
assert_eq!(Rgb::parse_hex("#012345"), Some(Rgb(1, 35, 69)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_plus_sign() {
|
||||
assert_eq!(Color::parse("+5"), None);
|
||||
assert_eq!(Color::parse(" +256"), None);
|
||||
assert_eq!(Color::parse("color +5"), None);
|
||||
assert_eq!(Color::parse("+1,+2,+3"), None);
|
||||
assert_eq!(Color::parse("1,+2,3"), None);
|
||||
assert_eq!(Color::parse("5"), Some(Color::Index(5)));
|
||||
assert_eq!(Color::parse("256"), Some(Color::ByLayer));
|
||||
assert_eq!(Color::parse("color 5"), Some(Color::Index(5)));
|
||||
assert_eq!(Color::parse("1, 2, 3"), Some(Color::True(Rgb(1, 2, 3))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_hex_rejects_repeated_hash() {
|
||||
assert_eq!(Rgb::parse_hex("##ff0000"), None);
|
||||
assert_eq!(Rgb::parse_hex("###ff0000"), None);
|
||||
assert_eq!(Color::parse("##ff0000"), None);
|
||||
assert_eq!(Rgb::parse_hex("#ff0000"), Some(Rgb(255, 0, 0)));
|
||||
assert_eq!(Rgb::parse_hex("ff0000"), Some(Rgb(255, 0, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_known_entries() {
|
||||
assert_eq!(aci_rgb(1), Rgb(255, 0, 0));
|
||||
assert_eq!(aci_rgb(10), Rgb(255, 0, 0));
|
||||
assert_eq!(aci_rgb(11), Rgb(255, 127, 127));
|
||||
assert_eq!(aci_rgb(12), Rgb(204, 0, 0));
|
||||
assert_eq!(aci_rgb(13), Rgb(204, 102, 102));
|
||||
assert_eq!(aci_rgb(14), Rgb(153, 0, 0));
|
||||
assert_eq!(aci_rgb(15), Rgb(153, 76, 76));
|
||||
assert_eq!(aci_rgb(16), Rgb(127, 0, 0));
|
||||
assert_eq!(aci_rgb(18), Rgb(76, 0, 0));
|
||||
assert_eq!(aci_rgb(12), Rgb(165, 0, 0));
|
||||
assert_eq!(aci_rgb(13), Rgb(165, 82, 82));
|
||||
assert_eq!(aci_rgb(20), Rgb(255, 63, 0));
|
||||
assert_eq!(aci_rgb(30), Rgb(255, 127, 0));
|
||||
assert_eq!(aci_rgb(50), Rgb(255, 255, 0));
|
||||
|
||||
+10
-119
@@ -1,5 +1,4 @@
|
||||
//! AUTOCONSTRAIN: infer constraints from geometry that already nearly satisfies them; and
|
||||
//! CONSTRAINTINFER's inference for what a command just placed ([`infer_placed`]).
|
||||
//! AUTOCONSTRAIN: infer constraints from geometry that already nearly satisfies them.
|
||||
|
||||
use cadcraft_doc::{Constraint, ConstraintKind, Drawing, EntityKind, GeomRef, Handle, Sub};
|
||||
use cadcraft_geom::{Line, Vec2};
|
||||
@@ -52,11 +51,15 @@ fn collect(d: &Drawing, handles: &[Handle]) -> Geo {
|
||||
g.curves.push((GeomRef::whole(h), ga.center, a.radius));
|
||||
}
|
||||
}
|
||||
EntityKind::Circle(c) if c.center.is_finite() && c.radius.is_finite() => {
|
||||
g.curves.push((GeomRef::whole(h), c.center.xy(), c.radius));
|
||||
EntityKind::Circle(c) => {
|
||||
if c.center.is_finite() && c.radius.is_finite() {
|
||||
g.curves.push((GeomRef::whole(h), c.center.xy(), c.radius));
|
||||
}
|
||||
}
|
||||
EntityKind::Point(p) if p.p.is_finite() => {
|
||||
g.points.push((GeomRef::whole(h), p.p.xy()));
|
||||
EntityKind::Point(p) => {
|
||||
if p.p.is_finite() {
|
||||
g.points.push((GeomRef::whole(h), p.p.xy()));
|
||||
}
|
||||
}
|
||||
EntityKind::LwPolyline(p) => {
|
||||
let n = p.vertices.len();
|
||||
@@ -98,11 +101,7 @@ fn ang_between(d1: Vec2, d2: Vec2) -> f64 {
|
||||
pub fn infer(d: &Drawing, handles: &[Handle], o: &InferOptions) -> Vec<Constraint> {
|
||||
let tol = if o.distance_tolerance.is_finite() { o.distance_tolerance.clamp(0.0, 1e9) } else { 0.05 };
|
||||
let atol = if o.angle_tolerance.is_finite() { o.angle_tolerance.clamp(0.0, 45.0).to_radians() } else { 1f64.to_radians() };
|
||||
// Resolve the allowed type names into a set once, so membership is O(1) per candidate instead
|
||||
// of re-scanning the whole (attacker-controllable) `o.types` list for every geometric match
|
||||
// (CWE-407). Empty `o.types` means "all types".
|
||||
let allowed: std::collections::HashSet<String> = o.types.iter().map(|t| t.to_ascii_lowercase()).collect();
|
||||
let allow = |k: ConstraintKind| allowed.is_empty() || allowed.contains(&k.name().to_ascii_lowercase());
|
||||
let allow = |k: ConstraintKind| o.types.is_empty() || o.types.iter().any(|t| t.eq_ignore_ascii_case(k.name()));
|
||||
let g = collect(d, handles);
|
||||
let mut out: Vec<Constraint> = Vec::new();
|
||||
let mut push = |kind: ConstraintKind, refs: Vec<GeomRef>| {
|
||||
@@ -192,114 +191,6 @@ pub fn infer(d: &Drawing, handles: &[Handle], o: &InferOptions) -> Vec<Constrain
|
||||
out
|
||||
}
|
||||
|
||||
/// Constraint types CONSTRAINTINFER applies.
|
||||
pub const INFER_TYPES: [ConstraintKind; 6] = [
|
||||
ConstraintKind::Coincident,
|
||||
ConstraintKind::Tangent,
|
||||
ConstraintKind::Perpendicular,
|
||||
ConstraintKind::Parallel,
|
||||
ConstraintKind::Horizontal,
|
||||
ConstraintKind::Vertical,
|
||||
];
|
||||
|
||||
/// CONSTRAINTINFER: the constraints that the exact placement of `placed` (objects a command just
|
||||
/// drew or edited) implies against each other and `others`: end points snapped onto end points,
|
||||
/// midpoints, centres or onto objects (Coincident), tangent and perpendicular snaps, parallel
|
||||
/// lines, and horizontal/vertical lines (ortho, polar). Snapped geometry is exact, so the
|
||||
/// tolerances in `o` are expected to be tight. Only [`INFER_TYPES`] (and `o.types`, when set)
|
||||
/// are returned, each involving a placed object.
|
||||
pub fn infer_placed(d: &Drawing, placed: &[Handle], others: &[Handle], o: &InferOptions) -> Vec<Constraint> {
|
||||
let tol = if o.distance_tolerance.is_finite() { o.distance_tolerance.clamp(0.0, 1e9) } else { 0.0 };
|
||||
let mut all: Vec<Handle> = placed.iter().take(MAX_OBJECTS).copied().collect();
|
||||
for h in others {
|
||||
if all.len() >= MAX_OBJECTS {
|
||||
break;
|
||||
}
|
||||
if !all.contains(h) {
|
||||
all.push(*h);
|
||||
}
|
||||
}
|
||||
let mine = |c: &Constraint| c.refs.iter().any(|r| placed.contains(&r.handle));
|
||||
let mut out: Vec<Constraint> = infer(d, &all, o).into_iter().filter(|c| INFER_TYPES.contains(&c.kind) && mine(c)).collect();
|
||||
// Placed end points on other objects' midpoints, centres, or on the objects themselves.
|
||||
let allow = o.types.is_empty() || o.types.iter().any(|t| t.eq_ignore_ascii_case(ConstraintKind::Coincident.name()));
|
||||
if !allow {
|
||||
return out;
|
||||
}
|
||||
let g = collect(d, &all);
|
||||
// Point to point (midpoint, centre) first; point on an object last, so a tangent or
|
||||
// perpendicular snap keeps its tangency (an end on the curve it touches adds nothing then).
|
||||
let mut extra = Vec::new();
|
||||
let mut on = Vec::new();
|
||||
for (rp, p) in g.points.iter().filter(|(r, _)| placed.contains(&r.handle)) {
|
||||
if out.iter().any(|c| c.kind == ConstraintKind::Coincident && c.refs.contains(rp)) {
|
||||
continue;
|
||||
}
|
||||
let target = g
|
||||
.lines
|
||||
.iter()
|
||||
.filter(|(r, _, _)| r.handle != rp.handle)
|
||||
.find_map(|(r, a, b)| {
|
||||
let ln = Line::new(*a, *b);
|
||||
if a.mid(*b).dist(*p) <= tol && r.sub == Sub::Whole {
|
||||
Some(GeomRef::new(r.handle, Sub::Mid))
|
||||
} else {
|
||||
let t = ln.param_of(*p);
|
||||
(ln.dist(*p) <= tol && (0.0..=1.0).contains(&t) && ln.len() > tol).then_some(*r)
|
||||
}
|
||||
})
|
||||
.or_else(|| {
|
||||
g.curves.iter().filter(|(r, _, _)| r.handle != rp.handle).find_map(|(r, c, rad)| {
|
||||
if c.dist(*p) <= tol {
|
||||
Some(GeomRef::new(r.handle, Sub::Center))
|
||||
} else {
|
||||
((c.dist(*p) - rad).abs() <= tol && on_arc(d, r.handle, *c, *p)).then_some(*r)
|
||||
}
|
||||
})
|
||||
});
|
||||
if let Some(t) = target
|
||||
&& extra.len() + on.len() + out.len() < MAX_CANDIDATES
|
||||
{
|
||||
let c = Constraint { id: 0, kind: ConstraintKind::Coincident, refs: vec![*rp, t], name: String::new(), expr: String::new() };
|
||||
if t.sub == Sub::Whole { on.push(c) } else { extra.push(c) }
|
||||
}
|
||||
}
|
||||
// Perpendicular lines meeting in a T (a perpendicular snap onto the middle of a line).
|
||||
let atol = if o.angle_tolerance.is_finite() { o.angle_tolerance.clamp(0.0, 45.0).to_radians() } else { 0.0 };
|
||||
let allow_per = o.types.is_empty() || o.types.iter().any(|t| t.eq_ignore_ascii_case(ConstraintKind::Perpendicular.name()));
|
||||
for (r1, a1, b1) in g.lines.iter().filter(|(r, _, _)| placed.contains(&r.handle)) {
|
||||
for (r2, a2, b2) in g.lines.iter().filter(|(r, _, _)| r.handle != r1.handle) {
|
||||
let (l1, l2) = (Line::new(*a1, *b1), Line::new(*a2, *b2));
|
||||
let touches = |l: &Line, p: Vec2| l.dist(p) <= tol && (0.0..=1.0).contains(&l.param_of(p));
|
||||
let tee = [*a1, *b1].iter().any(|p| touches(&l2, *p)) || [*a2, *b2].iter().any(|p| touches(&l1, *p));
|
||||
let ang = ang_between(*b1 - *a1, *b2 - *a2);
|
||||
let dup = out.iter().any(|c| c.kind == ConstraintKind::Perpendicular && c.refs.contains(r1) && c.refs.contains(r2));
|
||||
if allow_per
|
||||
&& tee
|
||||
&& !dup
|
||||
&& (std::f64::consts::FRAC_PI_2 - ang).abs() <= atol
|
||||
&& l1.len() > tol
|
||||
&& l2.len() > tol
|
||||
&& out.len() < MAX_CANDIDATES
|
||||
{
|
||||
out.push(Constraint { id: 0, kind: ConstraintKind::Perpendicular, refs: vec![*r1, *r2], name: String::new(), expr: String::new() });
|
||||
}
|
||||
}
|
||||
}
|
||||
let n = out.iter().take_while(|c| c.kind == ConstraintKind::Coincident).count();
|
||||
out.splice(n..n, extra);
|
||||
out.extend(on);
|
||||
out
|
||||
}
|
||||
|
||||
/// Does `p` (on the circle) lie within the arc's sweep (always for a circle)?
|
||||
fn on_arc(d: &Drawing, h: Handle, c: Vec2, p: Vec2) -> bool {
|
||||
match d.entity(h).map(|e| &e.kind) {
|
||||
Some(EntityKind::Arc(a)) => cadcraft_geom::angle_in_sweep((p - c).angle(), a.start, a.end),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Infer and apply constraints over `handles`; returns the ids added. Redundant or conflicting
|
||||
/// candidates are skipped.
|
||||
pub fn autoconstrain(d: &mut Drawing, handles: &[Handle], o: &InferOptions) -> Vec<u32> {
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
//! Curve math for the residuals: ellipse points and derivatives, B-spline end derivatives and
|
||||
//! curve end frames (point, tangent, curvature) for tangent and smooth (G2) joins.
|
||||
//!
|
||||
//! Textbook formulas only: the derivative of a clamped B-spline at its ends follows from the
|
||||
//! derivative control points (Piegl & Tiller, *The NURBS Book*, ch. 3–4), rational curves through
|
||||
//! the quotient rule on homogeneous coordinates.
|
||||
|
||||
use cadcraft_geom::{Spline as GSpline, Vec2};
|
||||
|
||||
/// `v <= lim`, or NaN.
|
||||
pub(crate) fn not_above(v: f64, lim: f64) -> bool {
|
||||
v.is_nan() || v <= lim
|
||||
}
|
||||
|
||||
/// A curve end: point, unit tangent in the curve's own direction, signed curvature (positive
|
||||
/// when the curve turns left).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) struct Frame {
|
||||
pub p: Vec2,
|
||||
pub t: Vec2,
|
||||
pub k: f64,
|
||||
}
|
||||
|
||||
impl Frame {
|
||||
/// From a point and the first and second derivatives with respect to any parameter.
|
||||
pub fn from_derivs(p: Vec2, d1: Vec2, d2: Vec2) -> Option<Frame> {
|
||||
let l = d1.len();
|
||||
if !l.is_finite() || not_above(l, 1e-300) {
|
||||
return None;
|
||||
}
|
||||
let k = d1.cross(d2) / (l * l * l);
|
||||
let f = Frame { p, t: d1 * (1.0 / l), k };
|
||||
(f.p.is_finite() && f.t.is_finite() && f.k.is_finite()).then_some(f)
|
||||
}
|
||||
|
||||
/// The same end traversed the other way.
|
||||
pub fn reversed(self) -> Frame {
|
||||
Frame { p: self.p, t: -self.t, k: -self.k }
|
||||
}
|
||||
}
|
||||
|
||||
/// An ellipse or elliptical arc in solver parameters.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) struct Ell {
|
||||
pub c: Vec2,
|
||||
/// Major axis vector (centre to the end of the major axis).
|
||||
pub mj: Vec2,
|
||||
pub ratio: f64,
|
||||
pub t0: f64,
|
||||
pub t1: f64,
|
||||
pub arc: bool,
|
||||
}
|
||||
|
||||
impl Ell {
|
||||
pub fn minor(&self) -> Vec2 {
|
||||
self.mj.perp() * self.ratio
|
||||
}
|
||||
pub fn at(&self, t: f64) -> Vec2 {
|
||||
self.c + self.mj * t.cos() + self.minor() * t.sin()
|
||||
}
|
||||
pub fn d1(&self, t: f64) -> Vec2 {
|
||||
self.mj * -t.sin() + self.minor() * t.cos()
|
||||
}
|
||||
pub fn d2(&self, t: f64) -> Vec2 {
|
||||
self.mj * -t.cos() - self.minor() * t.sin()
|
||||
}
|
||||
pub fn frame(&self, t: f64) -> Option<Frame> {
|
||||
Frame::from_derivs(self.at(t), self.d1(t), self.d2(t))
|
||||
}
|
||||
/// Semi-axes `(a, b)`.
|
||||
fn axes(&self) -> (f64, f64) {
|
||||
let a = self.mj.len();
|
||||
(a, a * self.ratio.abs())
|
||||
}
|
||||
/// Local coordinates of `p` along the major and minor axes.
|
||||
fn local(&self, p: Vec2) -> Option<(f64, f64, Vec2, Vec2)> {
|
||||
let (a, _) = self.axes();
|
||||
if not_above(a, 1e-300) {
|
||||
return None;
|
||||
}
|
||||
let u_dir = self.mj * (1.0 / a);
|
||||
let v_dir = u_dir.perp();
|
||||
let q = p - self.c;
|
||||
Some((q.dot(u_dir), q.dot(v_dir), u_dir, v_dir))
|
||||
}
|
||||
/// A distance-like residual of `p` from the ellipse: zero exactly on it, about the distance
|
||||
/// near it.
|
||||
pub fn on(&self, p: Vec2) -> f64 {
|
||||
let (a, b) = self.axes();
|
||||
match self.local(p) {
|
||||
Some((u, v, _, _)) if b > 1e-300 => {
|
||||
let s = a / b;
|
||||
(u * u + v * v * s * s).sqrt() - a
|
||||
}
|
||||
_ => p.dist(self.c) - a,
|
||||
}
|
||||
}
|
||||
/// Unit outward normal of the ellipse's implicit form at (or near) `p`.
|
||||
pub fn normal(&self, p: Vec2) -> Vec2 {
|
||||
let (a, b) = self.axes();
|
||||
match self.local(p) {
|
||||
Some((u, v, ud, vd)) if b > 1e-300 => (ud * (u / (a * a)) + vd * (v / (b * b))).normalized(),
|
||||
_ => (p - self.c).normalized(),
|
||||
}
|
||||
}
|
||||
/// Largest `n·(P − c)` over the ellipse for a unit direction `n` (its support distance).
|
||||
pub fn support(&self, n: Vec2) -> f64 {
|
||||
let (x, y) = (n.dot(self.mj), n.dot(self.minor()));
|
||||
(x * x + y * y).sqrt()
|
||||
}
|
||||
}
|
||||
|
||||
/// A circle (or the circle of an arc) as a parametric curve.
|
||||
pub(crate) fn circle_at(c: Vec2, r: f64, t: f64) -> (Vec2, Vec2) {
|
||||
let (s, co) = t.sin_cos();
|
||||
(c + Vec2::new(co, s) * r, Vec2::new(-s, co) * r)
|
||||
}
|
||||
|
||||
/// Homogeneous control point.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct H {
|
||||
x: f64,
|
||||
y: f64,
|
||||
w: f64,
|
||||
}
|
||||
|
||||
impl H {
|
||||
fn sub(self, o: H) -> H {
|
||||
H { x: self.x - o.x, y: self.y - o.y, w: self.w - o.w }
|
||||
}
|
||||
fn scale(self, f: f64) -> H {
|
||||
H { x: self.x * f, y: self.y * f, w: self.w * f }
|
||||
}
|
||||
}
|
||||
|
||||
/// Is the knot vector clamped (end knot repeated `degree + 1` times) at the start or end?
|
||||
pub(crate) fn clamped(s: &GSpline, at_end: bool) -> bool {
|
||||
let p = s.degree;
|
||||
let k = &s.knots;
|
||||
if !s.is_valid() || k.len() < 2 * (p + 1) {
|
||||
return false;
|
||||
}
|
||||
let scale = k.iter().fold(1.0f64, |m, v| m.max(v.abs()));
|
||||
let tol = 1e-12 * scale;
|
||||
let window = if at_end { k.get(k.len() - p - 1..) } else { k.get(..=p) };
|
||||
match window {
|
||||
Some(w) => w.iter().all(|v| w.first().is_some_and(|f| (v - f).abs() <= tol)),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// End frame of a clamped spline at its start (`at_end` false) or end, in the curve's own
|
||||
/// direction. `None` for an unclamped end or degenerate data.
|
||||
pub(crate) fn spline_end(s: &GSpline, at_end: bool) -> Option<Frame> {
|
||||
if !clamped(s, at_end) {
|
||||
return None;
|
||||
}
|
||||
let p = s.degree;
|
||||
let n = s.control.len();
|
||||
let wt = |i: usize| if s.weights.is_empty() { Some(1.0) } else { s.weights.get(i).copied() };
|
||||
let hp = |i: usize| -> Option<H> {
|
||||
let c = s.control.get(i)?;
|
||||
let w = wt(i)?;
|
||||
Some(H { x: c.x * w, y: c.y * w, w })
|
||||
};
|
||||
// Work on the start of the curve; for the end, reverse the control points and knots.
|
||||
let (q, u): (Vec<H>, Vec<f64>) = if at_end {
|
||||
let q = (0..n.min(3)).map(|i| hp(n - 1 - i)).collect::<Option<Vec<_>>>()?;
|
||||
let last = s.knots.len() - 1;
|
||||
let u = (0..s.knots.len().min(p + 3)).map(|i| s.knots.get(last - i).map(|v| -v)).collect::<Option<Vec<_>>>()?;
|
||||
(q, u)
|
||||
} else {
|
||||
let q = (0..n.min(3)).map(hp).collect::<Option<Vec<_>>>()?;
|
||||
let u = s.knots.get(..(p + 3).min(s.knots.len()))?.to_vec();
|
||||
(q, u)
|
||||
};
|
||||
let pf = p as f64;
|
||||
let (q0, q1) = (*q.first()?, *q.get(1)?);
|
||||
let den1 = u.get(p + 1)? - u.get(1)?;
|
||||
if not_above(den1.abs(), 1e-300) {
|
||||
return None;
|
||||
}
|
||||
let a1 = q1.sub(q0).scale(pf / den1);
|
||||
let a2 = if p >= 2 {
|
||||
let q2 = *q.get(2)?;
|
||||
let den2 = u.get(p + 2)? - u.get(2)?;
|
||||
let den0 = u.get(p + 1)? - u.get(2)?;
|
||||
if !(den2.abs() > 1e-300 && den0.abs() > 1e-300) {
|
||||
return None;
|
||||
}
|
||||
q2.sub(q1).scale(1.0 / den2).sub(q1.sub(q0).scale(1.0 / den1)).scale(pf * (pf - 1.0) / den0)
|
||||
} else {
|
||||
H { x: 0.0, y: 0.0, w: 0.0 }
|
||||
};
|
||||
// Quotient rule: C = A / w.
|
||||
if not_above(q0.w.abs(), 1e-300) {
|
||||
return None;
|
||||
}
|
||||
let c = Vec2::new(q0.x / q0.w, q0.y / q0.w);
|
||||
let c1 = (Vec2::new(a1.x, a1.y) - c * a1.w) / q0.w;
|
||||
let c2 = (Vec2::new(a2.x, a2.y) - c1 * (2.0 * a1.w) - c * a2.w) / q0.w;
|
||||
let f = Frame::from_derivs(c, c1, c2)?;
|
||||
// The reversed curve's start is the end traversed backwards.
|
||||
Some(if at_end { f.reversed() } else { f })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ellipse_residuals() {
|
||||
let e = Ell { c: Vec2::new(1.0, 2.0), mj: Vec2::new(4.0, 0.0), ratio: 0.5, t0: 0.0, t1: 1.0, arc: false };
|
||||
assert!(e.on(Vec2::new(5.0, 2.0)).abs() < 1e-12);
|
||||
assert!(e.on(Vec2::new(1.0, 4.0)).abs() < 1e-12);
|
||||
assert!(e.on(Vec2::new(6.0, 2.0)) > 0.0);
|
||||
assert!((e.support(Vec2::new(0.0, 1.0)) - 2.0).abs() < 1e-12);
|
||||
assert!((e.support(Vec2::new(1.0, 0.0)) - 4.0).abs() < 1e-12);
|
||||
assert!(e.normal(Vec2::new(1.0, 4.0)).near(Vec2::new(0.0, 1.0), 1e-12));
|
||||
// Curvature at the end of the major axis: a / b².
|
||||
let f = e.frame(0.0).unwrap();
|
||||
assert!((f.k - 4.0 / 4.0).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spline_end_derivatives_match_sampling() {
|
||||
let s = GSpline::from_control(vec![Vec2::new(0.0, 0.0), Vec2::new(1.0, 2.0), Vec2::new(3.0, 3.0), Vec2::new(6.0, 1.0)], 3);
|
||||
for at_end in [false, true] {
|
||||
let f = spline_end(&s, at_end).unwrap();
|
||||
let (lo, hi) = s.domain();
|
||||
let (t, h) = if at_end { (hi, -1e-4) } else { (lo, 1e-4) };
|
||||
let (p0, p1, p2) = (s.eval(t), s.eval(t + h), s.eval(t + 2.0 * h));
|
||||
assert!(f.p.near(p0, 1e-12));
|
||||
// One-sided differences (a negative step at the end still gives d/dt).
|
||||
let d1 = (p1 - p0) * (1.0 / h);
|
||||
let d2 = (p2 - p1 * 2.0 + p0) * (1.0 / (h * h));
|
||||
let g = Frame::from_derivs(p0, d1, d2).unwrap();
|
||||
assert!(f.t.near(g.t, 1e-3), "{f:?} {g:?}");
|
||||
assert!((f.k - g.k).abs() < 1e-2, "{f:?} {g:?}");
|
||||
}
|
||||
// An unclamped (periodic) spline has no ends.
|
||||
let closed = GSpline::from_fit_points_closed(&[Vec2::new(0.0, 0.0), Vec2::new(4.0, 0.0), Vec2::new(2.0, 3.0)]);
|
||||
assert!(spline_end(&closed, false).is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
//! Degrees of freedom: how much the constraints leave the geometry free to move.
|
||||
//!
|
||||
//! Per connected component, the remaining DOF is the number of free parameters minus the rank
|
||||
//! of the constraint Jacobian at the current geometry; equations beyond the rank are redundant
|
||||
//! (over-constraint). Per object, the DOF is the dimension of the null-space motion restricted
|
||||
//! to the object's own parameters: how many independent ways the object can still move while
|
||||
//! every constraint keeps holding (the rest of its component moving along as needed).
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use cadcraft_doc::{Constraint, Drawing, Handle};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::model::{Model, Prep};
|
||||
use crate::{Comp, MAX_CONSTRAINTS, MAX_EVAL_WORK, SolveError, SolveOptions, build_with, lm};
|
||||
|
||||
/// Most free parameters for which per-object freedom is analysed (null-space basis size).
|
||||
const MAX_NULL_COLS: usize = 600;
|
||||
/// Largest Jacobian (rows × columns × min) whose rank is computed.
|
||||
const MAX_RANK_WORK: f64 = 4e8;
|
||||
/// Most objects listed in one report.
|
||||
const MAX_OBJECTS: usize = 10_000;
|
||||
|
||||
/// Constraint state of an object or a selection.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum DofStatus {
|
||||
/// No constraints.
|
||||
Unconstrained,
|
||||
/// Some freedom left.
|
||||
Under,
|
||||
/// No freedom left, no redundant constraints.
|
||||
Fully,
|
||||
/// Redundant constraints.
|
||||
Over,
|
||||
/// Too large to analyse.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl DofStatus {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
DofStatus::Unconstrained => "Not constrained",
|
||||
DofStatus::Under => "Under-constrained",
|
||||
DofStatus::Fully => "Fully constrained",
|
||||
DofStatus::Over => "Over-constrained",
|
||||
DofStatus::Unknown => "Unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Degrees of freedom of one object.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ObjectDof {
|
||||
pub handle: String,
|
||||
#[serde(rename = "type")]
|
||||
pub kind: &'static str,
|
||||
/// Parameters of the object: its degrees of freedom without constraints.
|
||||
pub params: usize,
|
||||
/// Remaining degrees of freedom (`None` when the system is too large to analyse).
|
||||
pub dof: Option<usize>,
|
||||
pub status: DofStatus,
|
||||
}
|
||||
|
||||
/// Degrees of freedom of the drawing's constrained geometry, or of a selection.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DofReport {
|
||||
/// Remaining degrees of freedom: the constraint components touching the analysed objects,
|
||||
/// plus unconstrained objects' own parameters (`None` when too large to analyse).
|
||||
pub dof: Option<usize>,
|
||||
pub status: DofStatus,
|
||||
/// Equations that duplicate others.
|
||||
pub redundant: usize,
|
||||
/// Constraints taking part.
|
||||
pub constraints: usize,
|
||||
pub objects: Vec<ObjectDof>,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
struct CompDof {
|
||||
dof: Option<usize>,
|
||||
redundant: usize,
|
||||
/// Per slot (model order).
|
||||
objects: Vec<Option<usize>>,
|
||||
}
|
||||
|
||||
/// Compute component and object freedom only when rank and residual-evaluation work fit their budgets.
|
||||
/// Return unknown freedom before building the Jacobian when either work limit is exceeded.
|
||||
fn analyse(comp: &Comp) -> CompDof {
|
||||
let free = comp.free(None);
|
||||
let rows = comp.rows(None);
|
||||
let nf = free.len();
|
||||
let unknown = CompDof { dof: None, redundant: 0, objects: vec![None; comp.model.slots.len()] };
|
||||
if rows > lm::MAX_ROWS || (rows as f64) * (nf as f64) * (rows.min(nf) as f64) > MAX_RANK_WORK {
|
||||
return unknown;
|
||||
}
|
||||
// Numerical differentiation evaluates every residual about `rows × free` times, and each
|
||||
// spline-end residual reconstructs its spline through a dense solve. Account for that
|
||||
// reconstruction cost before building the Jacobian so a component with few equations but large
|
||||
// splines cannot pass the rank-work check yet still require billions of operations (row 43).
|
||||
let eval_work = (rows as f64) * (nf as f64) * (1.0 + comp.built.iter().map(|b| b.spline_reconstruct_cost(&comp.model)).sum::<f64>());
|
||||
if eval_work > MAX_EVAL_WORK {
|
||||
return unknown;
|
||||
}
|
||||
let f = |x: &[f64], out: &mut Vec<f64>| comp.eval(x, out, None);
|
||||
let jac = lm::jacobian(&f, &comp.model.x0, &free, rows);
|
||||
if nf > MAX_NULL_COLS {
|
||||
let rank = lm::rank_qr(&jac, rows, nf);
|
||||
return CompDof { dof: Some(nf - rank.min(nf)), redundant: rows.saturating_sub(rank), objects: vec![None; comp.model.slots.len()] };
|
||||
}
|
||||
let (rank, basis) = lm::null_space(&jac, rows, nf);
|
||||
let objects = comp
|
||||
.model
|
||||
.slots
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let cols: Vec<usize> = free.iter().enumerate().filter(|(_, j)| (s.off..s.off + s.len).contains(*j)).map(|(k, _)| k).collect();
|
||||
if cols.is_empty() || basis.is_empty() {
|
||||
return Some(0);
|
||||
}
|
||||
// Null vectors restricted to the object's columns; their rank is its freedom.
|
||||
let restricted: Vec<Vec<f64>> = basis.iter().map(|v| cols.iter().map(|&k| v.get(k).copied().unwrap_or(0.0)).collect()).collect();
|
||||
Some(lm::qr(restricted, 0.0, 1e-6).0)
|
||||
})
|
||||
.collect();
|
||||
CompDof { dof: Some(nf - rank.min(nf)), redundant: rows.saturating_sub(rank), objects }
|
||||
}
|
||||
|
||||
fn plural(n: usize, one: &str, many: &str) -> String {
|
||||
format!("{n} {}", if n == 1 { one } else { many })
|
||||
}
|
||||
|
||||
/// Degrees of freedom of the constrained geometry (`handles` empty), or of the given objects
|
||||
/// (constrained or not) and the constraint components they belong to.
|
||||
pub fn dof(d: &Drawing, handles: &[Handle]) -> Result<DofReport, SolveError> {
|
||||
let cons: Vec<&Constraint> = d.constraints.iter().take(MAX_CONSTRAINTS).collect();
|
||||
let mut prep = Prep::new(crate::model::MAX_RIGID_WORK);
|
||||
let (comps, _) = build_with(d, &cons, &SolveOptions::default(), &mut prep)?;
|
||||
let want = |h: Handle| handles.is_empty() || handles.contains(&h);
|
||||
let mut objects = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
let mut total = Some(0usize);
|
||||
let mut redundant = 0;
|
||||
let mut constraints = 0;
|
||||
let mut any_constrained = false;
|
||||
for comp in &comps {
|
||||
if !comp.model.slots.iter().any(|s| want(s.handle)) {
|
||||
continue;
|
||||
}
|
||||
any_constrained = true;
|
||||
let a = analyse(comp);
|
||||
constraints += comp.built.len();
|
||||
redundant += a.redundant;
|
||||
total = match (total, a.dof) {
|
||||
(Some(t), Some(v)) => Some(t.saturating_add(v)),
|
||||
_ => None,
|
||||
};
|
||||
for (s, od) in comp.model.slots.iter().zip(&a.objects) {
|
||||
if !want(s.handle) || !seen.insert(s.handle) || objects.len() >= MAX_OBJECTS {
|
||||
continue;
|
||||
}
|
||||
let status = match od {
|
||||
_ if a.redundant > 0 => DofStatus::Over,
|
||||
Some(0) => DofStatus::Fully,
|
||||
Some(_) => DofStatus::Under,
|
||||
None => DofStatus::Unknown,
|
||||
};
|
||||
let kind = d.entity(s.handle).map_or("", |e| e.kind.type_name());
|
||||
objects.push(ObjectDof { handle: s.handle.hex(), kind, params: s.len, dof: *od, status });
|
||||
}
|
||||
}
|
||||
let mut free_objects = false;
|
||||
for &h in handles.iter().take(MAX_OBJECTS) {
|
||||
if seen.contains(&h) {
|
||||
continue;
|
||||
}
|
||||
let m = Model::build_with(d, &[h], &mut prep);
|
||||
let Some(s) = m.slots.first() else { continue };
|
||||
seen.insert(h);
|
||||
free_objects = true;
|
||||
total = total.map(|t| t.saturating_add(s.len));
|
||||
let kind = d.entity(h).map_or("", |e| e.kind.type_name());
|
||||
objects.push(ObjectDof { handle: h.hex(), kind, params: s.len, dof: Some(s.len), status: DofStatus::Unconstrained });
|
||||
}
|
||||
let status = if redundant > 0 {
|
||||
DofStatus::Over
|
||||
} else if !any_constrained {
|
||||
DofStatus::Unconstrained
|
||||
} else {
|
||||
match total {
|
||||
Some(0) => DofStatus::Fully,
|
||||
Some(_) => DofStatus::Under,
|
||||
None => DofStatus::Unknown,
|
||||
}
|
||||
};
|
||||
let message = match (status, total) {
|
||||
(DofStatus::Unconstrained, _) if !free_objects => "No constraints".to_string(),
|
||||
(DofStatus::Over, _) => format!("Over-constrained: {}", plural(redundant, "redundant equation", "redundant equations")),
|
||||
(DofStatus::Fully, _) => "Fully constrained".to_string(),
|
||||
(_, Some(n)) => format!("{} remaining", plural(n, "degree of freedom", "degrees of freedom")),
|
||||
(_, None) => "Too large to analyse".to_string(),
|
||||
};
|
||||
Ok(DofReport { dof: total, status, redundant, constraints, objects, message })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod budget_tests {
|
||||
use super::*;
|
||||
use crate::residual::{Built, Form};
|
||||
use cadcraft_doc::{Common, ConstraintKind, EntityKind, GeomRef, Space};
|
||||
use cadcraft_geom::{Spline, Vec2};
|
||||
|
||||
/// An expensive fit-spline endpoint residual produces unknown freedom before numerical differentiation.
|
||||
#[test]
|
||||
fn endpoint_analysis_rejects_cubic_fit_work_before_differentiation() {
|
||||
let mut d = Drawing::default();
|
||||
let pts: Vec<_> = (0..500).map(|i| Vec2::new(i as f64, (i % 3) as f64)).collect();
|
||||
let mut spline = Spline::from_control(pts.clone(), 3);
|
||||
spline.fit = pts;
|
||||
let h = d.add(&Space::Model, Common::default(), EntityKind::Spline(spline)).unwrap();
|
||||
let model = Model::build(&d, &[h]);
|
||||
let endpoint = Built {
|
||||
id: 1,
|
||||
kind: ConstraintKind::Tangent,
|
||||
refs: vec![GeomRef::whole(h)],
|
||||
form: Form::EndOn { spline: 0, at_end: false },
|
||||
target: 0.0,
|
||||
sign: 1.0,
|
||||
rows: 2,
|
||||
aux_init: Vec::new(),
|
||||
aux: 0,
|
||||
};
|
||||
let comp = Comp { model, built: vec![endpoint], weights: vec![1.0; 2], held: Vec::new(), tol: 1e-9 };
|
||||
assert!(analyse(&comp).dof.is_none());
|
||||
// Solving it is refused for the same reason, before any Jacobian is built.
|
||||
assert!(matches!(comp.run(None), Err(SolveError::TooLarge)));
|
||||
}
|
||||
|
||||
/// Many Tangent constraints on one large fit spline rebuild it once while being interpreted,
|
||||
/// and the solve is refused by its rebuild budget instead of running.
|
||||
#[test]
|
||||
fn repeated_spline_tangents_rebuild_once_and_solve_is_bounded() {
|
||||
use cadcraft_doc::{Constraint, Line};
|
||||
use cadcraft_geom::Vec3;
|
||||
let mut d = Drawing::default();
|
||||
let pts: Vec<_> = (0..400).map(|i| Vec2::new(i as f64, (i % 3) as f64)).collect();
|
||||
let h = d.add(&Space::Model, Common::default(), EntityKind::Spline(Spline::fit_with(&pts, false, Default::default()))).unwrap();
|
||||
let l =
|
||||
d.add(&Space::Model, Common::default(), EntityKind::Line(Line { a: Vec3::new(-1.0, 0.0, 0.0), b: Vec3::new(-1.0, 5.0, 0.0) })).unwrap();
|
||||
for id in 1..=200 {
|
||||
d.constraints.push(Constraint {
|
||||
id,
|
||||
kind: ConstraintKind::Tangent,
|
||||
refs: vec![GeomRef::whole(h), GeomRef::whole(l)],
|
||||
name: String::new(),
|
||||
expr: String::new(),
|
||||
});
|
||||
}
|
||||
let cons: Vec<&Constraint> = d.constraints.iter().collect();
|
||||
let (comps, skipped) = crate::build(&d, &cons, &SolveOptions::default()).unwrap();
|
||||
assert!(skipped.is_empty());
|
||||
let comp = comps.first().unwrap();
|
||||
assert_eq!(comp.built.len(), 200);
|
||||
assert!(matches!(comp.run(None), Err(SolveError::TooLarge)));
|
||||
assert!(crate::purge_invalid(&mut d).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ use std::collections::BTreeMap;
|
||||
|
||||
/// Longest expression accepted (characters).
|
||||
pub const MAX_LEN: usize = 1024;
|
||||
/// Deepest syntax tree and combined expression/parameter evaluation path.
|
||||
/// Deepest nesting (parentheses, unary minus) and parameter reference chain.
|
||||
pub const MAX_DEPTH: usize = 64;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, thiserror::Error)]
|
||||
@@ -49,7 +49,7 @@ struct Parser<'a> {
|
||||
|
||||
impl Parser<'_> {
|
||||
fn ws(&mut self) {
|
||||
while self.s.get(self.i).is_some_and(u8::is_ascii_whitespace) {
|
||||
while self.s.get(self.i).is_some_and(|c| c.is_ascii_whitespace()) {
|
||||
self.i += 1;
|
||||
}
|
||||
}
|
||||
@@ -165,20 +165,6 @@ pub fn parse(text: &str) -> Result<Expr, ExprError> {
|
||||
if p.peek().is_some() {
|
||||
return Err(ExprError::Syntax(format!("unexpected `{}`", text.get(p.i..).unwrap_or("").trim())));
|
||||
}
|
||||
let mut pending = vec![(&e, 1)];
|
||||
while let Some((node, depth)) = pending.pop() {
|
||||
if depth > MAX_DEPTH {
|
||||
return Err(ExprError::TooDeep);
|
||||
}
|
||||
match node {
|
||||
Expr::Neg(a) => pending.push((a, depth + 1)),
|
||||
Expr::Bin(_, a, b) => {
|
||||
pending.push((a, depth + 1));
|
||||
pending.push((b, depth + 1));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(e)
|
||||
}
|
||||
|
||||
@@ -199,58 +185,14 @@ pub fn names(e: &Expr, out: &mut Vec<String>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// `text` with every whole-name use of `old` replaced by `new`; numbers (`1e3`), other names
|
||||
/// (`d10` for `d1`) and spacing are left as written.
|
||||
pub fn rename(text: &str, old: &str, new: &str) -> String {
|
||||
let b = text.as_bytes();
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut i = 0;
|
||||
while let Some(&c) = b.get(i) {
|
||||
let start = i;
|
||||
if c.is_ascii_digit() || c == b'.' {
|
||||
while b.get(i).is_some_and(|c| c.is_ascii_digit() || *c == b'.') {
|
||||
i += 1;
|
||||
}
|
||||
if b.get(i).is_some_and(|c| *c == b'e' || *c == b'E') {
|
||||
let mut j = i + 1;
|
||||
if b.get(j).is_some_and(|c| *c == b'+' || *c == b'-') {
|
||||
j += 1;
|
||||
}
|
||||
if b.get(j).is_some_and(u8::is_ascii_digit) {
|
||||
i = j;
|
||||
while b.get(i).is_some_and(u8::is_ascii_digit) {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if c.is_ascii_alphabetic() || c == b'_' {
|
||||
while b.get(i).is_some_and(|c| c.is_ascii_alphanumeric() || *c == b'_') {
|
||||
i += 1;
|
||||
}
|
||||
} else {
|
||||
i += text.get(i..).and_then(|r| r.chars().next()).map_or(1, char::len_utf8);
|
||||
}
|
||||
let token = text.get(start..i).unwrap_or_default();
|
||||
out.push_str(if token == old { new } else { token });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Evaluate with a variable lookup.
|
||||
pub fn eval_with(e: &Expr, lookup: &mut dyn FnMut(&str) -> Result<f64, ExprError>) -> Result<f64, ExprError> {
|
||||
eval_at_depth(e, &mut |n, _| lookup(n), 0)
|
||||
}
|
||||
|
||||
fn eval_at_depth(e: &Expr, lookup: &mut dyn FnMut(&str, usize) -> Result<f64, ExprError>, depth: usize) -> Result<f64, ExprError> {
|
||||
if depth >= MAX_DEPTH {
|
||||
return Err(ExprError::TooDeep);
|
||||
}
|
||||
let v = match e {
|
||||
Expr::Num(v) => *v,
|
||||
Expr::Var(n) => lookup(n, depth + 1)?,
|
||||
Expr::Neg(a) => -eval_at_depth(a, lookup, depth + 1)?,
|
||||
Expr::Var(n) => lookup(n)?,
|
||||
Expr::Neg(a) => -eval_with(a, lookup)?,
|
||||
Expr::Bin(op, a, b) => {
|
||||
let (x, y) = (eval_at_depth(a, lookup, depth + 1)?, eval_at_depth(b, lookup, depth + 1)?);
|
||||
let (x, y) = (eval_with(a, lookup)?, eval_with(b, lookup)?);
|
||||
match op {
|
||||
'+' => x + y,
|
||||
'-' => x - y,
|
||||
@@ -294,12 +236,9 @@ impl Env {
|
||||
/// Value of a named parameter.
|
||||
pub fn value(&mut self, name: &str) -> Result<f64, ExprError> {
|
||||
let mut stack = Vec::new();
|
||||
self.value_inner(name, &mut stack, 0)
|
||||
self.value_inner(name, &mut stack)
|
||||
}
|
||||
fn value_inner(&mut self, name: &str, stack: &mut Vec<String>, depth: usize) -> Result<f64, ExprError> {
|
||||
if depth >= MAX_DEPTH {
|
||||
return Err(ExprError::TooDeep);
|
||||
}
|
||||
fn value_inner(&mut self, name: &str, stack: &mut Vec<String>) -> Result<f64, ExprError> {
|
||||
if let Some(v) = self.cache.get(name) {
|
||||
return Ok(*v);
|
||||
}
|
||||
@@ -312,7 +251,7 @@ impl Env {
|
||||
let text = self.exprs.get(name).cloned().ok_or_else(|| ExprError::Unknown(name.to_string()))?;
|
||||
let e = parse(&text)?;
|
||||
stack.push(name.to_string());
|
||||
let r = eval_at_depth(&e, &mut |n, depth| self.value_inner(n, stack, depth), depth);
|
||||
let r = eval_with(&e, &mut |n| self.value_inner(n, stack));
|
||||
stack.pop();
|
||||
let v = r?;
|
||||
self.cache.insert(name.to_string(), v);
|
||||
@@ -322,7 +261,7 @@ impl Env {
|
||||
pub fn eval(&mut self, text: &str) -> Result<f64, ExprError> {
|
||||
let e = parse(text)?;
|
||||
let mut stack = Vec::new();
|
||||
eval_at_depth(&e, &mut |n, depth| self.value_inner(n, &mut stack, depth), 0)
|
||||
eval_with(&e, &mut |n| self.value_inner(n, &mut stack))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,38 +314,6 @@ mod tests {
|
||||
assert!(chain.value("p0").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expression_and_parameter_depth_share_a_limit_on_a_small_stack() {
|
||||
std::thread::Builder::new()
|
||||
.stack_size(1024 * 1024)
|
||||
.spawn(|| {
|
||||
for op in ["+", "-", "*", "/"] {
|
||||
let within = vec!["1"; MAX_DEPTH].join(op);
|
||||
assert!(eval_const(&within).is_ok());
|
||||
assert_eq!(parse(&format!("{within}{op}1")), Err(ExprError::TooDeep));
|
||||
}
|
||||
let mut env = Env::new();
|
||||
for i in 0..MAX_DEPTH {
|
||||
env.insert(&format!("p{i}"), &format!("p{}+{}", i + 1, vec!["0"; 30].join("+")));
|
||||
}
|
||||
env.insert(&format!("p{MAX_DEPTH}"), "1");
|
||||
assert_eq!(env.value("p0"), Err(ExprError::TooDeep));
|
||||
assert_eq!(env.eval("p0+1"), Err(ExprError::TooDeep));
|
||||
env.insert("safe", "2*3");
|
||||
assert_eq!(env.eval("safe+1"), Ok(7.0));
|
||||
})
|
||||
.unwrap()
|
||||
.join()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_replaces_whole_names_only() {
|
||||
assert_eq!(rename("d1+d10*d1 / (d1_x+2e1)", "d1", "w"), "w+d10*w / (d1_x+2e1)");
|
||||
assert_eq!(rename("1e2+e", "e", "x"), "1e2+x");
|
||||
assert_eq!(rename("é+a", "a", "b"), "é+b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_valid() {
|
||||
assert!(valid_name("d1") && valid_name("_w") && !valid_name("1d") && !valid_name("a-b") && !valid_name("") && !valid_name("pi"));
|
||||
|
||||
+53
-445
@@ -1,7 +1,6 @@
|
||||
//! CADCraft parametric solver.
|
||||
//!
|
||||
//! Maps constrained lines, arcs, circles, points, polyline vertices, ellipses, splines and block
|
||||
//! references to a parameter vector
|
||||
//! Maps constrained lines, arcs, circles, points and polyline vertices to a parameter vector
|
||||
//! ([`model`]), turns each [`Constraint`] into residual equations ([`residual`]), and drives the
|
||||
//! residuals to zero with a weighted minimum-norm damped Gauss–Newton solver ([`lm`]), so
|
||||
//! geometry moves as little as possible and geometry the caller wants kept (just edited, or picked
|
||||
@@ -10,11 +9,7 @@
|
||||
//! Inconsistent systems are not "solved" by moving things wildly: the solve fails, nothing is
|
||||
//! written back, and the error names the conflicting constraints (each one whose removal makes
|
||||
//! the rest solvable). New constraints that add no independent equation are reported as
|
||||
//! redundant (over-constraining), as AutoCAD does, naming the constraints they duplicate.
|
||||
//!
|
||||
//! [`dof()`] reports the remaining degrees of freedom: the rank of the constraint Jacobian per
|
||||
//! connected component (Householder QR with column pivoting), and per object the dimension of
|
||||
//! the null-space motion its own parameters still have.
|
||||
//! redundant (over-constraining), as AutoCAD does.
|
||||
//!
|
||||
//! Own implementation (clean-room): the KittyCAD `ezpz` crate (MIT) was evaluated; it pulls in
|
||||
//! the `faer` linear-algebra stack and models KCL's geometry, so a small solver tailored to the
|
||||
@@ -23,8 +18,6 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod auto;
|
||||
mod curves;
|
||||
mod dof;
|
||||
pub mod expr;
|
||||
mod lm;
|
||||
mod model;
|
||||
@@ -36,12 +29,11 @@ use cadcraft_doc::{Constraint, ConstraintKind, Drawing, GeomRef, Handle};
|
||||
use cadcraft_geom::Vec2;
|
||||
use serde::Serialize;
|
||||
|
||||
pub use auto::{INFER_TYPES, InferOptions, autoconstrain, infer, infer_placed};
|
||||
pub use dof::{DofReport, DofStatus, ObjectDof, dof};
|
||||
pub use auto::{InferOptions, autoconstrain, infer};
|
||||
pub use expr::{Env, ExprError};
|
||||
pub use model::{ref_points, rigid_points, supported};
|
||||
pub use model::supported;
|
||||
|
||||
use model::{Model, Prep};
|
||||
use model::Model;
|
||||
use residual::{Built, Form};
|
||||
|
||||
/// Most constraints one drawing may hold.
|
||||
@@ -50,11 +42,6 @@ pub const MAX_CONSTRAINTS: usize = 5000;
|
||||
const MAX_CONFLICT_PROBES: usize = 48;
|
||||
/// Solver iterations.
|
||||
const MAX_ITER: usize = 200;
|
||||
/// Largest residual-evaluation work (including spline rebuilds) one numerical Jacobian may take,
|
||||
/// shared by solving, redundancy checks and DOF analysis.
|
||||
pub(crate) const MAX_EVAL_WORK: f64 = 2e9;
|
||||
/// Residual evaluations a solver iteration makes besides its Jacobian (damped trial steps).
|
||||
const TRIAL_EVALS: f64 = 14.0;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, thiserror::Error)]
|
||||
pub enum SolveError {
|
||||
@@ -86,12 +73,6 @@ pub struct SolveOptions {
|
||||
pub keep: Vec<Handle>,
|
||||
/// Entities that should take up the change.
|
||||
pub prefer_move: Vec<Handle>,
|
||||
/// Points held where they are for this solve, as a Fix would hold them (the grip an edit
|
||||
/// just put down): see [`edited_points`].
|
||||
pub hold: Vec<GeomRef>,
|
||||
/// Where a Fix on a derived point (by constraint id) holds it, instead of where the point is
|
||||
/// now: see [`moved_anchors`].
|
||||
pub anchors: Vec<(u32, Vec2)>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
@@ -187,48 +168,17 @@ pub fn next_id(d: &Drawing) -> u32 {
|
||||
|
||||
// ---------------- systems ----------------
|
||||
|
||||
/// Interpret a constraint against the model's current parameters, appending its auxiliary
|
||||
/// unknowns (contact parameters) to the model, settled with the geometry held still.
|
||||
pub(crate) fn interpret(c: &Constraint, model: &mut Model, target: f64) -> Result<Built, String> {
|
||||
let x = model.x0.clone();
|
||||
let mut b = Built::new(c, model, &x, target)?;
|
||||
if b.aux_init.is_empty() {
|
||||
return Ok(b);
|
||||
}
|
||||
if model.x0.len() + b.aux_init.len() > model::MAX_PARAMS + 1000 {
|
||||
return Err("constraint system too large".into());
|
||||
}
|
||||
b.aux = model.x0.len();
|
||||
model.x0.extend(b.aux_init.iter().copied());
|
||||
let free: Vec<usize> = (b.aux..model.x0.len()).collect();
|
||||
let weights = vec![1.0; model.x0.len()];
|
||||
let o = {
|
||||
let m: &Model = model;
|
||||
let f = |x: &[f64], out: &mut Vec<f64>| b.eval(m, x, out);
|
||||
lm::solve(&f, &m.x0, &free, &weights, 1e-14, 60)
|
||||
};
|
||||
model.x0 = o.x;
|
||||
Ok(b)
|
||||
}
|
||||
|
||||
/// One connected component of the constraint graph.
|
||||
pub(crate) struct Comp {
|
||||
pub(crate) model: Model,
|
||||
pub(crate) built: Vec<Built>,
|
||||
struct Comp {
|
||||
model: Model,
|
||||
built: Vec<Built>,
|
||||
weights: Vec<f64>,
|
||||
/// Parameters of the points held by [`SolveOptions::hold`].
|
||||
held: Vec<usize>,
|
||||
tol: f64,
|
||||
}
|
||||
|
||||
impl Comp {
|
||||
fn locks(&self, skip: Option<usize>) -> Vec<bool> {
|
||||
let mut locked = vec![false; self.model.x0.len()];
|
||||
for &j in &self.held {
|
||||
if let Some(l) = locked.get_mut(j) {
|
||||
*l = true;
|
||||
}
|
||||
}
|
||||
for (i, b) in self.built.iter().enumerate() {
|
||||
if Some(i) == skip || b.form != Form::Lock {
|
||||
continue;
|
||||
@@ -243,79 +193,27 @@ impl Comp {
|
||||
}
|
||||
locked
|
||||
}
|
||||
pub(crate) fn free(&self, skip: Option<usize>) -> Vec<usize> {
|
||||
fn free(&self, skip: Option<usize>) -> Vec<usize> {
|
||||
self.locks(skip).iter().enumerate().filter(|(_, l)| !**l).map(|(i, _)| i).collect()
|
||||
}
|
||||
pub(crate) fn eval(&self, x: &[f64], out: &mut Vec<f64>, skip: Option<usize>) {
|
||||
fn eval(&self, x: &[f64], out: &mut Vec<f64>, skip: Option<usize>) {
|
||||
for (i, b) in self.built.iter().enumerate() {
|
||||
if Some(i) != skip {
|
||||
b.eval(&self.model, x, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(crate) fn rows(&self, skip: Option<usize>) -> usize {
|
||||
fn rows(&self, skip: Option<usize>) -> usize {
|
||||
self.built.iter().enumerate().filter(|(i, _)| Some(*i) != skip).map(|(_, b)| b.rows).sum()
|
||||
}
|
||||
/// Work of one solver iteration's residual evaluations over `free`: every residual twice per
|
||||
/// free parameter, plus a spline rebuild each time a spline's own defining points move (rebuilds
|
||||
/// are shared by residuals at the same points, see [`Model`]).
|
||||
pub(crate) fn eval_work(&self, free: &[usize], skip: Option<usize>) -> f64 {
|
||||
let rows = self.rows(skip) as f64;
|
||||
let evals = 2.0 * free.len() as f64 + TRIAL_EVALS;
|
||||
let mut splines: Vec<Handle> = Vec::new();
|
||||
for (i, b) in self.built.iter().enumerate() {
|
||||
if Some(i) != skip && b.spline_reconstruct_cost(&self.model) > 0.0 {
|
||||
splines.extend(b.refs.iter().map(|r| r.handle).filter(|h| self.model.spline_reconstruct_cost(*h) > 0.0));
|
||||
}
|
||||
}
|
||||
splines.sort_unstable();
|
||||
splines.dedup();
|
||||
let rebuilds: f64 = splines
|
||||
.iter()
|
||||
.filter_map(|h| self.model.slot(*h))
|
||||
.map(|s| {
|
||||
let own = free.iter().filter(|j| (s.off..s.off + s.len).contains(*j)).count() as f64;
|
||||
(2.0 * own + TRIAL_EVALS + 1.0) * self.model.spline_reconstruct_cost(s.handle)
|
||||
})
|
||||
.sum();
|
||||
evals * rows + rebuilds
|
||||
}
|
||||
fn run(&self, skip: Option<usize>) -> Result<lm::Outcome, SolveError> {
|
||||
let free = self.free(skip);
|
||||
let rows = self.rows(skip);
|
||||
if rows > lm::MAX_ROWS || (rows as f64) * (rows as f64) * (free.len() as f64) > 4e8 {
|
||||
return Err(SolveError::TooLarge);
|
||||
}
|
||||
// Numerical differentiation rebuilds a fit spline through a dense solve for every move of
|
||||
// its own fit points: refuse before iterating when that alone is too much (CWE-407).
|
||||
if self.eval_work(&free, skip) > MAX_EVAL_WORK {
|
||||
return Err(SolveError::TooLarge);
|
||||
}
|
||||
let f = |x: &[f64], out: &mut Vec<f64>| self.eval(x, out, skip);
|
||||
let mut o = lm::solve(&f, &self.model.x0, &free, &self.weights, self.tol, MAX_ITER);
|
||||
// A solution the ellipses would be stored differently from (axes swapped, minor axis
|
||||
// mirrored) holds only if the constraints still hold on the stored form: a constraint on
|
||||
// an axis end by index does not.
|
||||
if o.converged && !self.model.storable(&o.x) {
|
||||
let mut out = Vec::new();
|
||||
match self.model.stored_form(&o.x) {
|
||||
Some(mut xc) => {
|
||||
// Contact parameters are positions along the curves: they follow the stored form.
|
||||
for b in self.built.iter().filter(|b| b.form == Form::Contact) {
|
||||
for (k, r) in b.refs.iter().enumerate() {
|
||||
if let (Some(&t), Some(slot)) = (o.x.get(b.aux + k), xc.get_mut(b.aux + k)) {
|
||||
*slot = self.model.stored_t(&o.x, r.handle, t);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.eval(&xc, &mut out, skip);
|
||||
o.converged = lm::max_abs(&out) <= self.tol;
|
||||
o.x = xc;
|
||||
}
|
||||
None => o.converged = false,
|
||||
}
|
||||
}
|
||||
Ok(o)
|
||||
Ok(lm::solve(&f, &self.model.x0, &free, &self.weights, self.tol, MAX_ITER))
|
||||
}
|
||||
/// Constraint ids in conflict (each one whose removal makes the rest solvable).
|
||||
fn conflicts(&self, x_failed: &[f64]) -> Vec<u32> {
|
||||
@@ -392,98 +290,51 @@ fn components<'a>(cons: &[&'a Constraint]) -> Vec<Vec<&'a Constraint>> {
|
||||
groups.into_values().collect()
|
||||
}
|
||||
|
||||
/// The solver weight of each unknown of `model`: kept handles weigh more, preferred-to-move ones
|
||||
/// less, angles and ratios by size², and contact parameters move freely.
|
||||
fn solver_weights(model: &Model, opts: &SolveOptions) -> Vec<f64> {
|
||||
let mut weights = vec![1.0; model.x0.len()];
|
||||
for s in &model.slots {
|
||||
let w = if opts.keep.contains(&s.handle) {
|
||||
1e4
|
||||
} else if opts.prefer_move.contains(&s.handle) {
|
||||
1e-2
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
for j in s.off..s.off + s.len {
|
||||
if let Some(slot) = weights.get_mut(j) {
|
||||
*slot = w;
|
||||
}
|
||||
}
|
||||
for (j, f) in slot_scales(model, s) {
|
||||
if let Some(slot) = weights.get_mut(j) {
|
||||
*slot *= f;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Contact parameters move freely.
|
||||
let geo = model.slots.iter().map(|s| s.off + s.len).max().unwrap_or(0);
|
||||
for w in weights.iter_mut().skip(geo) {
|
||||
*w = 1e-3;
|
||||
}
|
||||
weights
|
||||
}
|
||||
|
||||
/// Per-unknown factors for the angles and ratios of one slot: size², so that the movement of
|
||||
/// points is what is minimised.
|
||||
fn slot_scales(model: &Model, s: &model::Slot) -> Vec<(usize, f64)> {
|
||||
let sq = |j: usize| model.x0.get(j).map_or(1.0, |r| (r * r).max(1e-6));
|
||||
match s.shape {
|
||||
model::Shape::Arc => vec![(s.off + 3, sq(s.off + 2)), (s.off + 4, sq(s.off + 2))],
|
||||
model::Shape::Ellipse { arc } => {
|
||||
let a2 = (sq(s.off + 2) + sq(s.off + 3)).max(1e-6);
|
||||
let mut v = vec![(s.off + 4, a2)];
|
||||
if arc {
|
||||
v.extend([(s.off + 5, a2), (s.off + 6, a2)]);
|
||||
}
|
||||
v
|
||||
}
|
||||
model::Shape::Insert { .. } => vec![(s.off + 2, model.rigid_extent2(s.handle).max(1.0))],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the component systems. Constraints that cannot be interpreted are returned as skipped.
|
||||
pub(crate) fn build(d: &Drawing, cons: &[&Constraint], opts: &SolveOptions) -> Result<(Vec<Comp>, Vec<(u32, String)>), SolveError> {
|
||||
build_with(d, cons, opts, &mut Prep::new(model::MAX_RIGID_WORK))
|
||||
}
|
||||
|
||||
/// Build constraint components sharing one caller-owned geometry preparation (block sampling and
|
||||
/// its member budget, spline rebuilds) between every model.
|
||||
/// Return constraints that cannot be interpreted as skipped entries.
|
||||
pub(crate) fn build_with(
|
||||
d: &Drawing,
|
||||
cons: &[&Constraint],
|
||||
opts: &SolveOptions,
|
||||
prep: &mut Prep,
|
||||
) -> Result<(Vec<Comp>, Vec<(u32, String)>), SolveError> {
|
||||
fn build(d: &Drawing, cons: &[&Constraint], opts: &SolveOptions) -> Result<(Vec<Comp>, Vec<(u32, String)>), SolveError> {
|
||||
let mut e = env(d);
|
||||
let mut skipped = Vec::new();
|
||||
let mut comps = Vec::new();
|
||||
for group in components(cons) {
|
||||
let handles: Vec<Handle> = group.iter().flat_map(|c| c.refs.iter().map(|r| r.handle)).collect();
|
||||
let mut model = Model::build_with(d, &handles, prep);
|
||||
let model = Model::build(d, &handles);
|
||||
let mut built = Vec::new();
|
||||
for c in group {
|
||||
let target = if c.kind.is_dimensional() { constraint_value(&mut e, c)? } else { 0.0 };
|
||||
match interpret(c, &mut model, target) {
|
||||
Ok(mut b) => {
|
||||
if let Form::Anchor(_) = b.form
|
||||
&& let Some(&(_, a)) = opts.anchors.iter().find(|(id, _)| *id == c.id)
|
||||
{
|
||||
b.form = Form::Anchor(a);
|
||||
}
|
||||
built.push(b);
|
||||
}
|
||||
match Built::new(c, &model, &model.x0, target) {
|
||||
Ok(b) => built.push(b),
|
||||
Err(msg) => skipped.push((c.id, msg)),
|
||||
}
|
||||
}
|
||||
if built.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let weights = solver_weights(&model, opts);
|
||||
let held = opts.hold.iter().filter_map(|r| model.lock_indices(r)).flatten().collect();
|
||||
let mut weights = vec![1.0; model.x0.len()];
|
||||
for s in &model.slots {
|
||||
let w = if opts.keep.contains(&s.handle) {
|
||||
1e4
|
||||
} else if opts.prefer_move.contains(&s.handle) {
|
||||
1e-2
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
for j in s.off..s.off + s.len {
|
||||
if let Some(slot) = weights.get_mut(j) {
|
||||
*slot = w;
|
||||
}
|
||||
}
|
||||
if s.shape == model::Shape::Arc {
|
||||
// Angles: weight by r² so the endpoints' movement is what is minimised.
|
||||
let r2 = model.x0.get(s.off + 2).map_or(1.0, |r| (r * r).max(1e-6));
|
||||
for j in [s.off + 3, s.off + 4] {
|
||||
if let Some(slot) = weights.get_mut(j) {
|
||||
*slot *= r2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let tol = scale_tol(&model.x0);
|
||||
comps.push(Comp { model, built, weights, held, tol });
|
||||
comps.push(Comp { model, built, weights, tol });
|
||||
}
|
||||
Ok((comps, skipped))
|
||||
}
|
||||
@@ -513,13 +364,13 @@ pub fn solve(d: &mut Drawing, opts: &SolveOptions) -> Result<SolveReport, SolveE
|
||||
pub fn residuals(d: &Drawing) -> Vec<(u32, Option<f64>)> {
|
||||
let mut e = env(d);
|
||||
let handles: Vec<Handle> = d.constraints.iter().flat_map(|c| c.refs.iter().map(|r| r.handle)).collect();
|
||||
let mut model = Model::build(d, &handles);
|
||||
let model = Model::build(d, &handles);
|
||||
d.constraints
|
||||
.iter()
|
||||
.take(MAX_CONSTRAINTS)
|
||||
.map(|c| {
|
||||
let target = if c.kind.is_dimensional() { constraint_value(&mut e, c).ok() } else { Some(0.0) };
|
||||
let r = target.and_then(|t| interpret(c, &mut model, t).ok()).map(|b| {
|
||||
let r = target.and_then(|t| Built::new(c, &model, &model.x0, t).ok()).map(|b| {
|
||||
let mut out = Vec::new();
|
||||
b.eval(&model, &model.x0, &mut out);
|
||||
lm::max_abs(&out)
|
||||
@@ -529,142 +380,6 @@ pub fn residuals(d: &Drawing) -> Vec<(u32, Option<f64>)> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Put back what each Fix locks where `before` had it, and report whether anything moved.
|
||||
///
|
||||
/// A Fix keeps no position of its own: the solver locks the parameters at their current values.
|
||||
/// Without this, an edit that moves a fixed point (a grip drag) would carry the lock along with
|
||||
/// it. Only Fixes that already existed in `before`, on objects of the same shape, are restored.
|
||||
/// An object held by a Fix on a derived point (a line's midpoint, an arc's end) has nothing to
|
||||
/// lock: when the edit only moved it (MOVE), it is put back whole, so it is not reshaped to bring
|
||||
/// the point back.
|
||||
pub fn restore_fixed(d: &mut Drawing, before: &Drawing) -> bool {
|
||||
let refs: Vec<GeomRef> = d
|
||||
.constraints
|
||||
.iter()
|
||||
.filter(|c| c.kind == ConstraintKind::Fix && before.constraints.contains(c))
|
||||
.flat_map(|c| c.refs.iter().copied())
|
||||
.collect();
|
||||
if refs.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let handles: Vec<Handle> = refs.iter().map(|r| r.handle).collect();
|
||||
let (now, old) = (Model::build(d, &handles), Model::build(before, &handles));
|
||||
let mut x = now.x0.clone();
|
||||
let mut changed = false;
|
||||
for r in &refs {
|
||||
let same_shape = matches!((now.slot(r.handle), old.slot(r.handle)), (Some(a), Some(b)) if a.shape == b.shape);
|
||||
if !same_shape {
|
||||
continue;
|
||||
}
|
||||
let (Some(i), Some(j)) = (now.lock_indices(r), old.lock_indices(r)) else {
|
||||
if let (Some(a), Some(b)) = (now.slot(r.handle), old.slot(r.handle))
|
||||
&& translated(&now, &old, r.handle)
|
||||
&& let (Some(dst), Some(src)) = (x.get_mut(a.off..a.off + a.len), old.x0.get(b.off..b.off + b.len))
|
||||
{
|
||||
for (v, &w) in dst.iter_mut().zip(src) {
|
||||
*v = w;
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
continue;
|
||||
};
|
||||
for (i, j) in i.into_iter().zip(j) {
|
||||
if let (Some(slot), Some(&v)) = (x.get_mut(i), old.x0.get(j))
|
||||
&& *slot != v
|
||||
{
|
||||
*slot = v;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
now.write_back(d, &x);
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// The parameters of `h`'s points (see [`Model::point_refs`]) in `m`, as (x, y) pairs.
|
||||
fn point_params(m: &Model, h: Handle) -> Vec<(usize, usize)> {
|
||||
m.point_refs(h)
|
||||
.iter()
|
||||
.filter_map(|r| m.lock_indices(r))
|
||||
.flat_map(|ix| ix.as_chunks::<2>().0.iter().map(|&[x, y]| (x, y)).collect::<Vec<_>>())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Is `h` in `now` its shape in `old` moved by one vector, and nothing else?
|
||||
fn translated(now: &Model, old: &Model, h: Handle) -> bool {
|
||||
let (Some(a), Some(b)) = (now.slot(h), old.slot(h)) else { return false };
|
||||
let (Some(xa), Some(xb)) = (now.x0.get(a.off..a.off + a.len), old.x0.get(b.off..b.off + b.len)) else { return false };
|
||||
let tol = scale_tol(xb) * 10.0;
|
||||
let pts: Vec<(usize, usize)> =
|
||||
point_params(old, h).into_iter().filter_map(|(i, j)| Some((i.checked_sub(b.off)?, j.checked_sub(b.off)?))).collect();
|
||||
let delta = |(i, j): (usize, usize)| Some(Vec2::new(xa.get(i)? - xb.get(i)?, xa.get(j)? - xb.get(j)?));
|
||||
let Some(v) = pts.first().and_then(|&p| delta(p)) else { return false };
|
||||
if v.len() <= tol || !pts.iter().all(|&p| delta(p).is_some_and(|d| d.near(v, tol))) {
|
||||
return false;
|
||||
}
|
||||
(0..xb.len())
|
||||
.filter(|k| !pts.iter().any(|&(i, j)| *k == i || *k == j))
|
||||
.all(|k| matches!((xa.get(k), xb.get(k)), (Some(p), Some(q)) if (p - q).abs() <= tol))
|
||||
}
|
||||
|
||||
/// The points of `h` (its ends, centre or vertices) the edit from `before` to `d` moved, when it
|
||||
/// moved some of them but not all: the grip that was dragged, to pass as [`SolveOptions::hold`] so
|
||||
/// it stays where it was put and the rest of the object takes up the constraints. Empty when the
|
||||
/// object kept its points or moved them all.
|
||||
pub fn edited_points(d: &Drawing, before: &Drawing, h: Handle) -> Vec<GeomRef> {
|
||||
let (now, old) = (Model::build(d, &[h]), Model::build(before, &[h]));
|
||||
if !matches!((now.slot(h), old.slot(h)), (Some(a), Some(b)) if a.shape == b.shape) {
|
||||
return Vec::new();
|
||||
}
|
||||
let tol = scale_tol(&old.x0) * 10.0;
|
||||
let refs = now.point_refs(h);
|
||||
let moved: Vec<GeomRef> = refs
|
||||
.iter()
|
||||
.filter(|r| matches!((now.lock_indices(r), old.lock_indices(r)), (Some(i), Some(j)) if i.iter().zip(&j).any(|(&i, &j)| matches!((now.x0.get(i), old.x0.get(j)), (Some(p), Some(q)) if (p - q).abs() > tol))))
|
||||
.copied()
|
||||
.collect();
|
||||
if moved.len() == refs.len() { Vec::new() } else { moved }
|
||||
}
|
||||
|
||||
/// Where `before` had each derived point held by a Fix (a line's midpoint, an arc's end), for the
|
||||
/// Fixes whose point has moved since, to pass as [`SolveOptions::anchors`].
|
||||
///
|
||||
/// A Fix on a derived point anchors it where it is when the system is built, and
|
||||
/// [`restore_fixed`] has no parameters to put back: without this, an edit that moves the point
|
||||
/// would leave the Fix satisfied at the new place.
|
||||
pub fn moved_anchors(d: &Drawing, before: &Drawing) -> Vec<(u32, Vec2)> {
|
||||
let fixes: Vec<(u32, GeomRef)> = d
|
||||
.constraints
|
||||
.iter()
|
||||
.filter(|c| c.kind == ConstraintKind::Fix && before.constraints.contains(c))
|
||||
.filter_map(|c| match c.refs.as_slice() {
|
||||
[r] => Some((c.id, *r)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if fixes.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let handles: Vec<Handle> = fixes.iter().map(|(_, r)| r.handle).collect();
|
||||
let (now, old) = (Model::build(d, &handles), Model::build(before, &handles));
|
||||
let tol = scale_tol(&old.x0);
|
||||
let mut out = Vec::new();
|
||||
for (id, r) in fixes {
|
||||
let same_shape = matches!((now.slot(r.handle), old.slot(r.handle)), (Some(a), Some(b)) if a.shape == b.shape);
|
||||
if !same_shape || now.lock_indices(&r).is_some() {
|
||||
continue;
|
||||
}
|
||||
if let (Some(p), Some(a)) = (now.point(&now.x0, &r), old.point(&old.x0, &r))
|
||||
&& !p.near(a, tol)
|
||||
{
|
||||
out.push((id, a));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// True when every interpretable constraint holds at the current geometry.
|
||||
pub fn all_satisfied(d: &Drawing) -> bool {
|
||||
let handles: Vec<Handle> = d.constraints.iter().flat_map(|c| c.refs.iter().map(|r| r.handle)).collect();
|
||||
@@ -674,11 +389,6 @@ pub fn all_satisfied(d: &Drawing) -> bool {
|
||||
|
||||
/// Check that a constraint's references make sense for its type.
|
||||
pub fn validate(d: &Drawing, c: &Constraint) -> Result<(), String> {
|
||||
validate_with(d, c, &mut Prep::new(model::MAX_RIGID_WORK))
|
||||
}
|
||||
|
||||
/// [`validate`] sharing block samples and spline rebuilds with other checks of the same drawing.
|
||||
fn validate_with(d: &Drawing, c: &Constraint, prep: &mut Prep) -> Result<(), String> {
|
||||
let handles: Vec<Handle> = c.refs.iter().map(|r| r.handle).collect();
|
||||
for h in &handles {
|
||||
match d.entity(*h) {
|
||||
@@ -687,16 +397,16 @@ fn validate_with(d: &Drawing, c: &Constraint, prep: &mut Prep) -> Result<(), Str
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut model = Model::build_with(d, &handles, prep);
|
||||
interpret(c, &mut model, 1.0).map(|_| ())
|
||||
let model = Model::build(d, &handles);
|
||||
Built::new(c, &model, &model.x0, 1.0).map(|_| ())
|
||||
}
|
||||
|
||||
/// The current measured value of a dimensional constraint over `refs` (degrees for angular).
|
||||
pub fn measure(d: &Drawing, kind: ConstraintKind, refs: &[GeomRef]) -> Result<f64, String> {
|
||||
let c = Constraint { id: 0, kind, refs: refs.to_vec(), name: String::new(), expr: String::new() };
|
||||
let handles: Vec<Handle> = refs.iter().map(|r| r.handle).collect();
|
||||
let mut model = Model::build(d, &handles);
|
||||
let b = interpret(&c, &mut model, 1.0)?;
|
||||
let model = Model::build(d, &handles);
|
||||
let b = Built::new(&c, &model, &model.x0, 1.0)?;
|
||||
Ok(b.measure(&model, &model.x0))
|
||||
}
|
||||
|
||||
@@ -704,12 +414,7 @@ pub fn measure(d: &Drawing, kind: ConstraintKind, refs: &[GeomRef]) -> Result<f6
|
||||
/// Returns the removed ids.
|
||||
pub fn purge_invalid(d: &mut Drawing) -> Vec<u32> {
|
||||
let mut gone = Vec::new();
|
||||
// One preparation for every check: each block is sampled and each spline rebuilt once,
|
||||
// however many constraints refer to them (CWE-407). Its member budget is not shared, so
|
||||
// a constraint is never dropped because earlier ones spent it: every block is still
|
||||
// examined at most once, up to its own member limit.
|
||||
let mut prep = Prep::new(usize::MAX);
|
||||
let keep: Vec<bool> = d.constraints.iter().map(|c| validate_with(d, c, &mut prep).is_ok()).collect();
|
||||
let keep: Vec<bool> = d.constraints.iter().map(|c| validate(d, c).is_ok()).collect();
|
||||
let mut i = 0;
|
||||
d.constraints.retain(|c| {
|
||||
let k = keep.get(i).copied().unwrap_or(true);
|
||||
@@ -770,7 +475,8 @@ pub fn add(d: &mut Drawing, mut c: Constraint, opts: &SolveOptions) -> Result<u3
|
||||
return Err(SolveError::Conflict { ids });
|
||||
}
|
||||
// Redundancy: the new rows must raise the rank.
|
||||
if let Some(nb) = comp.built.iter().find(|b| b.id == id)
|
||||
if let Some(pos) = comp.built.iter().position(|b| b.id == id)
|
||||
&& let Some(nb) = comp.built.get(pos)
|
||||
&& nb.rows > 0
|
||||
{
|
||||
let free = comp.free(None);
|
||||
@@ -783,34 +489,11 @@ pub fn add(d: &mut Drawing, mut c: Constraint, opts: &SolveOptions) -> Result<u3
|
||||
};
|
||||
let rows_all: usize = ordered.iter().map(|b| b.rows).sum();
|
||||
let rows_old = rows_all - nb.rows;
|
||||
let nf = free.len();
|
||||
let jac = lm::jacobian(&f, &o.x, &free, rows_all);
|
||||
let r_all = lm::rank_qr(&jac, rows_all, nf);
|
||||
let r_old = lm::rank_qr(jac.get(..rows_old * nf).unwrap_or_default(), rows_old, nf);
|
||||
let r_all = lm::rank(&jac, rows_all, free.len());
|
||||
let r_old = lm::rank(jac.get(..rows_old * free.len()).unwrap_or_default(), rows_old, free.len());
|
||||
if r_all < r_old + nb.rows {
|
||||
return Err(SolveError::Redundant { ids: redundant_with(&comp, &ordered, &jac, nf, id) });
|
||||
}
|
||||
}
|
||||
// A Fix that locks parameters adds one equation per parameter it locks.
|
||||
if let Some(pos) = comp.built.iter().position(|b| b.id == id)
|
||||
&& comp.built.get(pos).is_some_and(|b| b.form == Form::Lock)
|
||||
{
|
||||
let before = comp.free(Some(pos));
|
||||
if comp.eval_work(&before, Some(pos)) > MAX_EVAL_WORK {
|
||||
return Err(SolveError::TooLarge);
|
||||
}
|
||||
let after = comp.free(None);
|
||||
let locked: Vec<usize> = before.iter().enumerate().filter(|(_, j)| !after.contains(j)).map(|(k, _)| k).collect();
|
||||
let rows_old = comp.rows(Some(pos));
|
||||
let nf = before.len();
|
||||
let f = |x: &[f64], out: &mut Vec<f64>| comp.eval(x, out, Some(pos));
|
||||
let mut jac = lm::jacobian(&f, &o.x, &before, rows_old);
|
||||
let r_old = lm::rank_qr(&jac, rows_old, nf);
|
||||
for k in &locked {
|
||||
jac.extend((0..nf).map(|c| if c == *k { 1.0 } else { 0.0 }));
|
||||
}
|
||||
if locked.is_empty() || lm::rank_qr(&jac, rows_old + locked.len(), nf) < r_old + locked.len() {
|
||||
let mine: Vec<Handle> = comp.built.get(pos).map(|b| b.refs.iter().map(|r| r.handle).collect()).unwrap_or_default();
|
||||
let mine: Vec<Handle> = nb.refs.iter().map(|r| r.handle).collect();
|
||||
let ids = comp.built.iter().filter(|b| b.id != id && b.refs.iter().any(|r| mine.contains(&r.handle))).map(|b| b.id).collect();
|
||||
return Err(SolveError::Redundant { ids });
|
||||
}
|
||||
@@ -820,53 +503,6 @@ pub fn add(d: &mut Drawing, mut c: Constraint, opts: &SolveOptions) -> Result<u3
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// The existing constraints a redundant new one (last in `ordered`) duplicates: each one whose
|
||||
/// removal would let the new one add independent equations. Falls back to the constraints on
|
||||
/// the same objects.
|
||||
fn redundant_with(comp: &Comp, ordered: &[&Built], jac: &[f64], nf: usize, id: u32) -> Vec<u32> {
|
||||
let mut starts = Vec::with_capacity(ordered.len());
|
||||
let mut at = 0;
|
||||
for b in ordered {
|
||||
starts.push(at);
|
||||
at += b.rows;
|
||||
}
|
||||
let pick = |skip: usize, with_new: bool| -> (Vec<f64>, usize) {
|
||||
let mut m = Vec::new();
|
||||
let mut rows = 0;
|
||||
let last = ordered.len().saturating_sub(1);
|
||||
for (i, b) in ordered.iter().enumerate() {
|
||||
if i == skip || (i == last && !with_new) {
|
||||
continue;
|
||||
}
|
||||
let s = starts.get(i).copied().unwrap_or(0);
|
||||
if let Some(part) = jac.get(s * nf..(s + b.rows) * nf) {
|
||||
m.extend_from_slice(part);
|
||||
rows += b.rows;
|
||||
}
|
||||
}
|
||||
(m, rows)
|
||||
};
|
||||
let new_rows = ordered.last().map_or(0, |b| b.rows);
|
||||
let mut ids = Vec::new();
|
||||
if ordered.len() <= MAX_CONFLICT_PROBES + 1 {
|
||||
for (i, b) in ordered.iter().enumerate().take(ordered.len().saturating_sub(1)) {
|
||||
if b.rows == 0 {
|
||||
continue;
|
||||
}
|
||||
let (old, ro) = pick(i, false);
|
||||
let (all, ra) = pick(i, true);
|
||||
if lm::rank_qr(&all, ra, nf) == lm::rank_qr(&old, ro, nf) + new_rows {
|
||||
ids.push(b.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if ids.is_empty() {
|
||||
let mine: Vec<Handle> = ordered.last().map(|b| b.refs.iter().map(|r| r.handle).collect()).unwrap_or_default();
|
||||
ids = comp.built.iter().filter(|b| b.id != id && b.refs.iter().any(|r| mine.contains(&r.handle))).map(|b| b.id).collect();
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
/// Format a value for an expression (up to 6 decimals, trailing zeros trimmed).
|
||||
pub fn format_value(v: f64) -> String {
|
||||
let s = format!("{v:.6}");
|
||||
@@ -909,33 +545,6 @@ pub fn set_parameter(d: &mut Drawing, name: &str, expr_text: &str, opts: &SolveO
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Rename a parameter (dimensional constraint name or user parameter) and every expression that
|
||||
/// uses it. Geometry doesn't change. On error the drawing is unchanged.
|
||||
pub fn rename_parameter(d: &mut Drawing, old: &str, new: &str) -> Result<(), SolveError> {
|
||||
if !name_in_use(d, old) {
|
||||
return Err(SolveError::Other(format!("no parameter `{old}`")));
|
||||
}
|
||||
if !expr::valid_name(new) {
|
||||
return Err(SolveError::Other(format!("invalid parameter name `{new}`")));
|
||||
}
|
||||
if new != old && name_in_use(d, new) {
|
||||
return Err(SolveError::Other(format!("`{new}` is already in use")));
|
||||
}
|
||||
for c in d.constraints.iter_mut().filter(|c| c.kind.is_dimensional()) {
|
||||
if c.name == old {
|
||||
c.name = new.to_string();
|
||||
}
|
||||
c.expr = expr::rename(&c.expr, old, new);
|
||||
}
|
||||
for p in &mut d.parametric.parameters {
|
||||
if p.name == old {
|
||||
p.name = new.to_string();
|
||||
}
|
||||
p.expr = expr::rename(&p.expr, old, new);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Where to draw a constraint's glyphs (one anchor per reference).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -971,7 +580,6 @@ pub fn glyphs(d: &Drawing) -> Vec<Glyph> {
|
||||
.point(x, r)
|
||||
.or_else(|| model.line(x, r).map(|(a, b)| a.mid(b)))
|
||||
.or_else(|| model.circle(x, r).map(|(c, rad)| c + Vec2::new(0.0, rad)))
|
||||
.or_else(|| model.anchor(x, r))
|
||||
})
|
||||
.collect();
|
||||
Glyph {
|
||||
|
||||
+43
-132
@@ -102,124 +102,50 @@ fn solve_dense(mut a: Vec<f64>, mut b: Vec<f64>, n: usize) -> Option<Vec<f64>> {
|
||||
y.iter().all(|v| v.is_finite()).then_some(y)
|
||||
}
|
||||
|
||||
/// Householder QR with column pivoting of the columns `cols` (each of the same length). Stops
|
||||
/// when the largest remaining column norm drops to `rel` times the first pivot's (or below
|
||||
/// `abs`). Returns the numerical rank and the Householder vectors (full length, zero above the
|
||||
/// diagonal; each reflector is `I − 2vvᵀ/(vᵀv)`).
|
||||
pub(crate) fn qr(mut cols: Vec<Vec<f64>>, rel: f64, abs: f64) -> (usize, Vec<Vec<f64>>) {
|
||||
let m = cols.first().map_or(0, Vec::len);
|
||||
let n = cols.len();
|
||||
let mut refl = Vec::new();
|
||||
let mut first = None;
|
||||
for k in 0..m.min(n) {
|
||||
// Pivot: the column with the largest remaining norm.
|
||||
let mut best = (k, -1.0f64);
|
||||
for (j, c) in cols.iter().enumerate().skip(k) {
|
||||
let v = c.get(k..).map_or(0.0, norm2);
|
||||
if v.is_finite() && v > best.1 {
|
||||
best = (j, v);
|
||||
}
|
||||
}
|
||||
let norm = best.1.max(0.0).sqrt();
|
||||
let lim = first.map_or(abs, |f: f64| (f * rel).max(abs));
|
||||
if crate::curves::not_above(norm, lim) {
|
||||
break;
|
||||
}
|
||||
first.get_or_insert(norm);
|
||||
cols.swap(k, best.0);
|
||||
let Some(col) = cols.get(k) else { break };
|
||||
let mut v = vec![0.0; m];
|
||||
for i in k..m {
|
||||
if let (Some(d), Some(s)) = (v.get_mut(i), col.get(i)) {
|
||||
*d = *s;
|
||||
}
|
||||
}
|
||||
let x0 = v.get(k).copied().unwrap_or(0.0);
|
||||
let alpha = if x0 >= 0.0 { -norm } else { norm };
|
||||
if let Some(d) = v.get_mut(k) {
|
||||
*d -= alpha;
|
||||
}
|
||||
let vv = norm2(&v);
|
||||
if crate::curves::not_above(vv, 0.0) {
|
||||
break;
|
||||
}
|
||||
for c in cols.iter_mut().skip(k) {
|
||||
reflect(&v, vv, c);
|
||||
}
|
||||
refl.push(v);
|
||||
}
|
||||
(refl.len(), refl)
|
||||
}
|
||||
|
||||
fn reflect(v: &[f64], vv: f64, c: &mut [f64]) {
|
||||
let d: f64 = v.iter().zip(c.iter()).map(|(a, b)| a * b).sum();
|
||||
let f = 2.0 * d / vv;
|
||||
if f != 0.0 {
|
||||
for (ci, vi) in c.iter_mut().zip(v) {
|
||||
*ci -= f * vi;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rows and columns scaled to unit norm (zero ones left alone), as columns of the transpose:
|
||||
/// `out[i]` is row `i` of the row-major `rows × cols` matrix `m`.
|
||||
fn balanced_rows(m: &[f64], rows: usize, cols: usize) -> Vec<Vec<f64>> {
|
||||
let mut r: Vec<Vec<f64>> = (0..rows).map(|i| m.get(i * cols..(i + 1) * cols).map(<[f64]>::to_vec).unwrap_or_else(|| vec![0.0; cols])).collect();
|
||||
for row in &mut r {
|
||||
let n = norm2(row).sqrt();
|
||||
let f = if n > 1e-12 && n.is_finite() { 1.0 / n } else { 0.0 };
|
||||
row.iter_mut().for_each(|v| *v *= f);
|
||||
}
|
||||
// Columns: unit norm, except numerical noise (an exactly zero derivative evaluated by
|
||||
// differences), which scaling would turn into a spurious independent direction.
|
||||
/// Numerical rank of a row-major `rows × cols` matrix (rows normalised first).
|
||||
pub(crate) fn rank(m: &[f64], rows: usize, cols: usize) -> usize {
|
||||
let mut a: Vec<Vec<f64>> = (0..rows)
|
||||
.map(|i| {
|
||||
let r: Vec<f64> = m.get(i * cols..(i + 1) * cols).map(<[f64]>::to_vec).unwrap_or_default();
|
||||
let n = norm2(&r).sqrt();
|
||||
if n > 1e-12 { r.iter().map(|v| v / n).collect() } else { vec![0.0; cols] }
|
||||
})
|
||||
.collect();
|
||||
let mut rank = 0;
|
||||
let mut used = vec![false; rows];
|
||||
for c in 0..cols {
|
||||
let n = r.iter().map(|row| row.get(c).map_or(0.0, |v| v * v)).sum::<f64>().sqrt();
|
||||
let f = if n > COL_NOISE && n.is_finite() { 1.0 / n } else { 0.0 };
|
||||
for row in &mut r {
|
||||
if let Some(v) = row.get_mut(c) {
|
||||
*v *= f;
|
||||
let mut piv = None;
|
||||
let mut best = 1e-7;
|
||||
for (i, row) in a.iter().enumerate() {
|
||||
if used.get(i).copied().unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
let v = row.get(c).copied().unwrap_or(0.0).abs();
|
||||
if v > best {
|
||||
best = v;
|
||||
piv = Some(i);
|
||||
}
|
||||
}
|
||||
let Some(p) = piv else { continue };
|
||||
if let Some(u) = used.get_mut(p) {
|
||||
*u = true;
|
||||
}
|
||||
rank += 1;
|
||||
let prow = a.get(p).cloned().unwrap_or_default();
|
||||
let pv = prow.get(c).copied().unwrap_or(1.0);
|
||||
for (i, row) in a.iter_mut().enumerate() {
|
||||
if i == p || used.get(i).copied().unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
let f = row.get(c).copied().unwrap_or(0.0) / pv;
|
||||
if f != 0.0 {
|
||||
for (v, pr) in row.iter_mut().zip(&prow) {
|
||||
*v -= f * pr;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// Column norm (after row balancing) below which a column is difference noise.
|
||||
const COL_NOISE: f64 = 1e-6;
|
||||
/// Relative threshold below which a pivot counts as zero (the Jacobian is numerical).
|
||||
const RANK_TOL: f64 = 1e-7;
|
||||
|
||||
/// Numerical rank of a row-major `rows × cols` Jacobian (rows and columns balanced first,
|
||||
/// Householder QR with column pivoting).
|
||||
pub(crate) fn rank_qr(m: &[f64], rows: usize, cols: usize) -> usize {
|
||||
if rows == 0 || cols == 0 {
|
||||
return 0;
|
||||
}
|
||||
// QR of the transpose: its columns are the Jacobian's rows.
|
||||
qr(balanced_rows(m, rows, cols), RANK_TOL, 1e-12).0
|
||||
}
|
||||
|
||||
/// Rank and an orthonormal basis of the null space (each vector of length `cols`) of a
|
||||
/// row-major `rows × cols` Jacobian, in balanced column coordinates (a diagonal scaling, so
|
||||
/// which parameters a null vector moves is preserved).
|
||||
pub(crate) fn null_space(m: &[f64], rows: usize, cols: usize) -> (usize, Vec<Vec<f64>>) {
|
||||
let (rank, refl) = if rows == 0 { (0, Vec::new()) } else { qr(balanced_rows(m, rows, cols), RANK_TOL, 1e-12) };
|
||||
let mut basis = Vec::with_capacity(cols.saturating_sub(rank));
|
||||
for j in rank..cols {
|
||||
let mut e = vec![0.0; cols];
|
||||
if let Some(v) = e.get_mut(j) {
|
||||
*v = 1.0;
|
||||
}
|
||||
// Q e_j with Q = H_0 H_1 … H_{r−1}.
|
||||
for v in refl.iter().rev() {
|
||||
let vv = norm2(v);
|
||||
if vv > 0.0 {
|
||||
reflect(v, vv, &mut e);
|
||||
}
|
||||
}
|
||||
basis.push(e);
|
||||
}
|
||||
(rank, basis)
|
||||
rank
|
||||
}
|
||||
|
||||
/// Run the solver from `x0`. Only `free` parameters change.
|
||||
@@ -315,24 +241,9 @@ mod tests {
|
||||
let y = solve_dense(vec![2.0, 1.0, 1.0, 3.0], vec![3.0, 5.0], 2).unwrap();
|
||||
assert!((y[0] - 0.8).abs() < 1e-12 && (y[1] - 1.4).abs() < 1e-12);
|
||||
assert!(solve_dense(vec![1.0, 2.0, 2.0, 4.0], vec![1.0, 1.0], 2).is_none());
|
||||
assert_eq!(rank_qr(&[1.0, 0.0, 0.0, 1.0, 1.0, 1.0], 3, 2), 2);
|
||||
assert_eq!(rank_qr(&[1.0, 2.0, 2.0, 4.0], 2, 2), 1);
|
||||
assert_eq!(rank_qr(&[0.0; 4], 2, 2), 0);
|
||||
assert_eq!(rank_qr(&[1e-9, 0.0, 0.0, 1e6], 2, 2), 2, "balanced: scale does not hide rank");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_space_spans_free_motion() {
|
||||
// x0 + x1 = c fixes one combination; x2 is untouched.
|
||||
let (r, n) = null_space(&[1.0, 1.0, 0.0], 1, 3);
|
||||
assert_eq!((r, n.len()), (1, 2));
|
||||
for v in &n {
|
||||
assert!((v[0] + v[1]).abs() < 1e-12);
|
||||
}
|
||||
// x2's own motion survives in the null space; x0 alone does not.
|
||||
assert!(n.iter().any(|v| v[2].abs() > 0.5));
|
||||
let (r, n) = null_space(&[], 0, 2);
|
||||
assert_eq!((r, n.len()), (0, 2));
|
||||
assert_eq!(rank(&[1.0, 0.0, 0.0, 1.0, 1.0, 1.0], 3, 2), 2);
|
||||
assert_eq!(rank(&[1.0, 2.0, 2.0, 4.0], 2, 2), 1);
|
||||
assert_eq!(rank(&[0.0; 4], 2, 2), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+35
-653
@@ -5,63 +5,22 @@
|
||||
//! - Arc: `cx cy r start end` (angles in radians)
|
||||
//! - LwPolyline: `x0 y0 x1 y1 …` (bulges are kept; segments are treated as straight lines)
|
||||
//! - Point: `x y`
|
||||
//! - Ellipse: `cx cy mx my ratio` plus `start end` (parameters, radians) for an elliptical arc;
|
||||
//! `(mx, my)` is the major axis vector
|
||||
//! - Spline: its defining points `x0 y0 x1 y1 …`: the fit points of an interpolating spline
|
||||
//! (rebuilt through them on write-back), else the control points
|
||||
//! - Block reference: `x y rotation`; the block's characteristic points ride along rigidly
|
||||
//!
|
||||
//! Constraints may append auxiliary unknowns after the entity parameters (contact parameters of
|
||||
//! a tangency between curves); they are never written back.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
|
||||
use cadcraft_doc::{Block, Drawing, EntityKind, GeomRef, Handle, Insert, Sub};
|
||||
use cadcraft_geom::{Spline as GSpline, Vec2, ccw_sweep};
|
||||
|
||||
use crate::curves::{Ell, Frame, clamped, spline_end};
|
||||
use cadcraft_doc::{Drawing, EntityKind, GeomRef, Handle, Sub};
|
||||
use cadcraft_geom::{Vec2, ccw_sweep};
|
||||
|
||||
/// Most parameters one solve may have.
|
||||
pub const MAX_PARAMS: usize = 4000;
|
||||
/// Most defining points of a spline taking part in constraints.
|
||||
pub const MAX_SPLINE_POINTS: usize = 500;
|
||||
/// Most characteristic points of a block reference used as rigid constraint points.
|
||||
pub const MAX_RIGID_POINTS: usize = 256;
|
||||
/// Block members examined when sampling rigid points, counting those that contribute none, so a
|
||||
/// block padded with point-less entities cannot make sampling (repeated per Insert) unbounded.
|
||||
const MAX_RIGID_MEMBERS: usize = 100_000;
|
||||
/// Shared across every model built for one analysis request.
|
||||
pub(crate) const MAX_RIGID_WORK: usize = 1_000_000;
|
||||
/// How far past 1 a solved ellipse ratio may land by rounding and still be stored as 1.
|
||||
const RATIO_TOL: f64 = 1e-9;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Shape {
|
||||
Line,
|
||||
Circle,
|
||||
Arc,
|
||||
Poly {
|
||||
n: usize,
|
||||
closed: bool,
|
||||
},
|
||||
Poly { n: usize, closed: bool },
|
||||
Point,
|
||||
/// `arc`: elliptical arc (has start/end parameters).
|
||||
Ellipse {
|
||||
arc: bool,
|
||||
},
|
||||
/// `n` defining points; `fit`: they are fit points; `ends`: the curve has a start and an end
|
||||
/// (open and clamped) that are its first and last defining points.
|
||||
Spline {
|
||||
n: usize,
|
||||
fit: bool,
|
||||
ends: bool,
|
||||
},
|
||||
/// Block reference with `n` rigid points.
|
||||
Insert {
|
||||
n: usize,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -72,268 +31,60 @@ pub(crate) struct Slot {
|
||||
pub len: usize,
|
||||
}
|
||||
|
||||
/// The spline last rebuilt for each handle, with the defining points it was rebuilt from.
|
||||
type SplineCache = HashMap<Handle, (Vec<Vec2>, Rc<GSpline>)>;
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct Model {
|
||||
pub slots: Vec<Slot>,
|
||||
pub x0: Vec<f64>,
|
||||
index: HashMap<Handle, usize>,
|
||||
/// Spline templates (degree, knots, weights, fit options) the defining points plug into.
|
||||
splines: HashMap<Handle, GSpline>,
|
||||
/// Block reference rigid points relative to the insertion point at rotation 0.
|
||||
rigid: HashMap<Handle, Vec<Vec2>>,
|
||||
/// Rebuilt splines, shared with every model of the same [`Prep`]: residuals that ask for the
|
||||
/// same spline at the same defining points (several constraints on one spline, or derivatives
|
||||
/// taken along other objects' parameters) reuse one rebuild instead of a dense solve each.
|
||||
rebuilt: Rc<RefCell<SplineCache>>,
|
||||
}
|
||||
|
||||
/// Geometry preparation shared by every model built for one operation (a solve, a DOF report, a
|
||||
/// purge): rigid samples per block, a case-insensitive block-name index and rebuilt splines. A
|
||||
/// block referenced by many Inserts is sampled once, an unknown block name costs one hashed
|
||||
/// lookup, and a spline referenced by many constraints is rebuilt once (CWE-407).
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Prep {
|
||||
/// Block members that may still be examined across the operation.
|
||||
members_left: usize,
|
||||
/// Block-local rigid samples by the name an Insert gives (`None`: no such block).
|
||||
blocks: HashMap<String, Option<Rc<[Vec2]>>>,
|
||||
/// Lower-cased block name to the stored name it resolves to, built on the first exact miss.
|
||||
folded: Option<HashMap<String, String>>,
|
||||
rebuilt: Rc<RefCell<SplineCache>>,
|
||||
}
|
||||
|
||||
impl Prep {
|
||||
/// Preparation that may examine at most `members` block members in all.
|
||||
pub(crate) fn new(members: usize) -> Prep {
|
||||
Prep { members_left: members, blocks: HashMap::new(), folded: None, rebuilt: Rc::default() }
|
||||
}
|
||||
|
||||
/// The block an Insert names, as [`Drawing::block`] resolves it (exact name, else the first
|
||||
/// stored name equal ignoring ASCII case), without scanning every block on each miss.
|
||||
fn resolve<'d>(&mut self, d: &'d Drawing, name: &str) -> Option<&'d Block> {
|
||||
if let Some(b) = d.blocks.get(name) {
|
||||
return Some(b);
|
||||
}
|
||||
let folded = self.folded.get_or_insert_with(|| {
|
||||
let mut m = HashMap::with_capacity(d.blocks.len());
|
||||
for k in d.blocks.keys() {
|
||||
m.entry(k.to_ascii_lowercase()).or_insert_with(|| k.clone());
|
||||
}
|
||||
m
|
||||
});
|
||||
d.blocks.get(folded.get(&name.to_ascii_lowercase())?).map(|b| &**b)
|
||||
}
|
||||
|
||||
/// Finite characteristic points of a block in its own coordinates, sampled once per name.
|
||||
fn block_points(&mut self, d: &Drawing, name: &str) -> Option<Rc<[Vec2]>> {
|
||||
if let Some(s) = self.blocks.get(name) {
|
||||
return s.clone();
|
||||
}
|
||||
let sampled = self.resolve(d, name).map(|b| sample_block(b, &mut self.members_left));
|
||||
self.blocks.insert(name.to_string(), sampled.clone());
|
||||
sampled
|
||||
}
|
||||
|
||||
/// World positions of an Insert's rigid points, at most [`MAX_RIGID_POINTS`].
|
||||
fn rigid_points(&mut self, d: &Drawing, ins: &Insert) -> Vec<Vec2> {
|
||||
let Some(local) = self.block_points(d, &ins.block) else { return Vec::new() };
|
||||
let Some(block) = self.resolve(d, &ins.block) else { return Vec::new() };
|
||||
let m = ins.transform(block.base.xy());
|
||||
local.iter().map(|p| m.apply(*p)).filter(|p| p.is_finite()).take(MAX_RIGID_POINTS).collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Model {
|
||||
/// Work for a spline that an endpoint residual actually reconstructs. Fit interpolation
|
||||
/// uses dense Gaussian elimination (cubic); control splines clone and evaluate their template.
|
||||
pub(crate) fn spline_reconstruct_cost(&self, handle: Handle) -> f64 {
|
||||
let Some(Shape::Spline { n, fit, .. }) = self.slot(handle).map(|s| s.shape) else { return 0.0 };
|
||||
let n = n as f64;
|
||||
if fit { n * n * n } else { n * n }
|
||||
}
|
||||
}
|
||||
|
||||
/// Can this entity take part in constraints?
|
||||
pub fn supported(kind: &EntityKind) -> bool {
|
||||
matches!(
|
||||
kind,
|
||||
EntityKind::Line(_)
|
||||
| EntityKind::Circle(_)
|
||||
| EntityKind::Arc(_)
|
||||
| EntityKind::LwPolyline(_)
|
||||
| EntityKind::Point(_)
|
||||
| EntityKind::Ellipse(_)
|
||||
| EntityKind::Spline(_)
|
||||
| EntityKind::Insert(_)
|
||||
)
|
||||
}
|
||||
|
||||
/// World positions of a block reference's rigid points: the characteristic points (end points,
|
||||
/// centres, vertices, nested insertion points) of the block's own objects, at most
|
||||
/// [`MAX_RIGID_POINTS`]. Nested blocks are not entered.
|
||||
pub fn rigid_points(d: &Drawing, ins: &Insert) -> Vec<Vec2> {
|
||||
Prep::new(MAX_RIGID_MEMBERS).rigid_points(d, ins)
|
||||
}
|
||||
|
||||
/// Sample a block's finite characteristic points in block coordinates, within the per-block
|
||||
/// member limit and the caller-shared `members_left`. Members that contribute no points still
|
||||
/// consume traversal work; nested blocks are not entered.
|
||||
fn sample_block(block: &Block, members_left: &mut usize) -> Rc<[Vec2]> {
|
||||
let mut out = Vec::new();
|
||||
// Bound the members examined, not only the points collected: entities that contribute no
|
||||
// points (Text, Ray, …) still cost traversal (CWE-407).
|
||||
for (examined, e) in block.entities.iter().enumerate() {
|
||||
if out.len() >= MAX_RIGID_POINTS || examined >= MAX_RIGID_MEMBERS || *members_left == 0 {
|
||||
break;
|
||||
}
|
||||
*members_left -= 1;
|
||||
let pts: Vec<Vec2> = match &e.kind {
|
||||
EntityKind::Line(l) => vec![l.a.xy(), l.b.xy()],
|
||||
EntityKind::Arc(a) => {
|
||||
let g = cadcraft_geom::Arc::new(a.center.xy(), a.radius, a.start, a.end);
|
||||
vec![g.start_point(), g.end_point(), g.center]
|
||||
}
|
||||
EntityKind::Circle(c) => vec![c.center.xy()],
|
||||
EntityKind::Point(p) => vec![p.p.xy()],
|
||||
EntityKind::Ellipse(el) => vec![el.center.xy()],
|
||||
EntityKind::Insert(i) => vec![i.insert.xy()],
|
||||
EntityKind::LwPolyline(p) => p.vertices.iter().take(MAX_RIGID_POINTS).map(|v| v.p).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
out.extend(pts.into_iter().filter(|p| p.is_finite()));
|
||||
}
|
||||
out.truncate(MAX_RIGID_POINTS);
|
||||
out.into()
|
||||
}
|
||||
|
||||
/// Constraint points of an object as the solver sees them (`Sub`, position): end points,
|
||||
/// centres, axis ends, defining points, rigid points. Points at the same place are listed once.
|
||||
pub fn ref_points(d: &Drawing, h: Handle) -> Vec<(Sub, Vec2)> {
|
||||
let m = Model::build(d, &[h]);
|
||||
let Some(s) = m.slots.first() else { return Vec::new() };
|
||||
let n = match s.shape {
|
||||
Shape::Ellipse { .. } => 4,
|
||||
Shape::Spline { n, .. } | Shape::Insert { n } | Shape::Poly { n, .. } => n,
|
||||
_ => 0,
|
||||
};
|
||||
let mut out: Vec<(Sub, Vec2)> = Vec::new();
|
||||
for sub in [Sub::Whole, Sub::Center, Sub::Start, Sub::End, Sub::Mid].into_iter().chain((0..n).map(|i| Sub::Vertex(i as u32))) {
|
||||
if let Some(p) = m.point(&m.x0, &GeomRef::new(h, sub))
|
||||
&& !out.iter().any(|(_, q)| *q == p)
|
||||
{
|
||||
out.push((sub, p));
|
||||
}
|
||||
}
|
||||
out
|
||||
matches!(kind, EntityKind::Line(_) | EntityKind::Circle(_) | EntityKind::Arc(_) | EntityKind::LwPolyline(_) | EntityKind::Point(_))
|
||||
}
|
||||
|
||||
fn p2(x: &[f64], i: usize) -> Option<Vec2> {
|
||||
Some(Vec2::new(*x.get(i)?, *x.get(i + 1)?))
|
||||
}
|
||||
|
||||
/// Does a spline interpolate its fit points (so they can be its parameters)?
|
||||
fn fit_mode(s: &GSpline) -> bool {
|
||||
s.fit.len() >= 2 && s.fit_opts.tolerance == 0.0
|
||||
}
|
||||
|
||||
impl Model {
|
||||
/// Build a model for the given entities. Unsupported or missing entities are skipped.
|
||||
pub fn build(d: &Drawing, handles: &[Handle]) -> Model {
|
||||
Self::build_with(d, handles, &mut Prep::new(MAX_RIGID_WORK))
|
||||
}
|
||||
|
||||
/// Add the parameters of one supported entity to `m`, and return its shape; `None` when it is
|
||||
/// left out (too large, degenerate or unsupported).
|
||||
fn add_params(m: &mut Model, d: &Drawing, h: Handle, kind: &EntityKind, off: usize, prep: &mut Prep) -> Option<Shape> {
|
||||
let shape = match kind {
|
||||
EntityKind::Line(l) => {
|
||||
m.x0.extend([l.a.x, l.a.y, l.b.x, l.b.y]);
|
||||
Shape::Line
|
||||
}
|
||||
EntityKind::Circle(c) => {
|
||||
m.x0.extend([c.center.x, c.center.y, c.radius]);
|
||||
Shape::Circle
|
||||
}
|
||||
EntityKind::Arc(a) => {
|
||||
m.x0.extend([a.center.x, a.center.y, a.radius, a.start, a.end]);
|
||||
Shape::Arc
|
||||
}
|
||||
EntityKind::LwPolyline(p) => {
|
||||
if p.vertices.len() < 2 || off + p.vertices.len() * 2 > MAX_PARAMS {
|
||||
return None;
|
||||
}
|
||||
for v in &p.vertices {
|
||||
m.x0.extend([v.p.x, v.p.y]);
|
||||
}
|
||||
Shape::Poly { n: p.vertices.len(), closed: p.closed }
|
||||
}
|
||||
EntityKind::Point(p) => {
|
||||
m.x0.extend([p.p.x, p.p.y]);
|
||||
Shape::Point
|
||||
}
|
||||
EntityKind::Ellipse(el) => {
|
||||
let ell = cadcraft_geom::Ellipse { center: el.center.xy(), major: el.major.xy(), ratio: el.ratio, start: el.start, end: el.end };
|
||||
let arc = !ell.is_full();
|
||||
m.x0.extend([el.center.x, el.center.y, el.major.x, el.major.y, el.ratio]);
|
||||
if arc {
|
||||
m.x0.extend([el.start, el.end]);
|
||||
}
|
||||
Shape::Ellipse { arc }
|
||||
}
|
||||
EntityKind::Spline(sp) => {
|
||||
let fit = fit_mode(sp);
|
||||
let pts = if fit { &sp.fit } else { &sp.control };
|
||||
let n = pts.len();
|
||||
if !(2..=MAX_SPLINE_POINTS).contains(&n) || off + n * 2 > MAX_PARAMS || (!fit && !sp.is_valid()) {
|
||||
return None;
|
||||
}
|
||||
for p in pts {
|
||||
m.x0.extend([p.x, p.y]);
|
||||
}
|
||||
// Fit points interpolate exactly, so the curve's ends are the first and last
|
||||
// fit point; a control polygon's ends are its first and last points when the
|
||||
// knots are clamped.
|
||||
let ends = !sp.closed && (fit || (clamped(sp, false) && clamped(sp, true)));
|
||||
// Keep only what rebuilding reads, so each rebuild copies no unused data: missing
|
||||
// weights default to 1 in eval and only excess weights are unused; a control
|
||||
// spline's fit points are dropped when its control points move (write_back), and
|
||||
// a fit spline is rebuilt from its parameters, not from its old control net.
|
||||
let mut kept = if fit {
|
||||
GSpline { fit_opts: sp.fit_opts, closed: sp.closed, ..GSpline::default() }
|
||||
} else {
|
||||
GSpline { fit: Vec::new(), ..sp.clone() }
|
||||
};
|
||||
kept.weights.truncate(kept.control.len());
|
||||
m.splines.insert(h, kept);
|
||||
Shape::Spline { n, fit, ends }
|
||||
}
|
||||
EntityKind::Insert(ins) => {
|
||||
let base = Vec2::new(ins.insert.x, ins.insert.y);
|
||||
let pts: Vec<Vec2> = prep.rigid_points(d, ins).into_iter().map(|p| (p - base).rotate(-ins.rotation)).collect();
|
||||
m.x0.extend([ins.insert.x, ins.insert.y, ins.rotation]);
|
||||
let n = pts.len();
|
||||
m.rigid.insert(h, pts);
|
||||
Shape::Insert { n }
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(shape)
|
||||
}
|
||||
|
||||
/// Build solver parameters for supported entities, sharing block sampling, its member budget
|
||||
/// and spline rebuilds with every other model built from `prep`.
|
||||
pub(crate) fn build_with(d: &Drawing, handles: &[Handle], prep: &mut Prep) -> Model {
|
||||
let mut m = Model { rebuilt: Rc::clone(&prep.rebuilt), ..Model::default() };
|
||||
let mut m = Model::default();
|
||||
for &h in handles {
|
||||
if m.index.contains_key(&h) {
|
||||
continue;
|
||||
}
|
||||
let Some(e) = d.entity(h) else { continue };
|
||||
let off = m.x0.len();
|
||||
let Some(shape) = Self::add_params(&mut m, d, h, &e.kind, off, prep) else { continue };
|
||||
let shape = match &e.kind {
|
||||
EntityKind::Line(l) => {
|
||||
m.x0.extend([l.a.x, l.a.y, l.b.x, l.b.y]);
|
||||
Shape::Line
|
||||
}
|
||||
EntityKind::Circle(c) => {
|
||||
m.x0.extend([c.center.x, c.center.y, c.radius]);
|
||||
Shape::Circle
|
||||
}
|
||||
EntityKind::Arc(a) => {
|
||||
m.x0.extend([a.center.x, a.center.y, a.radius, a.start, a.end]);
|
||||
Shape::Arc
|
||||
}
|
||||
EntityKind::LwPolyline(p) => {
|
||||
if p.vertices.len() < 2 || off + p.vertices.len() * 2 > MAX_PARAMS {
|
||||
continue;
|
||||
}
|
||||
for v in &p.vertices {
|
||||
m.x0.extend([v.p.x, v.p.y]);
|
||||
}
|
||||
Shape::Poly { n: p.vertices.len(), closed: p.closed }
|
||||
}
|
||||
EntityKind::Point(p) => {
|
||||
m.x0.extend([p.p.x, p.p.y]);
|
||||
Shape::Point
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
if m.x0.len() > MAX_PARAMS || m.x0.get(off..).unwrap_or(&[]).iter().any(|v| !v.is_finite()) {
|
||||
m.x0.truncate(off);
|
||||
continue;
|
||||
@@ -377,131 +128,6 @@ impl Model {
|
||||
(Shape::Poly { n, .. }, Sub::End) => p2(x, o + 2 * n.checked_sub(1)?),
|
||||
(Shape::Poly { .. }, Sub::Mid) => None,
|
||||
(Shape::Point, Sub::Whole | Sub::Start | Sub::Center) => p2(x, o),
|
||||
(Shape::Ellipse { .. }, Sub::Center) => p2(x, o),
|
||||
(Shape::Ellipse { .. }, Sub::Vertex(i)) => {
|
||||
// Axis end points: +major, +minor, −major, −minor.
|
||||
let e = self.ell_at(x, o, false)?;
|
||||
Some(match i {
|
||||
0 => e.c + e.mj,
|
||||
1 => e.c + e.minor(),
|
||||
2 => e.c - e.mj,
|
||||
3 => e.c - e.minor(),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
(Shape::Ellipse { arc: true }, Sub::Start | Sub::End | Sub::Mid) => {
|
||||
let e = self.ell_at(x, o, true)?;
|
||||
Some(e.at(match r.sub {
|
||||
Sub::Start => e.t0,
|
||||
Sub::End => e.t1,
|
||||
_ => e.t0 + ccw_sweep(e.t0, e.t1) / 2.0,
|
||||
}))
|
||||
}
|
||||
(Shape::Spline { n, .. }, Sub::Vertex(i)) => {
|
||||
let i = i as usize;
|
||||
if i < n { p2(x, o + 2 * i) } else { None }
|
||||
}
|
||||
(Shape::Spline { ends: true, .. }, Sub::Start) => p2(x, o),
|
||||
(Shape::Spline { n, ends: true, .. }, Sub::End) => p2(x, o + 2 * n.checked_sub(1)?),
|
||||
(Shape::Insert { .. }, Sub::Whole | Sub::Start | Sub::Center) => p2(x, o),
|
||||
(Shape::Insert { n }, Sub::Vertex(i)) => {
|
||||
let i = i as usize;
|
||||
if i >= n {
|
||||
return None;
|
||||
}
|
||||
let q = *self.rigid.get(&s.handle)?.get(i)?;
|
||||
Some(p2(x, o)? + q.rotate(*x.get(o + 2)?))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn ell_at(&self, x: &[f64], o: usize, arc: bool) -> Option<Ell> {
|
||||
let (t0, t1) = if arc { (*x.get(o + 5)?, *x.get(o + 6)?) } else { (0.0, std::f64::consts::TAU) };
|
||||
Some(Ell { c: p2(x, o)?, mj: p2(x, o + 2)?, ratio: *x.get(o + 4)?, t0, t1, arc })
|
||||
}
|
||||
|
||||
/// An ellipse-like reference (ellipse or elliptical arc).
|
||||
pub fn ellipse(&self, x: &[f64], r: &GeomRef) -> Option<Ell> {
|
||||
let s = self.slot(r.handle)?;
|
||||
match (s.shape, r.sub) {
|
||||
(Shape::Ellipse { arc }, Sub::Whole) => self.ell_at(x, s.off, arc),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Is this reference (any part of) a spline?
|
||||
pub fn is_spline(&self, r: &GeomRef) -> bool {
|
||||
self.slot(r.handle).is_some_and(|s| matches!(s.shape, Shape::Spline { .. }))
|
||||
}
|
||||
|
||||
/// The spline of a reference with the defining points taken from `x`. The last rebuild of
|
||||
/// each spline is kept, so asking again at the same points costs a comparison, not a rebuild.
|
||||
fn spline(&self, x: &[f64], s: &Slot) -> Option<Rc<GSpline>> {
|
||||
let Shape::Spline { n, fit, .. } = s.shape else { return None };
|
||||
let t = self.splines.get(&s.handle)?;
|
||||
let pts = (0..n).map(|i| p2(x, s.off + 2 * i)).collect::<Option<Vec<_>>>()?;
|
||||
if let Some((at, sp)) = self.rebuilt.borrow().get(&s.handle)
|
||||
&& *at == pts
|
||||
{
|
||||
return Some(Rc::clone(sp));
|
||||
}
|
||||
let sp = Rc::new(if fit { t.refit(&pts, t.closed) } else { GSpline { control: pts.clone(), ..t.clone() } });
|
||||
self.rebuilt.borrow_mut().insert(s.handle, (pts, Rc::clone(&sp)));
|
||||
Some(sp)
|
||||
}
|
||||
|
||||
/// The start (`at_end` false) or end of a curve reference, in the curve's own direction:
|
||||
/// lines and polyline segments, arcs, elliptical arcs and open splines. A polyline's
|
||||
/// `start`/`end` are its first and last segments' outer ends.
|
||||
pub fn end_frame(&self, x: &[f64], r: &GeomRef, at_end: bool) -> Option<Frame> {
|
||||
let s = self.slot(r.handle)?;
|
||||
let o = s.off;
|
||||
let seg = match (s.shape, r.sub) {
|
||||
(Shape::Poly { .. }, Sub::Start) => Some(GeomRef::new(r.handle, Sub::Segment(0))),
|
||||
(Shape::Poly { n, closed: false }, Sub::End) => Some(GeomRef::new(r.handle, Sub::Segment(n.checked_sub(2)? as u32))),
|
||||
(Shape::Line, Sub::Start | Sub::End) => Some(GeomRef::whole(r.handle)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some((a, b)) = self.line(x, &seg.unwrap_or(*r)) {
|
||||
let d = b - a;
|
||||
let l = d.len();
|
||||
if crate::curves::not_above(l, 1e-300) {
|
||||
return None;
|
||||
}
|
||||
return Some(Frame { p: if at_end { b } else { a }, t: d * (1.0 / l), k: 0.0 });
|
||||
}
|
||||
match (s.shape, r.sub) {
|
||||
(Shape::Arc, Sub::Whole | Sub::Start | Sub::End) => {
|
||||
let (c, rad) = (p2(x, o)?, *x.get(o + 2)?);
|
||||
let a = *x.get(if at_end { o + 4 } else { o + 3 })?;
|
||||
if crate::curves::not_above(rad.abs(), 1e-300) {
|
||||
return None;
|
||||
}
|
||||
let (p, d1) = crate::curves::circle_at(c, rad, a);
|
||||
Some(Frame { p, t: d1.normalized(), k: 1.0 / rad })
|
||||
}
|
||||
(Shape::Ellipse { arc: true }, Sub::Whole | Sub::Start | Sub::End) => {
|
||||
let e = self.ell_at(x, o, true)?;
|
||||
e.frame(if at_end { e.t1 } else { e.t0 })
|
||||
}
|
||||
(Shape::Spline { ends: true, .. }, Sub::Whole | Sub::Start | Sub::End) => spline_end(&*self.spline(x, s)?, at_end),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Squared distance of a block reference's farthest rigid point from its insertion point.
|
||||
pub fn rigid_extent2(&self, h: Handle) -> f64 {
|
||||
self.rigid.get(&h).map_or(0.0, |v| v.iter().map(|q| q.len2()).fold(0.0, f64::max))
|
||||
}
|
||||
|
||||
/// Where to draw a glyph for a reference that is not a point, line or circle.
|
||||
pub fn anchor(&self, x: &[f64], r: &GeomRef) -> Option<Vec2> {
|
||||
let s = self.slot(r.handle)?;
|
||||
match s.shape {
|
||||
Shape::Ellipse { .. } => self.ell_at(x, s.off, false).map(|e| e.c + e.minor()),
|
||||
Shape::Spline { n, .. } => p2(x, s.off + 2 * (n / 2)),
|
||||
Shape::Insert { .. } => p2(x, s.off),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -550,18 +176,6 @@ impl Model {
|
||||
}
|
||||
}
|
||||
|
||||
/// The points of an object a Fix locks directly (ends, centre, vertices, insertion point).
|
||||
pub fn point_refs(&self, h: Handle) -> Vec<GeomRef> {
|
||||
let Some(s) = self.slot(h) else { return Vec::new() };
|
||||
let at = |sub: Sub| GeomRef { handle: h, sub };
|
||||
match s.shape {
|
||||
Shape::Line => vec![at(Sub::Start), at(Sub::End)],
|
||||
Shape::Point => vec![at(Sub::Whole)],
|
||||
Shape::Circle | Shape::Arc | Shape::Ellipse { .. } | Shape::Insert { .. } => vec![at(Sub::Center)],
|
||||
Shape::Poly { n, .. } | Shape::Spline { n, .. } => (0..n).filter_map(|i| u32::try_from(i).ok()).map(|i| at(Sub::Vertex(i))).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parameters a Fix on this reference locks directly. `None` when the reference is a derived
|
||||
/// point (fixed through a residual instead).
|
||||
pub fn lock_indices(&self, r: &GeomRef) -> Option<Vec<usize>> {
|
||||
@@ -579,67 +193,11 @@ impl Model {
|
||||
let (a, b) = Self::seg(n, closed, i as usize)?;
|
||||
vec![o + 2 * a, o + 2 * a + 1, o + 2 * b, o + 2 * b + 1]
|
||||
}
|
||||
(Shape::Ellipse { .. } | Shape::Insert { .. }, Sub::Center) => vec![o, o + 1],
|
||||
(Shape::Insert { .. }, Sub::Start) => vec![o, o + 1],
|
||||
(Shape::Spline { n, .. }, Sub::Vertex(i)) if (i as usize) < n => vec![o + 2 * i as usize, o + 2 * i as usize + 1],
|
||||
(Shape::Spline { ends: true, .. }, Sub::Start) => vec![o, o + 1],
|
||||
(Shape::Spline { n, ends: true, .. }, Sub::End) if n > 0 => vec![o + 2 * (n - 1), o + 2 * (n - 1) + 1],
|
||||
_ => return None,
|
||||
};
|
||||
Some(v)
|
||||
}
|
||||
|
||||
/// Can `write_back` store `x` as it is? An ellipse keeps 0 < ratio <= 1, its major axis the
|
||||
/// longer one: a solution past that is stored with its axes swapped or its minor axis
|
||||
/// mirrored (see [`Model::stored_form`]), which renumbers the axis ends.
|
||||
pub fn storable(&self, x: &[f64]) -> bool {
|
||||
self.slots.iter().all(|s| match s.shape {
|
||||
Shape::Ellipse { .. } => x.get(s.off + 4).is_some_and(|r| *r > 0.0 && *r <= 1.0 + RATIO_TOL),
|
||||
_ => true,
|
||||
})
|
||||
}
|
||||
|
||||
/// `x` with every ellipse re-expressed as [`Model::write_back`] stores it (the same curve with
|
||||
/// the major axis the longer one, a full ellipse's negative ratio made positive), so the
|
||||
/// constraints can be checked against what would be stored. `None` when an elliptical arc has
|
||||
/// a negative ratio or an ellipse a zero one.
|
||||
pub fn stored_form(&self, x: &[f64]) -> Option<Vec<f64>> {
|
||||
let mut xc = x.to_vec();
|
||||
for s in &self.slots {
|
||||
let Shape::Ellipse { arc } = s.shape else { continue };
|
||||
let o = s.off;
|
||||
let mut ratio = *x.get(o + 4)?;
|
||||
if ratio < 0.0 && !arc {
|
||||
ratio = -ratio;
|
||||
}
|
||||
if ratio <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
if ratio > 1.0 + RATIO_TOL {
|
||||
let major = p2(x, o + 2)?.perp() * ratio;
|
||||
*xc.get_mut(o + 2)? = major.x;
|
||||
*xc.get_mut(o + 3)? = major.y;
|
||||
ratio = 1.0 / ratio;
|
||||
if arc {
|
||||
*xc.get_mut(o + 5)? -= std::f64::consts::FRAC_PI_2;
|
||||
*xc.get_mut(o + 6)? -= std::f64::consts::FRAC_PI_2;
|
||||
}
|
||||
}
|
||||
*xc.get_mut(o + 4)? = ratio;
|
||||
}
|
||||
Some(xc)
|
||||
}
|
||||
|
||||
/// The parameter `stored_form` gives the point at parameter `t` on ellipse `h` (unchanged for
|
||||
/// any other object): `-t` where a negative ratio was made positive, a quarter turn back where
|
||||
/// the axes were swapped.
|
||||
pub fn stored_t(&self, x: &[f64], h: Handle, t: f64) -> f64 {
|
||||
let Some(s) = self.slot(h) else { return t };
|
||||
let (Shape::Ellipse { .. }, Some(&ratio)) = (s.shape, x.get(s.off + 4)) else { return t };
|
||||
let (t, ratio) = if ratio < 0.0 { (-t, -ratio) } else { (t, ratio) };
|
||||
if ratio > 1.0 + RATIO_TOL { t - std::f64::consts::FRAC_PI_2 } else { t }
|
||||
}
|
||||
|
||||
/// Write solved parameters back. Returns the handles that moved.
|
||||
pub fn write_back(&self, d: &mut Drawing, x: &[f64]) -> Vec<Handle> {
|
||||
let mut moved = Vec::new();
|
||||
@@ -653,14 +211,6 @@ impl Model {
|
||||
continue;
|
||||
}
|
||||
let shape = s.shape;
|
||||
// A control-point spline loses its fit data once its control points move.
|
||||
let spline = self.spline(x, s).map(|sp| {
|
||||
let mut sp = GSpline::clone(&sp);
|
||||
if matches!(shape, Shape::Spline { fit: false, .. }) {
|
||||
sp.fit.clear();
|
||||
}
|
||||
sp
|
||||
});
|
||||
let ok = d.modify_entity(s.handle, |e| match (&mut e.kind, shape) {
|
||||
(EntityKind::Line(l), Shape::Line) => {
|
||||
if let [ax, ay, bx, by] = new {
|
||||
@@ -679,14 +229,11 @@ impl Model {
|
||||
}
|
||||
(EntityKind::Arc(a), Shape::Arc) => {
|
||||
if let [cx, cy, r, s0, s1] = new {
|
||||
// A negative radius puts every point opposite its angle: turning both
|
||||
// ends half a turn keeps the same points with a positive radius.
|
||||
let turn = if *r < 0.0 { std::f64::consts::PI } else { 0.0 };
|
||||
a.center.x = *cx;
|
||||
a.center.y = *cy;
|
||||
a.radius = r.abs().max(1e-12);
|
||||
a.start = cadcraft_geom::norm_angle(*s0 + turn);
|
||||
a.end = cadcraft_geom::norm_angle(*s1 + turn);
|
||||
a.start = cadcraft_geom::norm_angle(*s0);
|
||||
a.end = cadcraft_geom::norm_angle(*s1);
|
||||
}
|
||||
}
|
||||
(EntityKind::LwPolyline(p), Shape::Poly { n, .. }) if p.vertices.len() == n => {
|
||||
@@ -702,48 +249,6 @@ impl Model {
|
||||
p.p.y = *py;
|
||||
}
|
||||
}
|
||||
(EntityKind::Ellipse(el), Shape::Ellipse { arc }) => {
|
||||
if let [cx, cy, mx, my, ratio, rest @ ..] = new {
|
||||
let (mut major, mut ratio) = (Vec2::new(*mx, *my), ratio.abs());
|
||||
let (mut t0, mut t1) = match rest {
|
||||
[a, b] if arc => (*a, *b),
|
||||
_ => (el.start, el.end),
|
||||
};
|
||||
// Keep the stored form: the major axis is the longer one. `storable` lets a
|
||||
// solved ratio exceed 1 only by rounding: that is a circle, not a swap.
|
||||
if ratio > 1.0 + RATIO_TOL {
|
||||
major = major.perp() * ratio;
|
||||
ratio = 1.0 / ratio;
|
||||
if arc {
|
||||
t0 -= std::f64::consts::FRAC_PI_2;
|
||||
t1 -= std::f64::consts::FRAC_PI_2;
|
||||
}
|
||||
}
|
||||
if major.len() > 1e-12 {
|
||||
el.center.x = *cx;
|
||||
el.center.y = *cy;
|
||||
el.major.x = major.x;
|
||||
el.major.y = major.y;
|
||||
el.ratio = ratio.clamp(1e-9, 1.0);
|
||||
if arc {
|
||||
el.start = cadcraft_geom::norm_angle(t0);
|
||||
el.end = cadcraft_geom::norm_angle(t1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(EntityKind::Spline(sp), Shape::Spline { .. }) => {
|
||||
if let Some(rebuilt) = spline.as_ref() {
|
||||
*sp = rebuilt.clone();
|
||||
}
|
||||
}
|
||||
(EntityKind::Insert(ins), Shape::Insert { .. }) => {
|
||||
if let [ix, iy, rot] = new {
|
||||
ins.insert.x = *ix;
|
||||
ins.insert.y = *iy;
|
||||
ins.rotation = cadcraft_geom::norm_angle(*rot);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
if ok.is_ok() {
|
||||
@@ -753,126 +258,3 @@ impl Model {
|
||||
moved
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod budget_tests {
|
||||
use super::*;
|
||||
use cadcraft_doc::{Block, Common, Entity, Line, Space};
|
||||
use cadcraft_geom::Vec3;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Writing an unchanged model back leaves partial rational weights and the original spline geometry intact.
|
||||
#[test]
|
||||
fn write_back_keeps_partial_rational_weights() {
|
||||
let mut drawing = Drawing::default();
|
||||
let mut spline = GSpline::from_control(vec![Vec2::ZERO, Vec2::new(1.0, 2.0), Vec2::new(3.0, 0.0)], 2);
|
||||
spline.weights = vec![2.0, 0.5];
|
||||
let h = drawing.add(&Space::Model, Common::default(), EntityKind::Spline(spline.clone())).unwrap();
|
||||
let model = Model::build(&drawing, &[h]);
|
||||
model.write_back(&mut drawing, &model.x0);
|
||||
let EntityKind::Spline(after) = &drawing.entity(h).unwrap().kind else { panic!("spline") };
|
||||
assert_eq!(after.weights, spline.weights);
|
||||
for i in 0..=10 {
|
||||
assert!(after.eval(i as f64 / 10.0).near(spline.eval(i as f64 / 10.0), 1e-12));
|
||||
}
|
||||
}
|
||||
|
||||
fn add_block(d: &mut Drawing, name: &str, lines: usize) {
|
||||
let mut block = Block::new(name);
|
||||
for _ in 0..lines {
|
||||
block.entities.push(Entity {
|
||||
handle: d.new_handle(),
|
||||
common: Common::default(),
|
||||
kind: EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(1.0, 0.0, 0.0) }),
|
||||
});
|
||||
}
|
||||
d.blocks.insert(name.into(), Arc::new(block));
|
||||
}
|
||||
|
||||
fn insert(block: &str) -> Insert {
|
||||
Insert {
|
||||
block: block.into(),
|
||||
insert: Vec3::ZERO,
|
||||
scale: Vec3::new(1.0, 1.0, 1.0),
|
||||
rotation: 0.0,
|
||||
attribs: Vec::new(),
|
||||
cols: 1,
|
||||
rows: 1,
|
||||
col_spacing: 0.0,
|
||||
row_spacing: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn add_insert(d: &mut Drawing, block: &str) -> Handle {
|
||||
d.add(&Space::Model, Common::default(), EntityKind::Insert(insert(block))).unwrap()
|
||||
}
|
||||
|
||||
/// The member budget is shared by every model of one preparation, and a block it already
|
||||
/// sampled is reused by later Inserts without examining its members again.
|
||||
#[test]
|
||||
fn rigid_sampling_budget_is_shared_and_blocks_sampled_once() {
|
||||
let mut d = Drawing::default();
|
||||
add_block(&mut d, "BAR", 3);
|
||||
add_block(&mut d, "BAZ", 3);
|
||||
let (bar, baz, bar2) = (add_insert(&mut d, "BAR"), add_insert(&mut d, "BAZ"), add_insert(&mut d, "BAR"));
|
||||
let mut prep = Prep::new(2);
|
||||
let first = Model::build_with(&d, &[bar], &mut prep);
|
||||
assert_eq!(first.rigid[&bar].len(), 4);
|
||||
assert_eq!(prep.members_left, 0);
|
||||
let other = Model::build_with(&d, &[baz], &mut prep);
|
||||
assert!(other.rigid[&baz].is_empty());
|
||||
let again = Model::build_with(&d, &[bar2], &mut prep);
|
||||
assert_eq!(again.rigid[&bar2].len(), 4);
|
||||
}
|
||||
|
||||
/// Block names resolve ignoring ASCII case, as `Drawing::block` does, through one index.
|
||||
#[test]
|
||||
fn block_names_resolve_ignoring_case() {
|
||||
let mut d = Drawing::default();
|
||||
add_block(&mut d, "Bar", 2);
|
||||
let (lower, missing) = (add_insert(&mut d, "bAR"), add_insert(&mut d, "nope"));
|
||||
let mut prep = Prep::new(MAX_RIGID_WORK);
|
||||
let m = Model::build_with(&d, &[lower, missing], &mut prep);
|
||||
assert_eq!(m.rigid[&lower].len(), 4);
|
||||
assert!(m.rigid[&missing].is_empty());
|
||||
assert!(prep.folded.as_ref().is_some_and(|f| f.len() == 1));
|
||||
assert_eq!(rigid_points(&d, &insert("BAR")).len(), 4);
|
||||
}
|
||||
|
||||
/// A spline asked for again at the same defining points is not rebuilt, also from another
|
||||
/// model of the same preparation; moved points rebuild it.
|
||||
#[test]
|
||||
fn spline_rebuilds_are_reused_at_the_same_points() {
|
||||
let mut d = Drawing::default();
|
||||
let pts: Vec<Vec2> = (0..6).map(|i| Vec2::new(i as f64, (i % 2) as f64)).collect();
|
||||
let h = d.add(&Space::Model, Common::default(), EntityKind::Spline(GSpline::fit_with(&pts, false, Default::default()))).unwrap();
|
||||
let mut prep = Prep::new(MAX_RIGID_WORK);
|
||||
let a = Model::build_with(&d, &[h], &mut prep);
|
||||
let b = Model::build_with(&d, &[h], &mut prep);
|
||||
let (sa, sb) = (a.slot(h).unwrap(), b.slot(h).unwrap());
|
||||
assert!(matches!(sa.shape, Shape::Spline { fit: true, .. }));
|
||||
let first = a.spline(&a.x0, sa).unwrap();
|
||||
assert!(Rc::ptr_eq(&first, &a.spline(&a.x0, sa).unwrap()));
|
||||
assert!(Rc::ptr_eq(&first, &b.spline(&b.x0, sb).unwrap()));
|
||||
let mut moved = a.x0.clone();
|
||||
moved[1] += 0.5;
|
||||
let other = a.spline(&moved, sa).unwrap();
|
||||
assert!(!Rc::ptr_eq(&first, &other));
|
||||
assert_eq!(other.fit[0], Vec2::new(0.0, 0.5));
|
||||
}
|
||||
|
||||
/// A control spline's template keeps no fit points and no excess weights.
|
||||
#[test]
|
||||
fn control_template_drops_unused_data() {
|
||||
let mut d = Drawing::default();
|
||||
let mut sp = GSpline::from_control(vec![Vec2::ZERO, Vec2::new(1.0, 2.0), Vec2::new(3.0, 0.0), Vec2::new(4.0, 1.0)], 3);
|
||||
sp.fit = vec![Vec2::ZERO; 10_000];
|
||||
sp.fit_opts.tolerance = 0.5;
|
||||
sp.weights = vec![1.0; 10_000];
|
||||
let h = d.add(&Space::Model, Common::default(), EntityKind::Spline(sp)).unwrap();
|
||||
let m = Model::build(&d, &[h]);
|
||||
let t = &m.splines[&h];
|
||||
assert!(t.fit.is_empty());
|
||||
assert_eq!(t.weights.len(), 4);
|
||||
}
|
||||
}
|
||||
|
||||
+139
-498
@@ -3,12 +3,8 @@
|
||||
use cadcraft_doc::{Constraint, ConstraintKind, DistAxis, GeomRef};
|
||||
use cadcraft_geom::Vec2;
|
||||
|
||||
use crate::curves::{Ell, circle_at};
|
||||
use crate::model::Model;
|
||||
|
||||
/// Contact parameter samples per curve when guessing where two curves touch.
|
||||
const CONTACT_SAMPLES: usize = 72;
|
||||
|
||||
/// How a constraint's references were interpreted (decided once from the starting geometry).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) enum Form {
|
||||
@@ -40,38 +36,10 @@ pub(crate) enum Form {
|
||||
C1,
|
||||
/// Arc sweep (angular on an arc).
|
||||
ArcSweep,
|
||||
/// Two lines measured along the rays from their intersection to each line's far end, the
|
||||
/// angle the dimension draws; `rev` marks a line whose far end is its start point.
|
||||
Rays {
|
||||
rev: [bool; 2],
|
||||
},
|
||||
/// Fix of a derived point (residual to an anchor).
|
||||
Anchor(Vec2),
|
||||
/// Fix handled by locking parameters (no rows).
|
||||
Lock,
|
||||
/// Point on an ellipse (refs: point, ellipse).
|
||||
PE {
|
||||
swap: bool,
|
||||
},
|
||||
/// Line tangent to an ellipse.
|
||||
LE {
|
||||
swap: bool,
|
||||
},
|
||||
/// Two curves (circles, arcs, ellipses; at least one ellipse) touching at a contact point
|
||||
/// given by one auxiliary parameter per curve.
|
||||
Contact,
|
||||
/// A spline end on a line, circle, arc or ellipse and tangent to it (`spline`: which
|
||||
/// reference is the spline).
|
||||
EndOn {
|
||||
spline: usize,
|
||||
at_end: bool,
|
||||
},
|
||||
/// Two curve ends joined with a common tangent (G1), and common curvature when `g2`.
|
||||
Join {
|
||||
a_end: bool,
|
||||
b_end: bool,
|
||||
g2: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -85,10 +53,6 @@ pub(crate) struct Built {
|
||||
/// Orientation chosen from the starting geometry (±1).
|
||||
pub sign: f64,
|
||||
pub rows: usize,
|
||||
/// Starting values of auxiliary unknowns (contact parameters), appended to the parameter
|
||||
/// vector at `aux` by [`crate::interpret`].
|
||||
pub aux_init: Vec<f64>,
|
||||
pub aux: usize,
|
||||
}
|
||||
|
||||
fn dir_n(a: Vec2, b: Vec2) -> Vec2 {
|
||||
@@ -97,10 +61,6 @@ fn dir_n(a: Vec2, b: Vec2) -> Vec2 {
|
||||
if l > 1e-12 { d * (1.0 / l) } else { d }
|
||||
}
|
||||
|
||||
fn ray(a: Vec2, b: Vec2, rev: bool) -> Vec2 {
|
||||
if rev { dir_n(b, a) } else { dir_n(a, b) }
|
||||
}
|
||||
|
||||
fn sgn(v: f64) -> f64 {
|
||||
if v < 0.0 { -1.0 } else { 1.0 }
|
||||
}
|
||||
@@ -116,16 +76,6 @@ fn r_at(c: &Constraint, i: usize) -> Option<&GeomRef> {
|
||||
}
|
||||
|
||||
impl Built {
|
||||
/// Estimate spline reconstruction work for endpoint and join residuals; other residual forms add no such cost.
|
||||
pub(crate) fn spline_reconstruct_cost(&self, m: &Model) -> f64 {
|
||||
let cost = |i: usize| self.refs.get(i).map_or(0.0, |r| m.spline_reconstruct_cost(r.handle));
|
||||
match self.form {
|
||||
Form::EndOn { spline, .. } => cost(spline),
|
||||
Form::Join { .. } => cost(0) + cost(1),
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Interpret a constraint against the model at `x`. `target` is the evaluated value for
|
||||
/// dimensional constraints (degrees for angular). Errors explain what references are needed.
|
||||
pub fn new(c: &Constraint, m: &Model, x: &[f64], target: f64) -> Result<Built, String> {
|
||||
@@ -135,9 +85,7 @@ impl Built {
|
||||
let ln = |i: usize| r_at(c, i).and_then(|r| m.line(x, r));
|
||||
let ci = |i: usize| r_at(c, i).and_then(|r| m.circle(x, r));
|
||||
let n = c.refs.len();
|
||||
let el = |i: usize| r_at(c, i).and_then(|r| m.ellipse(x, r));
|
||||
let lnx = |i: usize| ln(i).or_else(|| el(i).map(|e| (e.c - e.mj, e.c + e.mj)));
|
||||
let mut b = Built { id: c.id, kind: c.kind, refs, form: Form::PP, target, sign: 1.0, rows: 0, aux_init: Vec::new(), aux: 0 };
|
||||
let mut b = Built { id: c.id, kind: c.kind, refs, form: Form::PP, target, sign: 1.0, rows: 0 };
|
||||
let need = |what: &str| Err(format!("{} needs {what}", c.kind.name()));
|
||||
match c.kind {
|
||||
K::Coincident => {
|
||||
@@ -155,24 +103,20 @@ impl Built {
|
||||
Form::PC { swap: false }
|
||||
} else if pt(1).is_some() && ci(0).is_some() {
|
||||
Form::PC { swap: true }
|
||||
} else if pt(0).is_some() && el(1).is_some() {
|
||||
Form::PE { swap: false }
|
||||
} else if pt(1).is_some() && el(0).is_some() {
|
||||
Form::PE { swap: true }
|
||||
} else {
|
||||
return need("two points, or a point and a line/circle/arc/ellipse");
|
||||
return need("two points, or a point and a line/circle/arc");
|
||||
};
|
||||
if b.rows == 0 {
|
||||
b.rows = 1;
|
||||
}
|
||||
}
|
||||
K::Horizontal | K::Vertical => {
|
||||
if n == 1 && lnx(0).is_some() {
|
||||
if n == 1 && ln(0).is_some() {
|
||||
b.form = Form::L1;
|
||||
} else if n == 2 && pt(0).is_some() && pt(1).is_some() {
|
||||
b.form = Form::P2;
|
||||
} else {
|
||||
return need("a line, an ellipse or two points");
|
||||
return need("a line or two points");
|
||||
}
|
||||
b.rows = 1;
|
||||
}
|
||||
@@ -184,10 +128,9 @@ impl Built {
|
||||
b.rows = if c.kind == K::Collinear { 2 } else { 1 };
|
||||
}
|
||||
K::Concentric => {
|
||||
let center = |i: usize| ci(i).map(|(c, _)| c).or_else(|| el(i).map(|e| e.c)).or_else(|| pt(i));
|
||||
let curve = |i: usize| ci(i).is_some() || el(i).is_some();
|
||||
if !(n == 2 && center(0).is_some() && center(1).is_some() && (curve(0) || curve(1))) {
|
||||
return need("two circles, arcs or ellipses");
|
||||
let center = |i: usize| ci(i).map(|(c, _)| c).or_else(|| pt(i));
|
||||
if !(n == 2 && center(0).is_some() && center(1).is_some() && (ci(0).is_some() || ci(1).is_some())) {
|
||||
return need("two circles or arcs");
|
||||
}
|
||||
b.form = Form::CC;
|
||||
b.rows = 2;
|
||||
@@ -205,7 +148,29 @@ impl Built {
|
||||
};
|
||||
b.rows = 1;
|
||||
}
|
||||
K::Tangent | K::Smooth => return Self::tangent_form(b, c, m, x),
|
||||
K::Tangent | K::Smooth => {
|
||||
if n != 2 {
|
||||
return need("two objects");
|
||||
}
|
||||
b.rows = 1;
|
||||
if let (Some((a, bb)), Some((cc, _))) = (ln(0), ci(1)) {
|
||||
b.form = Form::LC { swap: false };
|
||||
b.sign = sgn(dir_n(a, bb).cross(cc - a));
|
||||
} else if let (Some((a, bb)), Some((cc, _))) = (ln(1), ci(0)) {
|
||||
b.form = Form::LC { swap: true };
|
||||
b.sign = sgn(dir_n(a, bb).cross(cc - a));
|
||||
} else if let (Some((c1, r1)), Some((c2, r2))) = (ci(0), ci(1)) {
|
||||
b.form = Form::CC;
|
||||
// sign > 0: external tangency; < 0: internal.
|
||||
let d = c1.dist(c2);
|
||||
b.sign = if d >= r1.abs().max(r2.abs()) { 1.0 } else { -1.0 };
|
||||
b.target = sgn(r1 - r2);
|
||||
} else if ln(0).is_some() && ln(1).is_some() && c.kind == K::Smooth {
|
||||
b.form = Form::LL;
|
||||
} else {
|
||||
return need("a line and a circle/arc, or two circles/arcs");
|
||||
}
|
||||
}
|
||||
K::Symmetric => {
|
||||
if n != 3 || ln(2).is_none() {
|
||||
return need("two objects and a symmetry line");
|
||||
@@ -268,15 +233,10 @@ impl Built {
|
||||
}
|
||||
K::Angular => {
|
||||
b.rows = 1;
|
||||
if let (2, Some((a1, b1)), Some((a2, b2))) = (n, ln(0), ln(1)) {
|
||||
// Start-to-end directions would measure the supplement when a line is drawn
|
||||
// towards the vertex, so use the rays the dimension draws.
|
||||
let rev = match cadcraft_geom::line_line_infinite(a1, b1, a2, b2) {
|
||||
Some((_, t, u)) => [t > 0.5, u > 0.5],
|
||||
None => [false, false],
|
||||
};
|
||||
b.form = Form::Rays { rev };
|
||||
let (d1, d2) = (ray(a1, b1, rev[0]), ray(a2, b2, rev[1]));
|
||||
if n == 2 && ln(0).is_some() && ln(1).is_some() {
|
||||
b.form = Form::LL;
|
||||
let (Some((a1, b1)), Some((a2, b2))) = (ln(0), ln(1)) else { return need("two lines") };
|
||||
let (d1, d2) = (dir_n(a1, b1), dir_n(a2, b2));
|
||||
b.sign = sgn(d1.cross(d2).atan2(d1.dot(d2)));
|
||||
} else if n == 1 && m.arc_sweep(x, r_at(c, 0).ok_or_else(|| "Angular needs references".to_string())?).is_some() {
|
||||
b.form = Form::ArcSweep;
|
||||
@@ -286,7 +246,7 @@ impl Built {
|
||||
b.target = target.to_radians();
|
||||
// Two lines measure a signed angle in [-180, 180] and the sign is re-read from the geometry, so a larger
|
||||
// target would flip the sign once solved and never be satisfied; only an arc sweep can reach a full turn.
|
||||
let max = if matches!(b.form, Form::Rays { .. }) { std::f64::consts::PI } else { std::f64::consts::TAU };
|
||||
let max = if b.form == Form::LL { std::f64::consts::PI } else { std::f64::consts::TAU };
|
||||
if !b.target.is_finite() || b.target.abs() > max {
|
||||
return Err("angle out of range".into());
|
||||
}
|
||||
@@ -297,7 +257,7 @@ impl Built {
|
||||
}
|
||||
b.form = Form::C1;
|
||||
b.rows = 1;
|
||||
if !target.is_finite() || crate::curves::not_above(target, 0.0) {
|
||||
if !(target.is_finite() && target > 0.0) {
|
||||
return Err(format!("{} value must be positive", c.kind.name()));
|
||||
}
|
||||
}
|
||||
@@ -312,145 +272,115 @@ impl Built {
|
||||
out.resize(start + self.rows, 0.0);
|
||||
}
|
||||
|
||||
fn eval_inner(&self, model: &Model, values: &[f64], out: &mut Vec<f64>) -> Option<()> {
|
||||
ResidualEval { built: self, model, values, out }.evaluate()
|
||||
}
|
||||
|
||||
/// Point and derivative of curve reference `i` (circle, arc or ellipse) at parameter `t`.
|
||||
fn curve_at(&self, m: &Model, x: &[f64], i: usize, t: f64) -> Option<(Vec2, Vec2)> {
|
||||
let r = self.refs.get(i)?;
|
||||
if let Some((c, rad)) = m.circle(x, r) {
|
||||
return Some(circle_at(c, rad, t));
|
||||
}
|
||||
let e = m.ellipse(x, r)?;
|
||||
Some((e.at(t), e.d1(t)))
|
||||
}
|
||||
|
||||
/// Contact parameters where two curves come closest (sampled), for [`Form::Contact`].
|
||||
fn contact_guess(&self, m: &Model, x: &[f64]) -> Option<Vec<f64>> {
|
||||
let step = std::f64::consts::TAU / CONTACT_SAMPLES as f64;
|
||||
let s0: Vec<(f64, Vec2)> = (0..CONTACT_SAMPLES).filter_map(|k| Some((k as f64 * step, self.curve_at(m, x, 0, k as f64 * step)?.0))).collect();
|
||||
let s1: Vec<(f64, Vec2)> = (0..CONTACT_SAMPLES).filter_map(|k| Some((k as f64 * step, self.curve_at(m, x, 1, k as f64 * step)?.0))).collect();
|
||||
let mut best: Option<(f64, f64, f64)> = None;
|
||||
for (t0, p0) in &s0 {
|
||||
for (t1, p1) in &s1 {
|
||||
let d = p0.dist(*p1);
|
||||
if d.is_finite() && best.is_none_or(|b| d < b.0) {
|
||||
best = Some((d, *t0, *t1));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, a, b)| vec![a, b])
|
||||
}
|
||||
|
||||
/// Tangent or Smooth with a spline: the spline end and the other curve's nearest end are
|
||||
/// joined (Smooth: G2; Tangent between splines: G1), or the spline's end touches a line,
|
||||
/// circle, arc or ellipse tangentially. A `start`/`end` reference picks the end.
|
||||
fn tangent_form(mut built: Built, constraint: &Constraint, model: &Model, values: &[f64]) -> Result<Built, String> {
|
||||
fn eval_inner(&self, m: &Model, x: &[f64], out: &mut Vec<f64>) -> Option<()> {
|
||||
use ConstraintKind as K;
|
||||
|
||||
let need = |what: &str| Err(format!("{} needs {what}", constraint.kind.name()));
|
||||
if built.refs.len() != 2 {
|
||||
return need("two objects");
|
||||
}
|
||||
if constraint.refs.iter().any(|reference| model.is_spline(reference)) {
|
||||
return Self::spline_form(built, model, values);
|
||||
}
|
||||
|
||||
let line = |index| r_at(constraint, index).and_then(|reference| model.line(values, reference));
|
||||
let circle = |index| r_at(constraint, index).and_then(|reference| model.circle(values, reference));
|
||||
let ellipse = |index| r_at(constraint, index).and_then(|reference| model.ellipse(values, reference));
|
||||
built.rows = 1;
|
||||
|
||||
if let (Some((start, end)), Some((center, _))) = (line(0), circle(1)) {
|
||||
built.form = Form::LC { swap: false };
|
||||
built.sign = sgn(dir_n(start, end).cross(center - start));
|
||||
} else if let (Some((start, end)), Some((center, _))) = (line(1), circle(0)) {
|
||||
built.form = Form::LC { swap: true };
|
||||
built.sign = sgn(dir_n(start, end).cross(center - start));
|
||||
} else if let (Some((first_center, first_radius)), Some((second_center, second_radius))) = (circle(0), circle(1)) {
|
||||
built.form = Form::CC;
|
||||
// sign > 0: external tangency; < 0: internal.
|
||||
let distance = first_center.dist(second_center);
|
||||
built.sign = if distance >= first_radius.abs().max(second_radius.abs()) { 1.0 } else { -1.0 };
|
||||
built.target = sgn(first_radius - second_radius);
|
||||
} else if let (Some((start, end)), Some(ellipse)) = (line(0), ellipse(1)) {
|
||||
built.form = Form::LE { swap: false };
|
||||
built.sign = sgn(dir_n(start, end).cross(ellipse.c - start));
|
||||
} else if let (Some((start, end)), Some(ellipse)) = (line(1), ellipse(0)) {
|
||||
built.form = Form::LE { swap: true };
|
||||
built.sign = sgn(dir_n(start, end).cross(ellipse.c - start));
|
||||
} else if (circle(0).is_some() || ellipse(0).is_some()) && (circle(1).is_some() || ellipse(1).is_some()) {
|
||||
built.form = Form::Contact;
|
||||
built.rows = 3;
|
||||
built.aux_init =
|
||||
built.contact_guess(model, values).ok_or_else(|| format!("{} could not find where the curves touch", constraint.kind.name()))?;
|
||||
} else if line(0).is_some() && line(1).is_some() && constraint.kind == K::Smooth {
|
||||
built.form = Form::LL;
|
||||
} else {
|
||||
return need("a line and a circle/arc/ellipse, two curves, or a spline end and a curve");
|
||||
}
|
||||
|
||||
Ok(built)
|
||||
}
|
||||
|
||||
fn spline_form(mut b: Built, m: &Model, x: &[f64]) -> Result<Built, String> {
|
||||
let (Some(r0), Some(r1)) = (b.refs.first().copied(), b.refs.get(1).copied()) else {
|
||||
return Err(format!("{} needs two objects", b.kind.name()));
|
||||
};
|
||||
let ends = |r: &GeomRef| -> &'static [bool] {
|
||||
match r.sub {
|
||||
cadcraft_doc::Sub::Start => &[false],
|
||||
cadcraft_doc::Sub::End => &[true],
|
||||
_ => &[false, true],
|
||||
let r = |i: usize| self.refs.get(i);
|
||||
let pt = |i: usize| m.point(x, r(i)?);
|
||||
let ln = |i: usize| m.line(x, r(i)?);
|
||||
let ci = |i: usize| m.circle(x, r(i)?);
|
||||
match (self.kind, self.form) {
|
||||
(_, Form::Lock) => {}
|
||||
(_, Form::Anchor(a)) => {
|
||||
let p = pt(0)?;
|
||||
out.extend([p.x - a.x, p.y - a.y]);
|
||||
}
|
||||
};
|
||||
let join = b.kind == ConstraintKind::Smooth || (m.is_spline(&r0) && m.is_spline(&r1));
|
||||
if join {
|
||||
let mut best: Option<(f64, bool, bool)> = None;
|
||||
for &ea in ends(&r0) {
|
||||
for &eb in ends(&r1) {
|
||||
if let (Some(fa), Some(fb)) = (m.end_frame(x, &r0, ea), m.end_frame(x, &r1, eb)) {
|
||||
let d = fa.p.dist(fb.p);
|
||||
if d.is_finite() && best.is_none_or(|bb| d < bb.0) {
|
||||
best = Some((d, ea, eb));
|
||||
(K::Coincident, Form::PP) => {
|
||||
let (p, q) = (pt(0)?, pt(1)?);
|
||||
out.extend([p.x - q.x, p.y - q.y]);
|
||||
}
|
||||
(K::Coincident, Form::PL { swap }) => {
|
||||
let (p, (a, b)) = if swap { (pt(1)?, ln(0)?) } else { (pt(0)?, ln(1)?) };
|
||||
out.push(dir_n(a, b).cross(p - a));
|
||||
}
|
||||
(K::Coincident, Form::PC { swap }) => {
|
||||
let (p, (c, rad)) = if swap { (pt(1)?, ci(0)?) } else { (pt(0)?, ci(1)?) };
|
||||
out.push(p.dist(c) - rad);
|
||||
}
|
||||
(K::Horizontal | K::Vertical, f) => {
|
||||
let (a, b) = if f == Form::L1 { ln(0)? } else { (pt(0)?, pt(1)?) };
|
||||
out.push(if self.kind == K::Horizontal { a.y - b.y } else { a.x - b.x });
|
||||
}
|
||||
(K::Parallel | K::Smooth, Form::LL) => {
|
||||
let ((a1, b1), (a2, b2)) = (ln(0)?, ln(1)?);
|
||||
out.push(dir_n(a1, b1).cross(dir_n(a2, b2)));
|
||||
}
|
||||
(K::Perpendicular, _) => {
|
||||
let ((a1, b1), (a2, b2)) = (ln(0)?, ln(1)?);
|
||||
out.push(dir_n(a1, b1).dot(dir_n(a2, b2)));
|
||||
}
|
||||
(K::Collinear, _) => {
|
||||
let ((a1, b1), (a2, b2)) = (ln(0)?, ln(1)?);
|
||||
let d = dir_n(a1, b1);
|
||||
out.extend([d.cross(a2 - a1), d.cross(b2 - a1)]);
|
||||
}
|
||||
(K::Concentric, _) => {
|
||||
let center = |i: usize| ci(i).map(|(c, _)| c).or_else(|| pt(i));
|
||||
let (p, q) = (center(0)?, center(1)?);
|
||||
out.extend([p.x - q.x, p.y - q.y]);
|
||||
}
|
||||
(K::Equal, Form::LL) => {
|
||||
let ((a1, b1), (a2, b2)) = (ln(0)?, ln(1)?);
|
||||
out.push(a1.dist(b1) - a2.dist(b2));
|
||||
}
|
||||
(K::Equal, _) => {
|
||||
let ((_, r1), (_, r2)) = (ci(0)?, ci(1)?);
|
||||
out.push(r1 - r2);
|
||||
}
|
||||
(K::Tangent | K::Smooth, Form::LC { swap }) => {
|
||||
let ((a, b), (c, rad)) = if swap { (ln(1)?, ci(0)?) } else { (ln(0)?, ci(1)?) };
|
||||
out.push(self.sign * dir_n(a, b).cross(c - a) - rad);
|
||||
}
|
||||
(K::Tangent | K::Smooth, _) => {
|
||||
let ((c1, r1), (c2, r2)) = (ci(0)?, ci(1)?);
|
||||
let d = c1.dist(c2);
|
||||
out.push(if self.sign > 0.0 { d - (r1 + r2) } else { d - self.target * (r1 - r2) });
|
||||
}
|
||||
(K::Symmetric, Form::PP) => {
|
||||
let (p, q, (a, b)) = (pt(0)?, pt(1)?, ln(2)?);
|
||||
let d = dir_n(a, b);
|
||||
out.extend([d.cross(p.mid(q) - a), d.dot(q - p)]);
|
||||
}
|
||||
(K::Symmetric, Form::SymLines { crossed }) => {
|
||||
let ((a1, b1), (a2, b2), (a, b)) = (ln(0)?, ln(1)?, ln(2)?);
|
||||
let (a2, b2) = if crossed { (b2, a2) } else { (a2, b2) };
|
||||
let d = dir_n(a, b);
|
||||
out.extend([d.cross(a1.mid(a2) - a), d.dot(a2 - a1), d.cross(b1.mid(b2) - a), d.dot(b2 - b1)]);
|
||||
}
|
||||
(K::Symmetric, _) => {
|
||||
let ((p, r1), (q, r2), (a, b)) = (ci(0)?, ci(1)?, ln(2)?);
|
||||
let d = dir_n(a, b);
|
||||
out.extend([d.cross(p.mid(q) - a), d.dot(q - p), r1 - r2]);
|
||||
}
|
||||
(K::Distance(axis), f) => {
|
||||
let v = match (axis, f) {
|
||||
(_, Form::PL { swap }) => {
|
||||
let (p, (a, b)) = if swap { (pt(1)?, ln(0)?) } else { (pt(0)?, ln(1)?) };
|
||||
dir_n(a, b).cross(p - a)
|
||||
}
|
||||
_ => {
|
||||
let (a, b) = if f == Form::L1 { ln(0)? } else { (pt(0)?, pt(1)?) };
|
||||
match axis {
|
||||
DistAxis::Aligned => a.dist(b),
|
||||
DistAxis::Horizontal => b.x - a.x,
|
||||
DistAxis::Vertical => b.y - a.y,
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
out.push(self.sign * v - self.target);
|
||||
}
|
||||
let Some((_, a_end, b_end)) = best else {
|
||||
return Err(format!("{} needs an open spline and a line, arc, elliptical arc, polyline segment or open spline", b.kind.name()));
|
||||
};
|
||||
let g2 = b.kind == ConstraintKind::Smooth;
|
||||
b.form = Form::Join { a_end, b_end, g2 };
|
||||
b.rows = if g2 { 4 } else { 3 };
|
||||
return Ok(b);
|
||||
(K::Angular, Form::ArcSweep) => {
|
||||
out.push(m.arc_sweep(x, r(0)?)? - self.target);
|
||||
}
|
||||
(K::Angular, _) => {
|
||||
let ((a1, b1), (a2, b2)) = (ln(0)?, ln(1)?);
|
||||
let (d1, d2) = (dir_n(a1, b1), dir_n(a2, b2));
|
||||
out.push(wrap(d1.cross(d2).atan2(d1.dot(d2)) - self.sign * self.target));
|
||||
}
|
||||
(K::Radius, _) => out.push(ci(0)?.1 - self.target),
|
||||
(K::Diameter, _) => out.push(2.0 * ci(0)?.1 - self.target),
|
||||
_ => {}
|
||||
}
|
||||
let (si, rs, ro) = if m.is_spline(&r0) { (0, r0, r1) } else { (1, r1, r0) };
|
||||
let dist = |p: Vec2| -> Option<f64> {
|
||||
if let Some((a, bb)) = m.line(x, &ro) {
|
||||
Some(dir_n(a, bb).cross(p - a).abs())
|
||||
} else if let Some((c, rad)) = m.circle(x, &ro) {
|
||||
Some((p.dist(c) - rad).abs())
|
||||
} else {
|
||||
m.ellipse(x, &ro).map(|e| e.on(p).abs())
|
||||
}
|
||||
};
|
||||
let mut best: Option<(f64, bool)> = None;
|
||||
for &e in ends(&rs) {
|
||||
if let Some(d) = m.end_frame(x, &rs, e).and_then(|f| dist(f.p))
|
||||
&& d.is_finite()
|
||||
&& best.is_none_or(|bb| d < bb.0)
|
||||
{
|
||||
best = Some((d, e));
|
||||
}
|
||||
}
|
||||
let Some((_, at_end)) = best else {
|
||||
return Err("Tangent needs an open spline and a line, circle, arc, ellipse or spline".into());
|
||||
};
|
||||
b.form = Form::EndOn { spline: si, at_end };
|
||||
b.rows = 2;
|
||||
Ok(b)
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// The current measured value (degrees for angular), for dimensional constraints.
|
||||
@@ -463,292 +393,3 @@ impl Built {
|
||||
if self.kind == ConstraintKind::Angular { v.to_degrees() } else { v }
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared access to a constraint's geometry, values, and output while evaluating one residual.
|
||||
struct ResidualEval<'a> {
|
||||
built: &'a Built,
|
||||
model: &'a Model,
|
||||
values: &'a [f64],
|
||||
out: &'a mut Vec<f64>,
|
||||
}
|
||||
|
||||
impl ResidualEval<'_> {
|
||||
fn evaluate(&mut self) -> Option<()> {
|
||||
use ConstraintKind as K;
|
||||
|
||||
match (self.built.kind, self.built.form) {
|
||||
(_, Form::Lock) => Some(()),
|
||||
(_, Form::PE { swap }) => self.eval_point_ellipse(swap),
|
||||
(_, Form::LE { swap }) => self.eval_line_ellipse(swap),
|
||||
(_, Form::Contact) => self.eval_contact(),
|
||||
(_, Form::EndOn { spline, at_end }) => self.eval_end_on(spline, at_end),
|
||||
(_, Form::Join { a_end, b_end, g2 }) => self.eval_join(a_end, b_end, g2),
|
||||
(_, Form::Anchor(anchor)) => self.eval_anchor(anchor),
|
||||
(K::Coincident, Form::PP) => self.eval_coincident_points(),
|
||||
(K::Coincident, Form::PL { swap }) => self.eval_point_line(swap),
|
||||
(K::Coincident, Form::PC { swap }) => self.eval_point_circle(swap),
|
||||
(K::Horizontal | K::Vertical, form) => self.eval_horizontal_vertical(form),
|
||||
(K::Parallel | K::Smooth, Form::LL) => self.eval_parallel(),
|
||||
(K::Perpendicular, _) => self.eval_perpendicular(),
|
||||
(K::Collinear, _) => self.eval_collinear(),
|
||||
(K::Concentric, _) => self.eval_concentric(),
|
||||
(K::Equal, Form::LL) => self.eval_equal_lines(),
|
||||
(K::Equal, _) => self.eval_equal_circles(),
|
||||
(K::Tangent | K::Smooth, Form::LC { swap }) => self.eval_tangent_line_circle(swap),
|
||||
(K::Tangent | K::Smooth, _) => self.eval_tangent_circles(),
|
||||
(K::Symmetric, Form::PP) => self.eval_symmetric_points(),
|
||||
(K::Symmetric, Form::SymLines { crossed }) => self.eval_symmetric_lines(crossed),
|
||||
(K::Symmetric, _) => self.eval_symmetric_circles(),
|
||||
(K::Distance(axis), form) => self.eval_distance(axis, form),
|
||||
(K::Angular, Form::ArcSweep) => self.eval_angular_arc(),
|
||||
(K::Angular, Form::Rays { rev }) => self.eval_angular_rays(rev),
|
||||
(K::Radius, _) => self.eval_radius(),
|
||||
(K::Diameter, _) => self.eval_diameter(),
|
||||
_ => Some(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn reference(&self, index: usize) -> Option<&GeomRef> {
|
||||
self.built.refs.get(index)
|
||||
}
|
||||
|
||||
fn point(&self, index: usize) -> Option<Vec2> {
|
||||
self.model.point(self.values, self.reference(index)?)
|
||||
}
|
||||
|
||||
fn line(&self, index: usize) -> Option<(Vec2, Vec2)> {
|
||||
self.model.line(self.values, self.reference(index)?)
|
||||
}
|
||||
|
||||
fn circle(&self, index: usize) -> Option<(Vec2, f64)> {
|
||||
self.model.circle(self.values, self.reference(index)?)
|
||||
}
|
||||
|
||||
fn ellipse(&self, index: usize) -> Option<Ell> {
|
||||
self.model.ellipse(self.values, self.reference(index)?)
|
||||
}
|
||||
|
||||
fn eval_point_ellipse(&mut self, swap: bool) -> Option<()> {
|
||||
let (point, ellipse) = if swap { (self.point(1)?, self.ellipse(0)?) } else { (self.point(0)?, self.ellipse(1)?) };
|
||||
self.out.push(ellipse.on(point));
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn eval_line_ellipse(&mut self, swap: bool) -> Option<()> {
|
||||
let (line, ellipse) = if swap { (self.line(1)?, self.ellipse(0)?) } else { (self.line(0)?, self.ellipse(1)?) };
|
||||
let (start, end) = line;
|
||||
let direction = dir_n(start, end);
|
||||
self.out.push(self.built.sign * direction.cross(ellipse.c - start) - ellipse.support(direction.perp()));
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn eval_contact(&mut self) -> Option<()> {
|
||||
let first_parameter = *self.values.get(self.built.aux)?;
|
||||
let second_parameter = *self.values.get(self.built.aux + 1)?;
|
||||
let (first_point, first_tangent) = self.built.curve_at(self.model, self.values, 0, first_parameter)?;
|
||||
let (second_point, second_tangent) = self.built.curve_at(self.model, self.values, 1, second_parameter)?;
|
||||
self.out.extend([
|
||||
first_point.x - second_point.x,
|
||||
first_point.y - second_point.y,
|
||||
first_tangent.normalized().cross(second_tangent.normalized()),
|
||||
]);
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn eval_end_on(&mut self, spline: usize, at_end: bool) -> Option<()> {
|
||||
let frame = self.model.end_frame(self.values, self.reference(spline)?, at_end)?;
|
||||
let other = 1 - spline.min(1);
|
||||
if let Some((start, end)) = self.line(other) {
|
||||
let direction = dir_n(start, end);
|
||||
self.out.extend([direction.cross(frame.p - start), frame.t.cross(direction)]);
|
||||
} else if let Some((center, radius)) = self.circle(other) {
|
||||
self.out.extend([frame.p.dist(center) - radius, frame.t.dot(dir_n(center, frame.p))]);
|
||||
} else {
|
||||
let ellipse = self.ellipse(other)?;
|
||||
self.out.extend([ellipse.on(frame.p), frame.t.dot(ellipse.normal(frame.p))]);
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn eval_join(&mut self, a_end: bool, b_end: bool, g2: bool) -> Option<()> {
|
||||
let first = self.model.end_frame(self.values, self.reference(0)?, a_end)?;
|
||||
let second = self.model.end_frame(self.values, self.reference(1)?, b_end)?;
|
||||
let first = if a_end { first } else { first.reversed() };
|
||||
let second = if b_end { second.reversed() } else { second };
|
||||
self.out.extend([first.p.x - second.p.x, first.p.y - second.p.y, first.t.cross(second.t)]);
|
||||
if g2 {
|
||||
self.out.push(first.k - second.k);
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn eval_anchor(&mut self, anchor: Vec2) -> Option<()> {
|
||||
let point = self.point(0)?;
|
||||
self.out.extend([point.x - anchor.x, point.y - anchor.y]);
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn eval_coincident_points(&mut self) -> Option<()> {
|
||||
let (first, second) = (self.point(0)?, self.point(1)?);
|
||||
self.out.extend([first.x - second.x, first.y - second.y]);
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn eval_point_line(&mut self, swap: bool) -> Option<()> {
|
||||
let (point, (start, end)) = if swap { (self.point(1)?, self.line(0)?) } else { (self.point(0)?, self.line(1)?) };
|
||||
self.out.push(dir_n(start, end).cross(point - start));
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn eval_point_circle(&mut self, swap: bool) -> Option<()> {
|
||||
let (point, (center, radius)) = if swap { (self.point(1)?, self.circle(0)?) } else { (self.point(0)?, self.circle(1)?) };
|
||||
self.out.push(point.dist(center) - radius);
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn eval_horizontal_vertical(&mut self, form: Form) -> Option<()> {
|
||||
use ConstraintKind as K;
|
||||
|
||||
let (first, second) = if form == Form::L1 {
|
||||
self.line(0).or_else(|| self.ellipse(0).map(|ellipse| (ellipse.c - ellipse.mj, ellipse.c + ellipse.mj)))?
|
||||
} else {
|
||||
(self.point(0)?, self.point(1)?)
|
||||
};
|
||||
let value = if self.built.kind == K::Horizontal { first.y - second.y } else { first.x - second.x };
|
||||
self.out.push(value);
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn eval_parallel(&mut self) -> Option<()> {
|
||||
let ((first_start, first_end), (second_start, second_end)) = (self.line(0)?, self.line(1)?);
|
||||
self.out.push(dir_n(first_start, first_end).cross(dir_n(second_start, second_end)));
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn eval_perpendicular(&mut self) -> Option<()> {
|
||||
let ((first_start, first_end), (second_start, second_end)) = (self.line(0)?, self.line(1)?);
|
||||
self.out.push(dir_n(first_start, first_end).dot(dir_n(second_start, second_end)));
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn eval_collinear(&mut self) -> Option<()> {
|
||||
let ((first_start, first_end), (second_start, second_end)) = (self.line(0)?, self.line(1)?);
|
||||
let direction = dir_n(first_start, first_end);
|
||||
self.out.extend([direction.cross(second_start - first_start), direction.cross(second_end - first_start)]);
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn eval_concentric(&mut self) -> Option<()> {
|
||||
let center = |index| {
|
||||
self.circle(index).map(|(center, _)| center).or_else(|| self.ellipse(index).map(|ellipse| ellipse.c)).or_else(|| self.point(index))
|
||||
};
|
||||
let (first, second) = (center(0)?, center(1)?);
|
||||
self.out.extend([first.x - second.x, first.y - second.y]);
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn eval_equal_lines(&mut self) -> Option<()> {
|
||||
let ((first_start, first_end), (second_start, second_end)) = (self.line(0)?, self.line(1)?);
|
||||
self.out.push(first_start.dist(first_end) - second_start.dist(second_end));
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn eval_equal_circles(&mut self) -> Option<()> {
|
||||
let ((_, first_radius), (_, second_radius)) = (self.circle(0)?, self.circle(1)?);
|
||||
self.out.push(first_radius - second_radius);
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn eval_tangent_line_circle(&mut self, swap: bool) -> Option<()> {
|
||||
let ((start, end), (center, radius)) = if swap { (self.line(1)?, self.circle(0)?) } else { (self.line(0)?, self.circle(1)?) };
|
||||
self.out.push(self.built.sign * dir_n(start, end).cross(center - start) - radius);
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn eval_tangent_circles(&mut self) -> Option<()> {
|
||||
let ((first_center, first_radius), (second_center, second_radius)) = (self.circle(0)?, self.circle(1)?);
|
||||
let distance = first_center.dist(second_center);
|
||||
let residual = if self.built.sign > 0.0 {
|
||||
distance - (first_radius + second_radius)
|
||||
} else {
|
||||
distance - self.built.target * (first_radius - second_radius)
|
||||
};
|
||||
self.out.push(residual);
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn eval_symmetric_points(&mut self) -> Option<()> {
|
||||
let (first, second, (line_start, line_end)) = (self.point(0)?, self.point(1)?, self.line(2)?);
|
||||
let direction = dir_n(line_start, line_end);
|
||||
self.out.extend([direction.cross(first.mid(second) - line_start), direction.dot(second - first)]);
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn eval_symmetric_lines(&mut self, crossed: bool) -> Option<()> {
|
||||
let ((first_start, first_end), (second_start, second_end), (line_start, line_end)) = (self.line(0)?, self.line(1)?, self.line(2)?);
|
||||
let (second_start, second_end) = if crossed { (second_end, second_start) } else { (second_start, second_end) };
|
||||
let direction = dir_n(line_start, line_end);
|
||||
self.out.extend([
|
||||
direction.cross(first_start.mid(second_start) - line_start),
|
||||
direction.dot(second_start - first_start),
|
||||
direction.cross(first_end.mid(second_end) - line_start),
|
||||
direction.dot(second_end - first_end),
|
||||
]);
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn eval_symmetric_circles(&mut self) -> Option<()> {
|
||||
let ((first_center, first_radius), (second_center, second_radius), (line_start, line_end)) =
|
||||
(self.circle(0)?, self.circle(1)?, self.line(2)?);
|
||||
let direction = dir_n(line_start, line_end);
|
||||
self.out.extend([
|
||||
direction.cross(first_center.mid(second_center) - line_start),
|
||||
direction.dot(second_center - first_center),
|
||||
first_radius - second_radius,
|
||||
]);
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn eval_distance(&mut self, axis: DistAxis, form: Form) -> Option<()> {
|
||||
let value = match (axis, form) {
|
||||
(_, Form::PL { swap }) => {
|
||||
let (point, (start, end)) = if swap { (self.point(1)?, self.line(0)?) } else { (self.point(0)?, self.line(1)?) };
|
||||
dir_n(start, end).cross(point - start)
|
||||
}
|
||||
_ => {
|
||||
let (first, second) = if form == Form::L1 { self.line(0)? } else { (self.point(0)?, self.point(1)?) };
|
||||
match axis {
|
||||
DistAxis::Aligned => first.dist(second),
|
||||
DistAxis::Horizontal => second.x - first.x,
|
||||
DistAxis::Vertical => second.y - first.y,
|
||||
}
|
||||
}
|
||||
};
|
||||
self.out.push(self.built.sign * value - self.built.target);
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn eval_angular_arc(&mut self) -> Option<()> {
|
||||
let reference = self.reference(0)?;
|
||||
self.out.push(self.model.arc_sweep(self.values, reference)? - self.built.target);
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn eval_angular_rays(&mut self, reversed: [bool; 2]) -> Option<()> {
|
||||
let ((first_start, first_end), (second_start, second_end)) = (self.line(0)?, self.line(1)?);
|
||||
let (first, second) = (ray(first_start, first_end, reversed[0]), ray(second_start, second_end, reversed[1]));
|
||||
let residual = wrap(first.cross(second).atan2(first.dot(second)) - self.built.sign * self.built.target);
|
||||
self.out.push(residual);
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn eval_radius(&mut self) -> Option<()> {
|
||||
self.out.push(self.circle(0)?.1 - self.built.target);
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn eval_diameter(&mut self) -> Option<()> {
|
||||
self.out.push(2.0 * self.circle(0)?.1 - self.built.target);
|
||||
Some(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,7 +305,7 @@ fn keep_option_moves_the_other_entity() {
|
||||
let mut d = Drawing::default();
|
||||
let a = line(&mut d, (0.0, 0.0), (10.0, 0.0));
|
||||
let b = line(&mut d, (0.0, 2.0), (10.0, 4.0));
|
||||
add(&mut d, con(ConstraintKind::Parallel, &[w(a), w(b)]), &SolveOptions { keep: vec![a], prefer_move: vec![b], ..Default::default() }).unwrap();
|
||||
add(&mut d, con(ConstraintKind::Parallel, &[w(a), w(b)]), &SolveOptions { keep: vec![a], prefer_move: vec![b] }).unwrap();
|
||||
let (a0, a1) = ln(&d, a);
|
||||
assert!(a0.near(Vec2::new(0.0, 0.0), 1e-3) && a1.near(Vec2::new(10.0, 0.0), 1e-3), "{a0:?} {a1:?}");
|
||||
}
|
||||
@@ -412,7 +412,7 @@ fn glyphs_and_residuals() {
|
||||
})
|
||||
.unwrap();
|
||||
assert!(!all_satisfied(&d));
|
||||
let r = solve(&mut d, &SolveOptions::default()).unwrap();
|
||||
let r = solve(&mut d, &SolveOptions { keep: vec![], prefer_move: vec![] }).unwrap();
|
||||
assert_eq!(r.moved, vec![a]);
|
||||
assert!(all_satisfied(&d));
|
||||
}
|
||||
@@ -463,29 +463,3 @@ fn set_parameter_rejects_out_of_domain_dimension() {
|
||||
set_parameter(&mut d, "rad1", "2", &opts).unwrap();
|
||||
assert!(all_satisfied(&d));
|
||||
}
|
||||
|
||||
/// The solver may meet a coincidence with a negative arc radius; the stored arc must still put
|
||||
/// the referenced point where the solver did (issue #703), through `add` and through `solve`.
|
||||
#[test]
|
||||
fn arc_solved_with_a_negative_radius_keeps_its_points() {
|
||||
use std::f64::consts::FRAC_PI_2;
|
||||
// (arc centre, radius, start, end, point): a semicircle with the point behind it, and the
|
||||
// asymmetric case from the issue.
|
||||
let cases = [((0.0, 0.0), 1.0, -FRAC_PI_2, FRAC_PI_2, (-5.0, 0.0)), ((6.694, -0.686), 0.62075, 3.4824, 1.1766, (-7.562, 4.946))];
|
||||
for (c, r, a0, a1, p) in cases {
|
||||
for through_solve in [false, true] {
|
||||
let mut d = Drawing::default();
|
||||
let a = arc(&mut d, c, r, a0, a1);
|
||||
let q = add_e(&mut d, EntityKind::Point(DPoint { p: v(p.0, p.1), angle: 0.0 }));
|
||||
let k = con(ConstraintKind::Coincident, &[s(a, Sub::Mid), w(q)]);
|
||||
if through_solve {
|
||||
d.constraints.push(Constraint { id: 1, ..k });
|
||||
solve(&mut d, &SolveOptions::default()).unwrap();
|
||||
} else {
|
||||
ok(&mut d, k);
|
||||
}
|
||||
assert!(all_satisfied(&d), "{c:?} solve {through_solve}: {:?}", residuals(&d));
|
||||
assert!(circ(&d, a).1 > 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,464 +0,0 @@
|
||||
//! Degrees of freedom, redundancy hints, ellipse/spline/block geometry and Smooth (G2).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use cadcraft_constraints::{DofStatus, SolveError, SolveOptions, add, all_satisfied, dof, solve};
|
||||
use cadcraft_doc::{
|
||||
Arc as DArc, Block, Circle as DCircle, Common, Constraint, ConstraintKind as K, DistAxis, Drawing, Ellipse as DEllipse, Entity, EntityKind,
|
||||
GeomRef, Handle, Insert, Line as DLine, Point as DPoint, Space, Sub,
|
||||
};
|
||||
use cadcraft_geom::{Spline as GSpline, Vec2, Vec3};
|
||||
|
||||
fn v(x: f64, y: f64) -> Vec3 {
|
||||
Vec3::new(x, y, 0.0)
|
||||
}
|
||||
fn put(d: &mut Drawing, k: EntityKind) -> Handle {
|
||||
d.add(&Space::Model, Common::default(), k).unwrap()
|
||||
}
|
||||
fn line(d: &mut Drawing, a: (f64, f64), b: (f64, f64)) -> Handle {
|
||||
put(d, EntityKind::Line(DLine { a: v(a.0, a.1), b: v(b.0, b.1) }))
|
||||
}
|
||||
fn circle(d: &mut Drawing, c: (f64, f64), r: f64) -> Handle {
|
||||
put(d, EntityKind::Circle(DCircle { center: v(c.0, c.1), radius: r }))
|
||||
}
|
||||
fn point(d: &mut Drawing, p: (f64, f64)) -> Handle {
|
||||
put(d, EntityKind::Point(DPoint { p: v(p.0, p.1), angle: 0.0 }))
|
||||
}
|
||||
fn ellipse(d: &mut Drawing, c: (f64, f64), major: (f64, f64), ratio: f64, start: f64, end: f64) -> Handle {
|
||||
put(d, EntityKind::Ellipse(DEllipse { center: v(c.0, c.1), major: v(major.0, major.1), ratio, start, end }))
|
||||
}
|
||||
fn spline(d: &mut Drawing, pts: &[(f64, f64)]) -> Handle {
|
||||
let s = GSpline::from_control(pts.iter().map(|p| Vec2::new(p.0, p.1)).collect(), 3);
|
||||
put(d, EntityKind::Spline(s))
|
||||
}
|
||||
fn con(kind: K, refs: &[GeomRef]) -> Constraint {
|
||||
Constraint { id: 0, kind, refs: refs.to_vec(), name: String::new(), expr: String::new() }
|
||||
}
|
||||
fn dim(kind: K, refs: &[GeomRef], expr: &str) -> Constraint {
|
||||
Constraint { id: 0, kind, refs: refs.to_vec(), name: String::new(), expr: expr.into() }
|
||||
}
|
||||
fn w(h: Handle) -> GeomRef {
|
||||
GeomRef::whole(h)
|
||||
}
|
||||
fn s(h: Handle, sub: Sub) -> GeomRef {
|
||||
GeomRef::new(h, sub)
|
||||
}
|
||||
fn ok(d: &mut Drawing, c: Constraint) -> u32 {
|
||||
let k = c.kind;
|
||||
add(d, c, &SolveOptions::default()).unwrap_or_else(|e| panic!("{k:?}: {e}"))
|
||||
}
|
||||
fn ends(d: &Drawing, h: Handle) -> (Vec2, Vec2) {
|
||||
match &d.entity(h).unwrap().kind {
|
||||
EntityKind::Line(l) => (l.a.xy(), l.b.xy()),
|
||||
_ => panic!("not a line"),
|
||||
}
|
||||
}
|
||||
fn ell(d: &Drawing, h: Handle) -> cadcraft_geom::Ellipse {
|
||||
match &d.entity(h).unwrap().kind {
|
||||
EntityKind::Ellipse(e) => cadcraft_geom::Ellipse { center: e.center.xy(), major: e.major.xy(), ratio: e.ratio, start: e.start, end: e.end },
|
||||
_ => panic!("not an ellipse"),
|
||||
}
|
||||
}
|
||||
fn spl(d: &Drawing, h: Handle) -> GSpline {
|
||||
match &d.entity(h).unwrap().kind {
|
||||
EntityKind::Spline(s) => s.clone(),
|
||||
_ => panic!("not a spline"),
|
||||
}
|
||||
}
|
||||
fn obj_dof(d: &Drawing, h: Handle) -> Option<usize> {
|
||||
let r = dof(d, &[h]).unwrap();
|
||||
r.objects.iter().find(|o| o.handle == h.hex()).and_then(|o| o.dof)
|
||||
}
|
||||
|
||||
/// Signed curvature by sampling a curve near a parameter (central differences).
|
||||
fn curvature(f: impl Fn(f64) -> Vec2, t: f64, h: f64) -> f64 {
|
||||
let (a, b, c) = (f(t - h), f(t), f(t + h));
|
||||
let d1 = (c - a) * (0.5 / h);
|
||||
let d2 = (c - b * 2.0 + a) * (1.0 / (h * h));
|
||||
d1.cross(d2) / d1.len().powi(3)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rectangle_dof_counts_down_to_fully_constrained() {
|
||||
let mut d = Drawing::default();
|
||||
let l = [
|
||||
line(&mut d, (0.0, 0.0), (10.0, 0.2)),
|
||||
line(&mut d, (10.0, 0.2), (10.1, 5.0)),
|
||||
line(&mut d, (10.1, 5.0), (0.0, 5.0)),
|
||||
line(&mut d, (0.0, 5.0), (0.0, 0.0)),
|
||||
];
|
||||
let r = dof(&d, &[]).unwrap();
|
||||
assert_eq!((r.status, r.dof, r.message.as_str()), (DofStatus::Unconstrained, Some(0), "No constraints"));
|
||||
// An unconstrained line has its four parameters.
|
||||
assert_eq!(obj_dof(&d, l[0]), Some(4));
|
||||
for i in 0..4 {
|
||||
ok(&mut d, con(K::Coincident, &[s(l[i], Sub::End), s(l[(i + 1) % 4], Sub::Start)]));
|
||||
}
|
||||
ok(&mut d, con(K::Horizontal, &[w(l[0])]));
|
||||
ok(&mut d, con(K::Horizontal, &[w(l[2])]));
|
||||
ok(&mut d, con(K::Vertical, &[w(l[1])]));
|
||||
ok(&mut d, con(K::Vertical, &[w(l[3])]));
|
||||
// 16 parameters − 12 equations: translation, width, height.
|
||||
let r = dof(&d, &[]).unwrap();
|
||||
assert_eq!((r.dof, r.status, r.redundant), (Some(4), DofStatus::Under, 0));
|
||||
assert_eq!(r.objects.len(), 4);
|
||||
assert!(r.objects.iter().all(|o| o.params == 4 && o.dof == Some(3) && o.status == DofStatus::Under), "{r:?}");
|
||||
ok(&mut d, con(K::Fix, &[s(l[0], Sub::Start)]));
|
||||
assert_eq!(dof(&d, &[]).unwrap().dof, Some(2));
|
||||
ok(&mut d, dim(K::Distance(DistAxis::Aligned), &[w(l[0])], "12"));
|
||||
// The bottom edge is now fixed; the others still stretch with the height.
|
||||
assert_eq!(obj_dof(&d, l[0]), Some(0));
|
||||
assert_eq!(obj_dof(&d, l[2]), Some(1));
|
||||
ok(&mut d, dim(K::Distance(DistAxis::Aligned), &[w(l[1])], "4"));
|
||||
let r = dof(&d, &[]).unwrap();
|
||||
assert_eq!((r.dof, r.status, r.message.as_str()), (Some(0), DofStatus::Fully, "Fully constrained"));
|
||||
assert!(r.objects.iter().all(|o| o.status == DofStatus::Fully));
|
||||
// A fully constrained rectangle refuses one more dimension, naming the constraints it repeats.
|
||||
let e = add(&mut d, dim(K::Distance(DistAxis::Aligned), &[w(l[3])], "4"), &SolveOptions::default()).unwrap_err();
|
||||
let SolveError::Redundant { ids } = e else { panic!("{e:?}") };
|
||||
assert!(!ids.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redundant_hint_names_the_duplicated_constraints() {
|
||||
let mut d = Drawing::default();
|
||||
let a = line(&mut d, (0.0, 0.0), (10.0, 0.1));
|
||||
let b = line(&mut d, (0.0, 5.0), (10.0, 5.2));
|
||||
let c = line(&mut d, (20.0, 0.0), (20.0, 9.0));
|
||||
let ha = ok(&mut d, con(K::Horizontal, &[w(a)]));
|
||||
let hb = ok(&mut d, con(K::Horizontal, &[w(b)]));
|
||||
let vc = ok(&mut d, con(K::Vertical, &[w(c)]));
|
||||
ok(&mut d, con(K::Coincident, &[s(b, Sub::End), s(c, Sub::Start)]));
|
||||
let before = d.clone();
|
||||
let e = add(&mut d, con(K::Parallel, &[w(a), w(b)]), &SolveOptions::default()).unwrap_err();
|
||||
let SolveError::Redundant { ids } = &e else { panic!("{e:?}") };
|
||||
assert!(ids.contains(&ha) && ids.contains(&hb), "{ids:?}");
|
||||
assert!(!ids.contains(&vc), "{ids:?}");
|
||||
assert!(e.to_string().contains(&format!("#{ha}")), "{e}");
|
||||
assert_eq!(d, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn over_constrained_drawing_is_reported() {
|
||||
let mut d = Drawing::default();
|
||||
let a = line(&mut d, (0.0, 0.0), (10.0, 0.0));
|
||||
ok(&mut d, con(K::Horizontal, &[w(a)]));
|
||||
// A file can hold a duplicate the command would have refused.
|
||||
d.constraints.push(Constraint { id: 99, ..con(K::Horizontal, &[w(a)]) });
|
||||
let r = dof(&d, &[]).unwrap();
|
||||
assert_eq!((r.status, r.redundant), (DofStatus::Over, 1));
|
||||
assert!(r.message.starts_with("Over-constrained"), "{}", r.message);
|
||||
assert_eq!(r.objects[0].status, DofStatus::Over);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ellipse_constraints() {
|
||||
let mut d = Drawing::default();
|
||||
let e = ellipse(&mut d, (0.0, 0.0), (6.0, 0.5), 0.5, 0.0, std::f64::consts::TAU);
|
||||
assert_eq!(obj_dof(&d, e), Some(5));
|
||||
// Horizontal applies to the major axis.
|
||||
ok(&mut d, con(K::Horizontal, &[w(e)]));
|
||||
assert!(ell(&d, e).major.y.abs() < 1e-9);
|
||||
// A point on the ellipse.
|
||||
let p = point(&mut d, (0.3, 3.4));
|
||||
ok(&mut d, con(K::Coincident, &[w(p), w(e)]));
|
||||
let g = ell(&d, e);
|
||||
let pp = match &d.entity(p).unwrap().kind {
|
||||
EntityKind::Point(q) => q.p.xy(),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let (a, b) = (g.major.len(), g.major.len() * g.ratio);
|
||||
let q = pp - g.center;
|
||||
assert!(((q.x / a).powi(2) + (q.y / b).powi(2) - 1.0).abs() < 1e-7);
|
||||
// Concentric with a circle.
|
||||
let c = circle(&mut d, (0.5, -0.4), 1.0);
|
||||
ok(&mut d, con(K::Concentric, &[w(e), w(c)]));
|
||||
let cc = match &d.entity(c).unwrap().kind {
|
||||
EntityKind::Circle(k) => k.center.xy(),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
assert!(cc.near(ell(&d, e).center, 1e-7));
|
||||
// A line tangent to the ellipse.
|
||||
let l = line(&mut d, (-10.0, 3.6), (10.0, 3.2));
|
||||
ok(&mut d, con(K::Tangent, &[w(l), w(e)]));
|
||||
let (la, lb) = ends(&d, l);
|
||||
let g = ell(&d, e);
|
||||
let n = (lb - la).normalized().perp();
|
||||
let support = (n.dot(g.major).powi(2) + n.dot(g.minor()).powi(2)).sqrt();
|
||||
assert!(((g.center - la).dot(n).abs() - support).abs() < 1e-7);
|
||||
// An axis end point coincides with a line end.
|
||||
let m = line(&mut d, (6.2, 0.3), (9.0, 5.0));
|
||||
ok(&mut d, con(K::Coincident, &[s(e, Sub::Vertex(0)), s(m, Sub::Start)]));
|
||||
let g = ell(&d, e);
|
||||
assert!(ends(&d, m).0.near(g.center + g.major, 1e-7));
|
||||
assert!(all_satisfied(&d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn circle_tangent_to_ellipse_and_elliptical_arc_ends() {
|
||||
let mut d = Drawing::default();
|
||||
let e = ellipse(&mut d, (0.0, 0.0), (5.0, 0.0), 0.6, 0.0, std::f64::consts::TAU);
|
||||
ok(&mut d, con(K::Fix, &[w(e)]));
|
||||
let c = circle(&mut d, (7.5, 0.4), 2.0);
|
||||
ok(&mut d, con(K::Tangent, &[w(c), w(e)]));
|
||||
let (cc, r) = match &d.entity(c).unwrap().kind {
|
||||
EntityKind::Circle(k) => (k.center.xy(), k.radius),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
// Closest distance from the circle's centre to the ellipse equals the radius.
|
||||
let g = ell(&d, e);
|
||||
let near = (0..20000).map(|i| g.at_param(i as f64 * std::f64::consts::TAU / 20000.0).dist(cc)).fold(f64::INFINITY, f64::min);
|
||||
assert!((near - r).abs() < 1e-3, "{near} vs {r}");
|
||||
assert!(all_satisfied(&d));
|
||||
// Elliptical arc ends are points.
|
||||
let arc = ellipse(&mut d, (20.0, 0.0), (4.0, 0.0), 0.5, 0.0, std::f64::consts::PI);
|
||||
assert_eq!(obj_dof(&d, arc), Some(7));
|
||||
let l = line(&mut d, (24.3, 0.2), (30.0, 0.0));
|
||||
ok(&mut d, con(K::Coincident, &[s(arc, Sub::Start), s(l, Sub::Start)]));
|
||||
let g = ell(&d, arc);
|
||||
assert!(ends(&d, l).0.near(g.at_param(g.start), 1e-7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spline_points_tangent_and_smooth_g2() {
|
||||
let mut d = Drawing::default();
|
||||
// Arc to the left of the spline's start.
|
||||
let a = put(&mut d, EntityKind::Arc(DArc { center: v(0.0, 3.0), radius: 3.0, start: std::f64::consts::PI, end: 1.5 * std::f64::consts::PI }));
|
||||
ok(&mut d, con(K::Fix, &[w(a)]));
|
||||
let sp = spline(&mut d, &[(0.1, 0.1), (3.0, 0.4), (6.0, 2.0), (9.0, 2.0)]);
|
||||
assert_eq!(obj_dof(&d, sp), Some(8));
|
||||
ok(&mut d, con(K::Smooth, &[w(sp), w(a)]));
|
||||
let g = spl(&d, sp);
|
||||
let (lo, _) = g.domain();
|
||||
// Joined at the arc's end (0, 0), tangent along +x, curvature 1/3 like the arc.
|
||||
assert!(g.eval(lo).near(Vec2::new(0.0, 0.0), 1e-7), "{:?}", g.eval(lo));
|
||||
let t = (g.eval(lo + 1e-6) - g.eval(lo)).normalized();
|
||||
assert!(t.near(Vec2::new(1.0, 0.0), 1e-4), "{t:?}");
|
||||
let k = curvature(|u| g.eval(u), lo + 1e-3, 1e-4);
|
||||
assert!((k - 1.0 / 3.0).abs() < 2e-2, "curvature {k}");
|
||||
assert!(all_satisfied(&d));
|
||||
// The spline's control points are constraint points.
|
||||
let p = point(&mut d, (9.5, 2.5));
|
||||
ok(&mut d, con(K::Coincident, &[s(sp, Sub::Vertex(3)), w(p)]));
|
||||
let pp = match &d.entity(p).unwrap().kind {
|
||||
EntityKind::Point(q) => q.p.xy(),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
assert!(spl(&d, sp).control[3].near(pp, 1e-7));
|
||||
// Smooth to a line: the far end ends straight.
|
||||
let l = line(&mut d, (9.2, 2.1), (15.0, 3.0));
|
||||
ok(&mut d, con(K::Smooth, &[w(sp), w(l)]));
|
||||
let g = spl(&d, sp);
|
||||
let (_, hi) = g.domain();
|
||||
assert!(g.eval(hi).near(ends(&d, l).0, 1e-7));
|
||||
let k = curvature(|u| g.eval(u), hi - 1e-3, 1e-4);
|
||||
assert!(k.abs() < 2e-2, "curvature at the line {k}");
|
||||
assert!(all_satisfied(&d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spline_tangent_to_circle_and_fit_spline_smooth() {
|
||||
let mut d = Drawing::default();
|
||||
let c = circle(&mut d, (0.0, 0.0), 2.0);
|
||||
ok(&mut d, con(K::Fix, &[w(c)]));
|
||||
let sp = spline(&mut d, &[(2.1, 0.3), (2.5, 3.0), (5.0, 4.0), (8.0, 4.0)]);
|
||||
ok(&mut d, con(K::Tangent, &[w(sp), w(c)]));
|
||||
let g = spl(&d, sp);
|
||||
let (lo, _) = g.domain();
|
||||
let p = g.eval(lo);
|
||||
assert!((p.len() - 2.0).abs() < 1e-7);
|
||||
let t = (g.eval(lo + 1e-7) - p).normalized();
|
||||
assert!(t.dot(p.normalized()).abs() < 1e-4);
|
||||
// A fit-point spline keeps its fit points and is rebuilt through them.
|
||||
let fit: Vec<Vec2> = [(20.0, 0.0), (23.0, 2.0), (27.0, 1.0), (30.0, 3.0)].iter().map(|p| Vec2::new(p.0, p.1)).collect();
|
||||
let fs = put(&mut d, EntityKind::Spline(GSpline::from_fit_points(&fit)));
|
||||
let l = line(&mut d, (10.0, -1.0), (19.8, 0.2));
|
||||
ok(&mut d, con(K::Fix, &[w(l)]));
|
||||
ok(&mut d, con(K::Smooth, &[w(fs), w(l)]));
|
||||
let g = spl(&d, fs);
|
||||
assert_eq!(g.fit.len(), 4);
|
||||
assert!(g.fit[0].near(Vec2::new(19.8, 0.2), 1e-7));
|
||||
let (lo, _) = g.domain();
|
||||
assert!(g.eval(lo).near(g.fit[0], 1e-9));
|
||||
assert!(all_satisfied(&d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_reference_moves_rigidly() {
|
||||
let mut d = Drawing::default();
|
||||
let mut b = Block::new("BAR");
|
||||
let h = d.new_handle();
|
||||
b.entities.push(Entity { handle: h, common: Common::default(), kind: EntityKind::Line(DLine { a: v(0.0, 0.0), b: v(4.0, 0.0) }) });
|
||||
d.blocks.insert("BAR".into(), Arc::new(b));
|
||||
let ins = put(
|
||||
&mut d,
|
||||
EntityKind::Insert(Insert {
|
||||
block: "BAR".into(),
|
||||
insert: v(1.0, 1.0),
|
||||
scale: v(1.0, 1.0),
|
||||
rotation: 1.2,
|
||||
attribs: Vec::new(),
|
||||
cols: 1,
|
||||
rows: 1,
|
||||
col_spacing: 0.0,
|
||||
row_spacing: 0.0,
|
||||
}),
|
||||
);
|
||||
assert_eq!(obj_dof(&d, ins), Some(3));
|
||||
let p0 = point(&mut d, (10.0, 10.0));
|
||||
let wall = line(&mut d, (10.0, 0.0), (10.0, 20.0));
|
||||
ok(&mut d, con(K::Fix, &[w(p0)]));
|
||||
ok(&mut d, con(K::Fix, &[w(wall)]));
|
||||
// The insertion point onto p0 leaves the turn; the block's far end (rigid point 1) onto the
|
||||
// wall takes it.
|
||||
ok(&mut d, con(K::Coincident, &[w(ins), w(p0)]));
|
||||
assert_eq!(obj_dof(&d, ins), Some(1));
|
||||
ok(&mut d, con(K::Coincident, &[s(ins, Sub::Vertex(1)), w(wall)]));
|
||||
let EntityKind::Insert(i) = &d.entity(ins).unwrap().kind else { panic!() };
|
||||
assert!(i.insert.xy().near(Vec2::new(10.0, 10.0), 1e-7));
|
||||
assert!((i.rotation - std::f64::consts::FRAC_PI_2).abs() < 1e-7, "{}", i.rotation);
|
||||
// Both points again would be one equation too many.
|
||||
let p1 = point(&mut d, (10.0, 14.0));
|
||||
ok(&mut d, con(K::Fix, &[w(p1)]));
|
||||
assert!(matches!(add(&mut d, con(K::Coincident, &[s(ins, Sub::Vertex(1)), w(p1)]), &SolveOptions::default()), Err(SolveError::Redundant { .. })));
|
||||
assert_eq!(dof(&d, &[]).unwrap().status, DofStatus::Fully);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_new_geometry_never_panics() {
|
||||
let mut d = Drawing::default();
|
||||
let bad_e = ellipse(&mut d, (0.0, 0.0), (0.0, 0.0), f64::NAN, 0.0, 1.0);
|
||||
let flat_e = ellipse(&mut d, (0.0, 0.0), (1e300, 0.0), 0.0, 0.0, 1.0);
|
||||
let broken = put(&mut d, EntityKind::Spline(GSpline { degree: 7, knots: vec![f64::NAN; 3], control: vec![Vec2::ZERO; 2], ..Default::default() }));
|
||||
let one = put(&mut d, EntityKind::Spline(GSpline::from_control(vec![Vec2::new(1.0, 1.0); 3], 2)));
|
||||
let missing = put(
|
||||
&mut d,
|
||||
EntityKind::Insert(Insert {
|
||||
block: "NOPE".into(),
|
||||
insert: v(0.0, 0.0),
|
||||
scale: v(0.0, f64::INFINITY),
|
||||
rotation: f64::NAN,
|
||||
attribs: Vec::new(),
|
||||
cols: 1,
|
||||
rows: 1,
|
||||
col_spacing: 0.0,
|
||||
row_spacing: 0.0,
|
||||
}),
|
||||
);
|
||||
let l = line(&mut d, (0.0, 0.0), (0.0, 0.0));
|
||||
let hs = [bad_e, flat_e, broken, one, missing, l];
|
||||
let kinds = [K::Coincident, K::Concentric, K::Tangent, K::Smooth, K::Horizontal, K::Fix, K::Equal, K::Symmetric, K::Radius];
|
||||
let subs = [Sub::Whole, Sub::Start, Sub::End, Sub::Center, Sub::Vertex(0), Sub::Vertex(3), Sub::Vertex(u32::MAX)];
|
||||
for k in kinds {
|
||||
for (i, h) in hs.iter().enumerate() {
|
||||
for sub in subs {
|
||||
let refs = [s(*h, sub), w(hs[(i + 1) % hs.len()]), w(hs[(i + 2) % hs.len()])];
|
||||
for n in 0..=3 {
|
||||
let _ = add(&mut d, dim(k, &refs[..n], "1"), &SolveOptions::default());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = solve(&mut d, &SolveOptions::default());
|
||||
let _ = dof(&d, &hs);
|
||||
let _ = dof(&d, &[Handle(0), Handle(u64::MAX)]);
|
||||
}
|
||||
|
||||
/// Fit-point vertex constraints retain a known freedom count because they do not reconstruct the spline.
|
||||
#[test]
|
||||
fn vertex_constraints_on_large_fit_spline_do_not_charge_reconstruction() {
|
||||
let mut d = Drawing::default();
|
||||
let pts: Vec<_> = (0..500).map(|i| Vec2::new(i as f64, (i % 3) as f64)).collect();
|
||||
let mut sp = GSpline::from_control(pts.clone(), 3);
|
||||
sp.fit = pts;
|
||||
let h = put(&mut d, EntityKind::Spline(sp));
|
||||
for i in 0..5 {
|
||||
let p = point(&mut d, (i as f64, (i % 3) as f64));
|
||||
let mut c = con(K::Coincident, &[s(h, Sub::Vertex(i)), w(p)]);
|
||||
c.id = i + 1;
|
||||
d.constraints.push(c);
|
||||
}
|
||||
let report = dof(&d, &[]).unwrap();
|
||||
assert_eq!(report.status, DofStatus::Under);
|
||||
assert_eq!(report.dof, Some(1000));
|
||||
}
|
||||
|
||||
/// Adds a constraint that the stored ellipse can only hold with its axes renumbered: refused,
|
||||
/// with the drawing unchanged.
|
||||
fn refused_on_minor_axis_end(p: (f64, f64)) {
|
||||
let mut d = Drawing::default();
|
||||
let e = ellipse(&mut d, (0.0, 0.0), (4.0, 0.0), 0.5, 0.0, std::f64::consts::TAU);
|
||||
ok(&mut d, con(K::Fix, &[s(e, Sub::Center)]));
|
||||
let p = point(&mut d, p);
|
||||
ok(&mut d, con(K::Fix, &[w(p)]));
|
||||
let before = d.clone();
|
||||
let r = add(&mut d, con(K::Coincident, &[s(e, Sub::Vertex(1)), w(p)]), &SolveOptions::default());
|
||||
assert!(matches!(r, Err(SolveError::Conflict { .. })), "{r:?}, ellipse {:?}", ell(&d, e));
|
||||
assert_eq!(d, before);
|
||||
}
|
||||
|
||||
/// Pulling a minor axis end past the major axis length would need a ratio above 1, which the
|
||||
/// stored ellipse holds only by swapping its axes (and so renumbering their ends).
|
||||
#[test]
|
||||
fn minor_axis_end_pulled_past_the_major_axis_is_refused() {
|
||||
refused_on_minor_axis_end((0.0, 6.0));
|
||||
}
|
||||
|
||||
/// Pulling a minor axis end through the centre would need a negative ratio, which the stored
|
||||
/// ellipse cannot hold: storing its absolute value would mirror the minor axis end.
|
||||
#[test]
|
||||
fn minor_axis_end_pulled_through_the_centre_is_refused() {
|
||||
refused_on_minor_axis_end((0.0, -1.0));
|
||||
}
|
||||
|
||||
/// A circle-like ellipse whose solution makes the minor axis the longer one: stored with its axes
|
||||
/// swapped, which constraints that name no axis end by index do not notice.
|
||||
fn round_ellipse(d: &mut Drawing, start: f64, end: f64) -> Handle {
|
||||
let e = ellipse(d, (0.0, 0.0), (4.0, 0.0), 1.0, start, end);
|
||||
ok(d, con(K::Fix, &[s(e, Sub::Center)]));
|
||||
e
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tangent_that_swaps_a_round_ellipse_axes_is_kept() {
|
||||
let mut d = Drawing::default();
|
||||
let e = round_ellipse(&mut d, 0.0, std::f64::consts::TAU);
|
||||
let l = line(&mut d, (-10.0, 4.5), (10.0, 4.5));
|
||||
ok(&mut d, con(K::Fix, &[w(l)]));
|
||||
ok(&mut d, con(K::Tangent, &[w(l), w(e)]));
|
||||
assert!(ell(&d, e).ratio <= 1.0);
|
||||
assert!(all_satisfied(&d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_on_curve_that_swaps_a_round_ellipse_axes_is_kept() {
|
||||
let mut d = Drawing::default();
|
||||
let e = round_ellipse(&mut d, 0.0, std::f64::consts::TAU);
|
||||
let p = point(&mut d, (0.0, 4.5));
|
||||
ok(&mut d, con(K::Fix, &[w(p)]));
|
||||
ok(&mut d, con(K::Coincident, &[w(p), w(e)]));
|
||||
assert!(ell(&d, e).ratio <= 1.0);
|
||||
assert!(all_satisfied(&d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn elliptical_arc_start_that_swaps_its_axes_is_kept() {
|
||||
let mut d = Drawing::default();
|
||||
let e = round_ellipse(&mut d, std::f64::consts::FRAC_PI_2, std::f64::consts::PI);
|
||||
let p = point(&mut d, (0.0, 4.5));
|
||||
ok(&mut d, con(K::Fix, &[w(p)]));
|
||||
ok(&mut d, con(K::Coincident, &[s(e, Sub::Start), w(p)]));
|
||||
assert!(ell(&d, e).ratio <= 1.0);
|
||||
assert!(all_satisfied(&d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tangent_circle_that_swaps_a_round_ellipse_axes_is_kept() {
|
||||
let mut d = Drawing::default();
|
||||
let e = round_ellipse(&mut d, 0.0, std::f64::consts::TAU);
|
||||
let c = circle(&mut d, (0.0, 6.0), 1.5);
|
||||
ok(&mut d, con(K::Fix, &[w(c)]));
|
||||
ok(&mut d, con(K::Tangent, &[w(c), w(e)]));
|
||||
assert!(ell(&d, e).ratio <= 1.0);
|
||||
assert!(all_satisfied(&d));
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
//! Annotative scaling.
|
||||
//!
|
||||
//! An annotation scale ("1:50") says how many drawing units one paper unit stands for. The
|
||||
//! drawing keeps a list of them (SCALELISTEDIT) and a current one (`CANNOSCALE`). An annotative
|
||||
//! object (text, MText, dimension, multileader, hatch, block reference) stores its size as it
|
||||
//! should measure on paper and the set of scales it supports ([`Common::anno_scales`]); in a view
|
||||
//! it draws at paper size × that view's annotation scale, and only in views whose scale it
|
||||
//! supports unless `ANNOALLVISIBLE` is 1. Model space is seen at `CANNOSCALE`, a layout viewport
|
||||
//! at its viewport scale, and paper space itself at 1:1.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Common, Drawing, Entity, EntityKind};
|
||||
|
||||
/// A named annotation scale: `paper_units` on paper show `drawing_units` of the drawing.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AnnoScale {
|
||||
pub name: String,
|
||||
pub paper_units: f64,
|
||||
pub drawing_units: f64,
|
||||
}
|
||||
|
||||
/// The most scales a drawing's list or one object holds (hostile files and parameters).
|
||||
pub const MAX_SCALES: usize = 1000;
|
||||
|
||||
impl AnnoScale {
|
||||
pub fn new(name: &str, paper_units: f64, drawing_units: f64) -> Self {
|
||||
AnnoScale { name: name.to_string(), paper_units, drawing_units }
|
||||
}
|
||||
/// The 1:1 scale.
|
||||
pub fn unit() -> Self {
|
||||
AnnoScale::new("1:1", 1.0, 1.0)
|
||||
}
|
||||
/// Both units finite and positive (and their ratio usable).
|
||||
pub fn is_valid(&self) -> bool {
|
||||
let ok = |v: f64| v.is_finite() && v > 0.0;
|
||||
ok(self.paper_units) && ok(self.drawing_units) && ok(self.drawing_units / self.paper_units)
|
||||
}
|
||||
/// Drawing units per paper unit (1:50 → 50): what an annotative object's paper size is
|
||||
/// multiplied by. 1 for an invalid scale.
|
||||
pub fn factor(&self) -> f64 {
|
||||
if self.is_valid() { self.drawing_units / self.paper_units } else { 1.0 }
|
||||
}
|
||||
/// Whether this scale has the ratio `factor` (drawing units per paper unit).
|
||||
pub fn has_factor(&self, factor: f64) -> bool {
|
||||
same_factor(self.factor(), factor)
|
||||
}
|
||||
}
|
||||
|
||||
/// Two scale factors are the same ratio (relative tolerance).
|
||||
pub fn same_factor(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1e-12)
|
||||
}
|
||||
|
||||
/// The default metric scale list.
|
||||
pub fn metric_scales() -> Vec<AnnoScale> {
|
||||
let mut v: Vec<AnnoScale> =
|
||||
[1.0, 2.0, 4.0, 5.0, 8.0, 10.0, 16.0, 20.0, 30.0, 40.0, 50.0, 100.0].iter().map(|n| AnnoScale::new(&format!("1:{n}"), 1.0, *n)).collect();
|
||||
v.extend([2.0, 4.0, 8.0, 10.0, 100.0].iter().map(|n| AnnoScale::new(&format!("{n}:1"), *n, 1.0)));
|
||||
v
|
||||
}
|
||||
|
||||
/// The default imperial scale list: the metric ratios, then the architectural inch-to-foot
|
||||
/// scales.
|
||||
pub fn imperial_scales() -> Vec<AnnoScale> {
|
||||
let mut v = metric_scales();
|
||||
let arch: [(&str, f64); 15] = [
|
||||
("1/128\" = 1'-0\"", 1.0 / 128.0),
|
||||
("1/64\" = 1'-0\"", 1.0 / 64.0),
|
||||
("1/32\" = 1'-0\"", 1.0 / 32.0),
|
||||
("1/16\" = 1'-0\"", 1.0 / 16.0),
|
||||
("3/32\" = 1'-0\"", 3.0 / 32.0),
|
||||
("1/8\" = 1'-0\"", 1.0 / 8.0),
|
||||
("3/16\" = 1'-0\"", 3.0 / 16.0),
|
||||
("1/4\" = 1'-0\"", 0.25),
|
||||
("3/8\" = 1'-0\"", 0.375),
|
||||
("1/2\" = 1'-0\"", 0.5),
|
||||
("3/4\" = 1'-0\"", 0.75),
|
||||
("1\" = 1'-0\"", 1.0),
|
||||
("1-1/2\" = 1'-0\"", 1.5),
|
||||
("3\" = 1'-0\"", 3.0),
|
||||
("6\" = 1'-0\"", 6.0),
|
||||
];
|
||||
v.extend(arch.iter().map(|(n, p)| AnnoScale::new(n, *p, 12.0)));
|
||||
v.push(AnnoScale::new("1'-0\" = 1'-0\"", 12.0, 12.0));
|
||||
v
|
||||
}
|
||||
|
||||
/// Kinds that can be annotative.
|
||||
pub fn can_be_annotative(k: &EntityKind) -> bool {
|
||||
matches!(
|
||||
k,
|
||||
EntityKind::Text(_) | EntityKind::MText(_) | EntityKind::Dimension(_) | EntityKind::MLeader(_) | EntityKind::Hatch(_) | EntityKind::Insert(_)
|
||||
)
|
||||
}
|
||||
|
||||
/// An annotative object: a kind that can be annotative, with at least one supported scale.
|
||||
pub fn is_annotative(e: &Entity) -> bool {
|
||||
!e.common.anno_scales.is_empty() && can_be_annotative(&e.kind)
|
||||
}
|
||||
|
||||
/// Make `e` annotative (supporting `current` when it supports no scale yet) or not. Kinds that
|
||||
/// can't be annotative are left alone.
|
||||
pub fn set_annotative(e: &mut Entity, on: bool, current: &AnnoScale) {
|
||||
if !on {
|
||||
e.common.anno_scales.clear();
|
||||
} else if can_be_annotative(&e.kind) && e.common.anno_scales.is_empty() {
|
||||
e.common.anno_scales.push(current.clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// How an object draws in a view.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum AnnoDraw {
|
||||
/// Not annotative: as stored.
|
||||
Plain,
|
||||
/// Annotative, at this factor (drawing units per paper unit).
|
||||
Scaled(f64),
|
||||
/// Annotative and not supporting the view's scale, with `ANNOALLVISIBLE` 0.
|
||||
Hidden,
|
||||
}
|
||||
|
||||
impl Common {
|
||||
/// Whether these properties list a scale with the ratio `factor`.
|
||||
pub fn supports_factor(&self, factor: f64) -> bool {
|
||||
self.anno_scales.iter().any(|s| s.has_factor(factor))
|
||||
}
|
||||
/// Whether these properties list the scale named `name`.
|
||||
pub fn supports_scale(&self, name: &str) -> bool {
|
||||
self.anno_scales.iter().any(|s| s.name.eq_ignore_ascii_case(name))
|
||||
}
|
||||
}
|
||||
|
||||
/// How `e` draws in a view whose annotation scale is `view` (drawing units per paper unit). An
|
||||
/// object that doesn't support the view's scale shows at its first scale when `all_visible`.
|
||||
pub fn anno_draw(e: &Entity, view: f64, all_visible: bool) -> AnnoDraw {
|
||||
if !is_annotative(e) {
|
||||
return AnnoDraw::Plain;
|
||||
}
|
||||
if e.common.supports_factor(view) {
|
||||
return AnnoDraw::Scaled(if view.is_finite() && view > 0.0 { view } else { 1.0 });
|
||||
}
|
||||
match e.common.anno_scales.first() {
|
||||
Some(s) if all_visible => AnnoDraw::Scaled(s.factor()),
|
||||
_ => AnnoDraw::Hidden,
|
||||
}
|
||||
}
|
||||
|
||||
/// `e` as it draws at annotation scale `f`: its paper sizes (text height and width, dimension
|
||||
/// overall scale, multileader arrow, landing and content, hatch pattern scale, block scale and
|
||||
/// attributes) multiplied by `f`. A dimension is drawn from its definition at that scale, not
|
||||
/// from a stored geometry block. Other kinds come back unchanged.
|
||||
pub fn anno_scaled(e: &Entity, f: f64) -> Entity {
|
||||
let f = if f.is_finite() && f > 0.0 { f } else { 1.0 };
|
||||
let mut out = e.clone();
|
||||
match &mut out.kind {
|
||||
EntityKind::Text(t) => t.height *= f,
|
||||
EntityKind::MText(t) => {
|
||||
t.height *= f;
|
||||
t.width *= f;
|
||||
if let Some(c) = &mut t.columns {
|
||||
c.scale(f);
|
||||
}
|
||||
}
|
||||
EntityKind::Dimension(dm) => {
|
||||
dm.overrides.insert("scale".into(), serde_json::json!(f));
|
||||
dm.block = None;
|
||||
}
|
||||
EntityKind::MLeader(m) => {
|
||||
let land = m.landing;
|
||||
m.arrow_size *= f;
|
||||
m.dogleg *= f;
|
||||
if let Some(t) = &mut m.text {
|
||||
t.insert = land + (t.insert - land) * f;
|
||||
t.height *= f;
|
||||
t.width *= f;
|
||||
if let Some(c) = &mut t.columns {
|
||||
c.scale(f);
|
||||
}
|
||||
}
|
||||
if let Some(ins) = &mut m.block {
|
||||
ins.insert = land + (ins.insert - land) * f;
|
||||
ins.scale = ins.scale * f;
|
||||
}
|
||||
}
|
||||
EntityKind::Hatch(h) if !h.solid => h.scale *= f,
|
||||
EntityKind::Insert(ins) => {
|
||||
let o = ins.insert;
|
||||
ins.scale = ins.scale * f;
|
||||
ins.col_spacing *= f;
|
||||
ins.row_spacing *= f;
|
||||
for a in &mut ins.attribs {
|
||||
a.text.insert = o + (a.text.insert - o) * f;
|
||||
a.text.align_pt = a.text.align_pt.map(|p| o + (p - o) * f);
|
||||
a.text.height *= f;
|
||||
if let Some(ml) = &mut a.multiline {
|
||||
ml.mtext.insert = o + (ml.mtext.insert - o) * f;
|
||||
ml.mtext.height *= f;
|
||||
ml.mtext.width *= f;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl Drawing {
|
||||
/// The scale named `name` in the drawing's scale list (case-insensitive).
|
||||
pub fn anno_scale(&self, name: &str) -> Option<&AnnoScale> {
|
||||
let n = name.trim();
|
||||
self.scales.iter().find(|s| s.name.eq_ignore_ascii_case(n))
|
||||
}
|
||||
/// The current annotation scale (`CANNOSCALE`); 1:1 when it names no scale of the list.
|
||||
pub fn current_anno_scale(&self) -> AnnoScale {
|
||||
let name = self.header.str("CANNOSCALE", "1:1");
|
||||
self.anno_scale(&name).filter(|s| s.is_valid()).cloned().unwrap_or_else(AnnoScale::unit)
|
||||
}
|
||||
/// Whether annotative objects that don't support the view's scale show (`ANNOALLVISIBLE`).
|
||||
pub fn anno_all_visible(&self) -> bool {
|
||||
self.header.i64("ANNOALLVISIBLE", 1) != 0
|
||||
}
|
||||
/// How top-level object `e` draws outside a viewport: in a layout's paper space at 1:1
|
||||
/// (paper space shows every annotative object), elsewhere (model space, or an object not in
|
||||
/// the drawing yet) as [`anno_draw`] at the current annotation scale.
|
||||
pub fn anno_draw(&self, e: &Entity) -> AnnoDraw {
|
||||
if !is_annotative(e) {
|
||||
return AnnoDraw::Plain;
|
||||
}
|
||||
if !self.model.contains(e.handle) && self.layouts.iter().any(|l| l.entities.contains(e.handle)) {
|
||||
return AnnoDraw::Scaled(1.0);
|
||||
}
|
||||
anno_draw(e, self.current_anno_scale().factor(), self.anno_all_visible())
|
||||
}
|
||||
/// Whether new objects of this kind are annotative: text and MText with an annotative text
|
||||
/// style, dimensions with an annotative dimension style, multileaders with an annotative
|
||||
/// multileader style, hatches while `HPANNOTATIVE` is 1, references to annotative blocks.
|
||||
pub fn annotative_by_style(&self, k: &EntityKind) -> bool {
|
||||
match k {
|
||||
EntityKind::Text(t) => self.text_style(&t.style).is_some_and(|s| s.annotative),
|
||||
EntityKind::MText(t) => self.text_style(&t.style).is_some_and(|s| s.annotative),
|
||||
EntityKind::Dimension(dm) => self.dim_style(&dm.style).is_some_and(|s| s.annotative),
|
||||
EntityKind::MLeader(m) => self.mleader_styles.iter().find(|s| s.name.eq_ignore_ascii_case(&m.style)).is_some_and(|s| s.annotative),
|
||||
EntityKind::Hatch(_) => self.header.i64("HPANNOTATIVE", 0) != 0,
|
||||
EntityKind::Insert(ins) => self.block(&ins.block).is_some_and(|b| b.annotative),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,347 +0,0 @@
|
||||
//! Associative arrays: the parameters an array keeps, and where they place its items.
|
||||
//!
|
||||
//! An associative array is a block reference to an anonymous block (`*U…`, the *array block*)
|
||||
//! that holds one block reference per item. Each item refers to the array's *source block*
|
||||
//! (another anonymous block holding the source objects) or, for items replaced with ARRAYEDIT,
|
||||
//! to another block. Other programs see an ordinary block reference; CADCraft keeps the
|
||||
//! parameters below on the array block ([`Block::array`](crate::Block)) so the items can be
|
||||
//! regenerated when the array is edited.
|
||||
//!
|
||||
//! All positions are in the array block's coordinates: its origin is the array's base point,
|
||||
//! which is where the array's block reference is inserted.
|
||||
|
||||
use cadcraft_geom::{Vec2, Vec3};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{Common, Drawing, Entity, EntityKind, Insert, Lineweight};
|
||||
|
||||
/// Most items one array may have (items × rows × levels), whatever MAXARRAY says.
|
||||
pub const MAX_ARRAY_ITEMS: u64 = 10_000_000;
|
||||
|
||||
fn one() -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
/// The parameters of an associative array.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ArrayParams {
|
||||
/// The source block the items refer to.
|
||||
pub source: String,
|
||||
pub kind: ArrayKind,
|
||||
/// Levels: copies of the whole array along Z.
|
||||
#[serde(default = "one")]
|
||||
pub levels: u32,
|
||||
#[serde(default)]
|
||||
pub level_spacing: f64,
|
||||
/// Items that refer to another block than the source (ARRAYEDIT REPlace).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub replaced: Vec<ReplacedItem>,
|
||||
/// The source objects are out for editing (ARRAYEDIT Source, until ARRAYCLOSE). Not saved:
|
||||
/// a drawing saved meanwhile keeps the objects as separate objects.
|
||||
#[serde(skip)]
|
||||
pub editing: Option<SourceEdit>,
|
||||
}
|
||||
|
||||
/// ARRAYEDIT Source in progress: the array's source objects placed in the drawing for editing.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct SourceEdit {
|
||||
/// The array (block reference) being edited.
|
||||
pub array: crate::Handle,
|
||||
/// The item the objects were placed at: (i, j, level).
|
||||
pub item: (u32, u32, u32),
|
||||
/// The objects placed for editing.
|
||||
pub objects: Vec<crate::Handle>,
|
||||
/// Objects with this handle or above were drawn while editing; they join the source too.
|
||||
pub first_new: u64,
|
||||
}
|
||||
|
||||
/// Rectangular or polar placement.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum ArrayKind {
|
||||
Rect(RectArray),
|
||||
Polar(PolarArray),
|
||||
}
|
||||
|
||||
/// A rectangular array: columns along the axis angle, rows across it.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RectArray {
|
||||
pub cols: u32,
|
||||
pub rows: u32,
|
||||
pub col_spacing: f64,
|
||||
pub row_spacing: f64,
|
||||
/// Direction of the rows (the column axis), radians.
|
||||
#[serde(default)]
|
||||
pub axis_angle: f64,
|
||||
/// Elevation added per row.
|
||||
#[serde(default)]
|
||||
pub row_elevation: f64,
|
||||
}
|
||||
|
||||
/// A polar array: items around a centre, rows as rings further out.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PolarArray {
|
||||
pub center: Vec2,
|
||||
pub items: u32,
|
||||
/// Angle to fill, degrees (positive = counterclockwise, ±360 = a full circle).
|
||||
pub fill: f64,
|
||||
/// Items are rotated as they go around (else they keep their orientation).
|
||||
pub rotate: bool,
|
||||
#[serde(default = "one")]
|
||||
pub rows: u32,
|
||||
/// Distance between rings, outwards from the centre.
|
||||
#[serde(default)]
|
||||
pub row_spacing: f64,
|
||||
#[serde(default)]
|
||||
pub row_elevation: f64,
|
||||
}
|
||||
|
||||
/// An item that refers to another block. `i` is the column (rectangular) or the item around the
|
||||
/// circle (polar), `j` the row.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ReplacedItem {
|
||||
pub i: u32,
|
||||
pub j: u32,
|
||||
#[serde(default)]
|
||||
pub level: u32,
|
||||
pub block: String,
|
||||
}
|
||||
|
||||
/// Where one item goes: its block reference's insertion point (the image of the array's base
|
||||
/// point) and rotation (radians).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ArrayItem {
|
||||
pub i: u32,
|
||||
pub j: u32,
|
||||
pub level: u32,
|
||||
pub insert: Vec3,
|
||||
pub rotation: f64,
|
||||
}
|
||||
|
||||
impl ArrayParams {
|
||||
/// Items along the first direction (columns or items around) and rows.
|
||||
pub fn grid(&self) -> (u32, u32) {
|
||||
match &self.kind {
|
||||
ArrayKind::Rect(r) => (r.cols, r.rows),
|
||||
ArrayKind::Polar(p) => (p.items, p.rows),
|
||||
}
|
||||
}
|
||||
|
||||
/// The number of items, saturating.
|
||||
pub fn item_count(&self) -> u64 {
|
||||
let (a, b) = self.grid();
|
||||
u64::from(a).saturating_mul(u64::from(b)).saturating_mul(u64::from(self.levels))
|
||||
}
|
||||
|
||||
/// Whether the parameters are usable: at least one item in every direction, at most
|
||||
/// [`MAX_ARRAY_ITEMS`] in all, and finite numbers. Parameters read from files are checked
|
||||
/// with this before they are kept.
|
||||
pub fn is_valid(&self) -> bool {
|
||||
let (a, b) = self.grid();
|
||||
let nums = match &self.kind {
|
||||
ArrayKind::Rect(r) => [r.col_spacing, r.row_spacing, r.axis_angle, r.row_elevation, 0.0, 0.0],
|
||||
ArrayKind::Polar(p) => [p.center.x, p.center.y, p.fill, p.row_spacing, p.row_elevation, 0.0],
|
||||
};
|
||||
a >= 1
|
||||
&& b >= 1
|
||||
&& self.levels >= 1
|
||||
&& self.item_count() <= MAX_ARRAY_ITEMS
|
||||
&& !self.source.trim().is_empty()
|
||||
&& nums.iter().chain(std::iter::once(&self.level_spacing)).all(|v| v.is_finite())
|
||||
&& self.replaced.len() as u64 <= MAX_ARRAY_ITEMS
|
||||
}
|
||||
|
||||
/// Where every item goes, row by row and level by level; `None` when the parameters are not
|
||||
/// [valid](Self::is_valid).
|
||||
pub fn items(&self) -> Option<Vec<ArrayItem>> {
|
||||
if !self.is_valid() {
|
||||
return None;
|
||||
}
|
||||
let n = usize::try_from(self.item_count()).ok()?;
|
||||
let mut out = Vec::with_capacity(n);
|
||||
for level in 0..self.levels {
|
||||
let lz = self.level_spacing * f64::from(level);
|
||||
match &self.kind {
|
||||
ArrayKind::Rect(r) => {
|
||||
let (ux, uy) = (Vec2::from_angle(r.axis_angle), Vec2::from_angle(r.axis_angle).perp());
|
||||
for j in 0..r.rows {
|
||||
for i in 0..r.cols {
|
||||
let p = ux * (r.col_spacing * f64::from(i)) + uy * (r.row_spacing * f64::from(j));
|
||||
out.push(ArrayItem { i, j, level, insert: p.to3(lz + r.row_elevation * f64::from(j)), rotation: 0.0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
ArrayKind::Polar(p) => {
|
||||
let total = p.fill.to_radians();
|
||||
let full = (total.abs() - std::f64::consts::TAU).abs() < 1e-9;
|
||||
let step = if full { total / f64::from(p.items.max(1)) } else { total / f64::from(p.items.saturating_sub(1).max(1)) };
|
||||
// Rows move the items outwards: along the line from the centre to the base point.
|
||||
let out_dir = {
|
||||
let d = (Vec2::ZERO - p.center).normalized();
|
||||
if d == Vec2::ZERO { Vec2::X } else { d }
|
||||
};
|
||||
for j in 0..p.rows {
|
||||
let o = out_dir * (p.row_spacing * f64::from(j));
|
||||
for i in 0..p.items {
|
||||
let a = step * f64::from(i);
|
||||
let at = o.rotate_about(p.center, a);
|
||||
let rotation = if p.rotate { a } else { 0.0 };
|
||||
out.push(ArrayItem { i, j, level, insert: at.to3(lz + p.row_elevation * f64::from(j)), rotation });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The block an item refers to: a replacement, else the source.
|
||||
pub fn block_for(&self, i: u32, j: u32, level: u32) -> &str {
|
||||
self.replaced.iter().rev().find(|r| (r.i, r.j, r.level) == (i, j, level)).map_or(self.source.as_str(), |r| r.block.as_str())
|
||||
}
|
||||
|
||||
/// The block references that make up the array block: one per item, on layer 0 with ByBlock
|
||||
/// properties so they take the array's. Handles are allocated from `d`. `None` when the
|
||||
/// parameters are not [valid](Self::is_valid).
|
||||
pub fn item_entities(&self, d: &mut Drawing) -> Option<Vec<Entity>> {
|
||||
let items = self.items()?;
|
||||
let common = Common {
|
||||
layer: "0".into(),
|
||||
color: cadcraft_color::Color::ByBlock,
|
||||
linetype: "ByBlock".into(),
|
||||
lineweight: Lineweight::ByBlock,
|
||||
..Common::default()
|
||||
};
|
||||
let mut out = Vec::with_capacity(items.len());
|
||||
for it in items {
|
||||
let ins = Insert {
|
||||
block: self.block_for(it.i, it.j, it.level).to_string(),
|
||||
insert: it.insert,
|
||||
scale: Vec3::new(1.0, 1.0, 1.0),
|
||||
rotation: it.rotation,
|
||||
attribs: Vec::new(),
|
||||
cols: 1,
|
||||
rows: 1,
|
||||
col_spacing: 0.0,
|
||||
row_spacing: 0.0,
|
||||
};
|
||||
out.push(Entity { handle: d.new_handle(), common: common.clone(), kind: EntityKind::Insert(ins) });
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Take the block names from the array block's items `ents` (in the order [`items`]
|
||||
/// lists them): the source block is whatever the items that are not replaced refer to, so
|
||||
/// the parameters follow blocks renamed on paste, on INSERT of a file or by other programs
|
||||
/// (which may rename anonymous blocks). `false` when the items don't match the parameters
|
||||
/// (another program changed the block), so the array should be treated as a plain block.
|
||||
///
|
||||
/// [`items`]: Self::items
|
||||
pub fn sync_names<'a>(&mut self, ents: impl Iterator<Item = &'a Entity>) -> bool {
|
||||
let n = self.item_count();
|
||||
let ents: Vec<&Entity> = ents.take(usize::try_from(n).unwrap_or(usize::MAX).saturating_add(1)).collect();
|
||||
if ents.len() as u64 != n {
|
||||
return false;
|
||||
}
|
||||
let Some(items) = self.items() else { return false };
|
||||
// Index the replacement entries by placement so each item is matched in O(1) instead of
|
||||
// scanning the whole `replaced` vector per item, which is quadratic when both the items
|
||||
// and the replacements are large (CWE-407). Last entry wins, as `.rev().find` did.
|
||||
let mut last_replaced: std::collections::HashMap<(u32, u32, u32), usize> = std::collections::HashMap::with_capacity(self.replaced.len());
|
||||
for (idx, r) in self.replaced.iter().enumerate() {
|
||||
last_replaced.insert((r.i, r.j, r.level), idx);
|
||||
}
|
||||
let mut source: Option<&str> = None;
|
||||
for (it, e) in items.iter().zip(&ents) {
|
||||
let EntityKind::Insert(ins) = &e.kind else { return false };
|
||||
match last_replaced.get(&(it.i, it.j, it.level)).and_then(|&idx| self.replaced.get_mut(idx)) {
|
||||
Some(r) => r.block.clone_from(&ins.block),
|
||||
None => match source {
|
||||
None => source = Some(&ins.block),
|
||||
Some(s) if s.eq_ignore_ascii_case(&ins.block) => {}
|
||||
Some(_) => return false,
|
||||
},
|
||||
}
|
||||
}
|
||||
if let Some(s) = source {
|
||||
self.source = s.to_string();
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl Drawing {
|
||||
/// The parameters of `e` when it is an associative array (a block reference to an array
|
||||
/// block whose items match its parameters), with the block names its items use.
|
||||
pub fn array_params(&self, e: &Entity) -> Option<ArrayParams> {
|
||||
let EntityKind::Insert(i) = &e.kind else { return None };
|
||||
let b = self.block(&i.block)?;
|
||||
let mut p = b.array.clone()?;
|
||||
p.sync_names(b.entities.iter().map(|e| e.as_ref())).then_some(p)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn rect(cols: u32, rows: u32) -> ArrayParams {
|
||||
ArrayParams {
|
||||
source: "*U1".into(),
|
||||
kind: ArrayKind::Rect(RectArray { cols, rows, col_spacing: 2.0, row_spacing: 3.0, axis_angle: 0.0, row_elevation: 0.0 }),
|
||||
levels: 2,
|
||||
level_spacing: 5.0,
|
||||
replaced: Vec::new(),
|
||||
editing: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rect_items_go_column_row_level() {
|
||||
let items = rect(3, 2).items().unwrap_or_default();
|
||||
assert_eq!(items.len(), 12);
|
||||
let last = items.last().copied().map(|i| (i.i, i.j, i.level, i.insert));
|
||||
assert_eq!(last, Some((2, 1, 1, Vec3::new(4.0, 3.0, 5.0))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polar_rows_go_outwards() {
|
||||
let p = ArrayParams {
|
||||
source: "*U1".into(),
|
||||
kind: ArrayKind::Polar(PolarArray {
|
||||
center: Vec2::new(-10.0, 0.0),
|
||||
items: 4,
|
||||
fill: 360.0,
|
||||
rotate: true,
|
||||
rows: 2,
|
||||
row_spacing: 1.0,
|
||||
row_elevation: 0.0,
|
||||
}),
|
||||
levels: 1,
|
||||
level_spacing: 0.0,
|
||||
replaced: Vec::new(),
|
||||
editing: None,
|
||||
};
|
||||
let items = p.items().unwrap_or_default();
|
||||
assert_eq!(items.len(), 8);
|
||||
// Item 1 of row 0 is a quarter turn round: base (0,0) → (-10,10).
|
||||
assert!(items[1].insert.xy().near(Vec2::new(-10.0, 10.0), 1e-9));
|
||||
// Row 1 starts one unit further out.
|
||||
assert!(items[4].insert.xy().near(Vec2::new(1.0, 0.0), 1e-9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_params_are_invalid() {
|
||||
let mut p = rect(u32::MAX, u32::MAX);
|
||||
assert!(!p.is_valid() && p.items().is_none());
|
||||
p = rect(0, 1);
|
||||
assert!(!p.is_valid());
|
||||
p = rect(2, 2);
|
||||
p.level_spacing = f64::NAN;
|
||||
assert!(!p.is_valid());
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ impl Sub {
|
||||
"mid" | "midpoint" | "m" => Sub::Mid,
|
||||
"center" | "centre" | "c" => Sub::Center,
|
||||
_ => {
|
||||
for (p, vertex) in [("vertex", true), ("segment", false), ("seg", false), ("s", false), ("v", true)] {
|
||||
for (p, vertex) in [("vertex", true), ("segment", false), ("seg", false), ("v", true)] {
|
||||
if let Some(n) = l.strip_prefix(p) {
|
||||
let i: u32 = n.trim().parse().ok()?;
|
||||
return Some(if vertex { Sub::Vertex(i) } else { Sub::Segment(i) });
|
||||
@@ -99,8 +99,7 @@ pub enum ConstraintKind {
|
||||
Horizontal,
|
||||
Vertical,
|
||||
Tangent,
|
||||
/// G2 continuity (common point, tangent and curvature) with a spline; tangency between
|
||||
/// other curves.
|
||||
/// G2 continuity; solved as tangency for now.
|
||||
Smooth,
|
||||
Symmetric,
|
||||
Equal,
|
||||
@@ -208,7 +207,7 @@ impl Default for ParametricSettings {
|
||||
angle_tolerance: 1.0,
|
||||
auto_types: ["Coincident", "Collinear", "Parallel", "Perpendicular", "Horizontal", "Vertical", "Tangent", "Concentric", "Equal"]
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.map(|s| s.to_string())
|
||||
.collect(),
|
||||
bars_visible: true,
|
||||
bar_exceptions: Vec::new(),
|
||||
|
||||
@@ -1,567 +0,0 @@
|
||||
//! Dynamic blocks made in another program, read from the data CADCraft keeps for them.
|
||||
//!
|
||||
//! A dynamic block's behaviour lives in objects CADCraft does not write itself and keeps as
|
||||
//! [`Foreign`](crate::foreign::Foreign) data: the block's BLOCK_RECORD has an extension dictionary
|
||||
//! whose `ACAD_ENHANCEDBLOCK` entry is an evaluation graph (`ACAD_EVALUATION_GRAPH`), and the
|
||||
//! graph lists the block's *parameters* (a distance, a point, visibility states, a lookup…) and
|
||||
//! *actions* (what changing a parameter does to the block's objects: stretch, move, look up…).
|
||||
//! [`dynamic_block`](crate::dynblock::dynamic_block) reads them into the model below without changing the kept data, so a drawing
|
||||
//! saved again still carries them exactly as read.
|
||||
//!
|
||||
//! Every count and index here comes from the file: they are capped by the data actually present
|
||||
//! and unknown or damaged objects are skipped, never trusted.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use cadcraft_geom::{Vec2, Vec3};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::foreign::ForeignObject;
|
||||
use crate::{Drawing, Handle, RawTag};
|
||||
|
||||
mod apply;
|
||||
mod grips;
|
||||
mod reference;
|
||||
pub use apply::{drag_grip, set_property};
|
||||
pub use grips::{DynGrip, GripPart, GripShape, grips};
|
||||
pub use reference::{CustomProperty, Reference, Value, custom_properties, reference};
|
||||
|
||||
/// Most objects one evaluation graph is read with.
|
||||
const MAX_NODES: usize = 10_000;
|
||||
|
||||
/// Handles as hex strings in JSON, as commands report them.
|
||||
fn hex<S: serde::Serializer>(h: &Handle, s: S) -> Result<S::Ok, S::Error> {
|
||||
s.serialize_str(&h.hex())
|
||||
}
|
||||
fn hexes<S: serde::Serializer>(hs: &[Handle], s: S) -> Result<S::Ok, S::Error> {
|
||||
s.collect_seq(hs.iter().map(|h| h.hex()))
|
||||
}
|
||||
fn hex_vertices<S: serde::Serializer>(v: &[(Handle, Vec<i32>)], s: S) -> Result<S::Ok, S::Error> {
|
||||
s.collect_seq(v.iter().map(|(h, i)| (h.hex(), i)))
|
||||
}
|
||||
|
||||
/// A dynamic block's parameters and actions.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DynamicBlock {
|
||||
pub parameters: Vec<Parameter>,
|
||||
pub actions: Vec<Action>,
|
||||
/// Graph members of types not modelled here (grips, constraints…), by DXF type.
|
||||
pub other: Vec<String>,
|
||||
/// The grip objects by graph id (parameters name their grips by id; visibility states list
|
||||
/// the grips shown in them by object).
|
||||
#[serde(skip)]
|
||||
pub grip_objects: Vec<(i32, Handle)>,
|
||||
}
|
||||
|
||||
/// One parameter: a property a reference to the block can change.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Parameter {
|
||||
/// Its id in the evaluation graph (actions and lookups refer to parameters by it).
|
||||
pub node: i32,
|
||||
#[serde(serialize_with = "hex")]
|
||||
pub handle: Handle,
|
||||
/// The name the block editor shows ("Linear", "Visibility State").
|
||||
pub element: String,
|
||||
/// The parameter's properties appear in a reference's Properties palette.
|
||||
pub show_properties: bool,
|
||||
pub kind: ParameterKind,
|
||||
/// Graph ids of its grips: a two-point parameter's base and end grips (0 for none), a
|
||||
/// one-point parameter's grip. A reference's history records grip drags by these.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub grips: Vec<i32>,
|
||||
}
|
||||
|
||||
/// What a parameter is, with its definition geometry in block coordinates.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum ParameterKind {
|
||||
/// A distance between two points (Distance1).
|
||||
Linear {
|
||||
base: Vec3,
|
||||
end: Vec3,
|
||||
label: String,
|
||||
description: String,
|
||||
values: ValueSet,
|
||||
/// The value changes the end point (`false`) or both points from the midpoint (`true`).
|
||||
from_midpoint: bool,
|
||||
},
|
||||
/// A distance and an angle from a base point.
|
||||
Polar { base: Vec3, end: Vec3 },
|
||||
/// Horizontal and vertical distances from a base point.
|
||||
Xy { base: Vec3, end: Vec3 },
|
||||
/// An angle about a base point.
|
||||
Rotation { base: Vec3, end: Vec3 },
|
||||
/// Aligns the reference with nearby geometry.
|
||||
Alignment { base: Vec3, end: Vec3 },
|
||||
/// Mirrors the objects about a reflection line.
|
||||
Flip { base: Vec3, end: Vec3 },
|
||||
/// A position (Position1).
|
||||
Point { at: Vec3, label: String, description: String },
|
||||
/// Named sets of visible objects.
|
||||
Visibility {
|
||||
at: Vec3,
|
||||
label: String,
|
||||
description: String,
|
||||
#[serde(serialize_with = "hexes")]
|
||||
controlled: Vec<Handle>,
|
||||
states: Vec<VisibilityState>,
|
||||
},
|
||||
/// A named value chosen from a lookup table.
|
||||
Lookup { at: Vec3, label: String, description: String },
|
||||
/// Moves the block's base point.
|
||||
BasePoint { at: Vec3 },
|
||||
/// A parameter type not modelled yet.
|
||||
Other { dxf_type: String },
|
||||
}
|
||||
|
||||
impl ParameterKind {
|
||||
/// The property name a reference shows ("Distance1", "Visibility1"), when it has one.
|
||||
pub fn label(&self) -> Option<&str> {
|
||||
match self {
|
||||
ParameterKind::Linear { label, .. }
|
||||
| ParameterKind::Point { label, .. }
|
||||
| ParameterKind::Visibility { label, .. }
|
||||
| ParameterKind::Lookup { label, .. } => Some(label),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One visibility state: its name and the objects shown in it (the parameter's other controlled
|
||||
/// objects are hidden).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VisibilityState {
|
||||
pub name: String,
|
||||
#[serde(serialize_with = "hexes")]
|
||||
pub visible: Vec<Handle>,
|
||||
/// The parameters and actions active in this state (their objects).
|
||||
#[serde(serialize_with = "hexes")]
|
||||
pub active: Vec<Handle>,
|
||||
}
|
||||
|
||||
/// The values a distance or angle may take.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ValueSet {
|
||||
/// Which of the limits below apply (see the methods).
|
||||
pub flags: i32,
|
||||
pub minimum: f64,
|
||||
pub maximum: f64,
|
||||
pub increment: f64,
|
||||
/// The allowed values, for a list.
|
||||
pub list: Vec<f64>,
|
||||
}
|
||||
|
||||
impl ValueSet {
|
||||
pub fn has_minimum(&self) -> bool {
|
||||
self.flags & 1 != 0
|
||||
}
|
||||
pub fn has_maximum(&self) -> bool {
|
||||
self.flags & 2 != 0
|
||||
}
|
||||
pub fn has_increment(&self) -> bool {
|
||||
self.flags & 4 != 0
|
||||
}
|
||||
pub fn is_list(&self) -> bool {
|
||||
self.flags & 8 != 0
|
||||
}
|
||||
}
|
||||
|
||||
/// One action: what changing parameters does to the block's objects.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Action {
|
||||
pub node: i32,
|
||||
#[serde(serialize_with = "hex")]
|
||||
pub handle: Handle,
|
||||
/// Its name in the block editor ("Stretch", "Lookup1").
|
||||
pub name: String,
|
||||
/// Where the block editor shows it.
|
||||
pub at: Vec3,
|
||||
/// The objects it acts on.
|
||||
#[serde(serialize_with = "hexes")]
|
||||
pub selection: Vec<Handle>,
|
||||
/// Graph ids of the parameters it follows, with the parameter value each connection reads
|
||||
/// ("EndXDelta").
|
||||
pub connections: Vec<(i32, String)>,
|
||||
pub kind: ActionKind,
|
||||
}
|
||||
|
||||
/// What an action does.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum ActionKind {
|
||||
/// Moves the points of its objects inside `frame`: a line's or polyline's points inside it
|
||||
/// (the whole of one outside it), any other object whole. `vertices` are the point indices the
|
||||
/// file lists for each object, used only when the frame can't be tested (see `apply`).
|
||||
Stretch {
|
||||
frame: Vec<Vec2>,
|
||||
#[serde(serialize_with = "hex_vertices")]
|
||||
vertices: Vec<(Handle, Vec<i32>)>,
|
||||
multiplier: f64,
|
||||
angle_offset: f64,
|
||||
},
|
||||
/// A table: each row sets the listed parameter values together.
|
||||
Lookup {
|
||||
columns: Vec<LookupColumn>,
|
||||
rows: Vec<Vec<String>>,
|
||||
},
|
||||
/// Moves the objects by the change of the parameter values it follows.
|
||||
Move {
|
||||
multiplier: f64,
|
||||
angle_offset: f64,
|
||||
},
|
||||
Scale,
|
||||
Rotate,
|
||||
Flip,
|
||||
Array,
|
||||
PolarStretch,
|
||||
/// An action type not modelled yet.
|
||||
Other {
|
||||
dxf_type: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// A lookup table column: the parameter value it reads or sets.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LookupColumn {
|
||||
/// The parameter's graph id.
|
||||
pub node: i32,
|
||||
/// The value it stands for ("VisibilityState", "lookupString").
|
||||
pub property: String,
|
||||
/// A lookup property (shown on the reference) rather than an input.
|
||||
pub lookup: bool,
|
||||
/// What the reference shows when no row matches ("Custom").
|
||||
pub unmatched: String,
|
||||
}
|
||||
|
||||
/// The groups of an object split at its subclass markers (100): `(marker, groups after it)`.
|
||||
fn sections(tags: &[RawTag]) -> Vec<(&str, &[RawTag])> {
|
||||
let mut out: Vec<(&str, &[RawTag])> = Vec::new();
|
||||
let mut start = 0;
|
||||
let mut name = "";
|
||||
for (i, t) in tags.iter().enumerate() {
|
||||
if t.code == 100 {
|
||||
out.push((name, tags.get(start..i).unwrap_or_default()));
|
||||
name = t.value.as_str();
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
out.push((name, tags.get(start..).unwrap_or_default()));
|
||||
out
|
||||
}
|
||||
|
||||
/// The groups after subclass marker `marker`, empty when the object has none.
|
||||
fn section<'a>(secs: &[(&str, &'a [RawTag])], marker: &str) -> &'a [RawTag] {
|
||||
secs.iter().find(|(n, _)| n.eq_ignore_ascii_case(marker)).map_or(&[], |(_, t)| t)
|
||||
}
|
||||
|
||||
/// Reads groups in order, as an object writes them.
|
||||
struct Cursor<'a> {
|
||||
tags: &'a [RawTag],
|
||||
at: usize,
|
||||
/// Groups scanned so far. A missing group neither moves the cursor nor ends the loops that
|
||||
/// ask for it, so a hostile record could make nested counts rescan it again and again; past
|
||||
/// `budget` every lookup fails at once and every count is zero.
|
||||
work: usize,
|
||||
budget: usize,
|
||||
}
|
||||
|
||||
impl<'a> Cursor<'a> {
|
||||
fn new(tags: &'a [RawTag]) -> Self {
|
||||
Cursor { tags, at: 0, work: 0, budget: tags.len().saturating_mul(16).saturating_add(1024) }
|
||||
}
|
||||
/// The next group with `code` from here on (the cursor moves past it).
|
||||
fn next(&mut self, code: i32) -> Option<&'a str> {
|
||||
if self.work >= self.budget {
|
||||
return None;
|
||||
}
|
||||
let rest = self.tags.get(self.at..)?;
|
||||
let Some(rel) = rest.iter().position(|t| t.code == code) else {
|
||||
self.work = self.work.saturating_add(rest.len().max(1));
|
||||
return None;
|
||||
};
|
||||
self.work = self.work.saturating_add(rel + 1);
|
||||
self.at += rel + 1;
|
||||
self.tags.get(self.at - 1).map(|t| t.value.as_str())
|
||||
}
|
||||
fn string(&mut self, code: i32) -> String {
|
||||
self.next(code).unwrap_or_default().to_string()
|
||||
}
|
||||
fn int(&mut self, code: i32) -> i32 {
|
||||
self.next(code).and_then(|v| v.trim().parse().ok()).unwrap_or(0)
|
||||
}
|
||||
fn real(&mut self, code: i32) -> f64 {
|
||||
self.next(code).and_then(|v| v.trim().parse::<f64>().ok()).filter(|v| v.is_finite()).unwrap_or(0.0)
|
||||
}
|
||||
/// A count read from the file, capped by the groups left (each item needs at least one).
|
||||
fn count(&mut self, code: i32) -> usize {
|
||||
let n = usize::try_from(self.int(code)).unwrap_or(0).min(self.tags.len().saturating_sub(self.at));
|
||||
if self.work >= self.budget { 0 } else { n }
|
||||
}
|
||||
fn handle(&mut self, code: i32) -> Option<Handle> {
|
||||
self.next(code).and_then(Handle::parse_hex)
|
||||
}
|
||||
fn handles(&mut self, code: i32, n: usize) -> Vec<Handle> {
|
||||
(0..n).filter_map(|_| self.handle(code)).collect()
|
||||
}
|
||||
/// A point written with codes `x`, `x + 10`, `x + 20`.
|
||||
fn point(&mut self, x: i32) -> Vec3 {
|
||||
let px = self.real(x);
|
||||
let py = self.real(x + 10);
|
||||
let at = self.at;
|
||||
// Z is optional: only take it when it is the very next group.
|
||||
let pz = match self.tags.get(at) {
|
||||
Some(t) if t.code == x + 20 => self.real(x + 20),
|
||||
_ => 0.0,
|
||||
};
|
||||
Vec3::new(px, py, pz)
|
||||
}
|
||||
}
|
||||
|
||||
/// The objects `xdict`'s `ACAD_ENHANCEDBLOCK` evaluation graph lists, when the dictionary has one.
|
||||
fn graph_members<'a>(objects: &HashMap<Handle, &'a ForeignObject>, xdict: Handle) -> Option<Vec<&'a ForeignObject>> {
|
||||
let dict = objects.get(&xdict)?;
|
||||
let mut graph = None;
|
||||
let mut tags = dict.tags.iter();
|
||||
while let Some(t) = tags.next() {
|
||||
if t.code == 3 && t.value.eq_ignore_ascii_case("ACAD_ENHANCEDBLOCK") {
|
||||
graph = tags.next().filter(|t| t.code == 350 || t.code == 360).and_then(|t| Handle::parse_hex(&t.value));
|
||||
break;
|
||||
}
|
||||
}
|
||||
let graph = objects.get(&graph?).filter(|o| o.kind.eq_ignore_ascii_case("ACAD_EVALUATION_GRAPH"))?;
|
||||
let members = section(§ions(&graph.tags), "AcDbEvalGraph").iter().filter(|t| t.code == 360).filter_map(|t| Handle::parse_hex(&t.value));
|
||||
// Deduplicate member handles before resolving them: a graph can list the same member many
|
||||
// times, and parsing each occurrence into its own owned parameter multiplies a single large
|
||||
// record into gigabytes of retained allocations (CWE-400).
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
Some(members.take(MAX_NODES).filter(|h| seen.insert(*h)).filter_map(|h| objects.get(&h).copied()).collect())
|
||||
}
|
||||
|
||||
/// The dynamic block data of block `name` (any case; `*Model_Space` for a drawing that is a
|
||||
/// block's own drawing), or `None` when it has none.
|
||||
pub fn dynamic_block(d: &Drawing, name: &str) -> Option<DynamicBlock> {
|
||||
let xdict = d.foreign.records.get(&("BLOCK_RECORD".to_string(), name.trim().to_ascii_uppercase()))?.xdict?;
|
||||
let objects: HashMap<Handle, &ForeignObject> = d.foreign.objects.iter().map(|o| (o.handle, o)).collect();
|
||||
let mut out = DynamicBlock::default();
|
||||
for o in graph_members(&objects, xdict)? {
|
||||
let secs = sections(&o.tags);
|
||||
let node = Cursor::new(section(&secs, "AcDbEvalExpr")).int(90);
|
||||
let element = Cursor::new(section(&secs, "AcDbBlockElement")).string(300);
|
||||
let kind = o.kind.to_ascii_uppercase();
|
||||
if kind.ends_with("PARAMETER") {
|
||||
let show_properties = Cursor::new(section(&secs, "AcDbBlockParameter")).int(280) != 0;
|
||||
let grips = grip_ids(&secs);
|
||||
out.parameters.push(Parameter { node, handle: o.handle, element, show_properties, kind: parameter(&kind, &secs), grips });
|
||||
} else if kind.ends_with("ACTION") {
|
||||
out.actions.push(action(o.handle, node, element, &kind, &secs));
|
||||
} else {
|
||||
if kind.ends_with("GRIP") {
|
||||
out.grip_objects.push((node, o.handle));
|
||||
}
|
||||
out.other.push(o.kind.clone());
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// A parameter's grip ids: after a two-point parameter's points, the grip count (170) and the
|
||||
/// base and end grips (91); after a one-point parameter's point, its grip (93).
|
||||
fn grip_ids(secs: &[(&str, &[RawTag])]) -> Vec<i32> {
|
||||
let two = section(secs, "AcDbBlock2PtParameter");
|
||||
if !two.is_empty() {
|
||||
let mut c = Cursor::new(two);
|
||||
c.point(1010);
|
||||
c.point(1011);
|
||||
let n = c.count(170).min(2);
|
||||
return (0..n).map(|_| c.int(91)).collect();
|
||||
}
|
||||
let mut c = Cursor::new(section(secs, "AcDbBlock1PtParameter"));
|
||||
c.point(1010);
|
||||
c.next(93).and_then(|v| v.trim().parse().ok()).into_iter().collect()
|
||||
}
|
||||
|
||||
fn parameter(kind: &str, secs: &[(&str, &[RawTag])]) -> ParameterKind {
|
||||
let two = || {
|
||||
let mut c = Cursor::new(section(secs, "AcDbBlock2PtParameter"));
|
||||
(c.point(1010), c.point(1011))
|
||||
};
|
||||
let one = || Cursor::new(section(secs, "AcDbBlock1PtParameter")).point(1010);
|
||||
match kind {
|
||||
"BLOCKLINEARPARAMETER" => {
|
||||
let (base, end) = two();
|
||||
let mut c = Cursor::new(section(secs, "AcDbBlockLinearParameter"));
|
||||
let label = c.string(305);
|
||||
let description = c.string(306);
|
||||
let from_midpoint = Cursor::new(section(secs, "AcDbBlock2PtParameter")).int(177) == 1;
|
||||
ParameterKind::Linear { base, end, label, description, values: value_set(&mut c), from_midpoint }
|
||||
}
|
||||
"BLOCKPOINTPARAMETER" => {
|
||||
let mut c = Cursor::new(section(secs, "AcDbBlockPointParameter"));
|
||||
ParameterKind::Point { at: one(), label: c.string(303), description: c.string(304) }
|
||||
}
|
||||
"BLOCKVISIBILITYPARAMETER" => visibility(one(), section(secs, "AcDbBlockVisibilityParameter")),
|
||||
"BLOCKLOOKUPPARAMETER" => {
|
||||
let mut c = Cursor::new(section(secs, "AcDbBlockLookupParameter"));
|
||||
ParameterKind::Lookup { at: one(), label: c.string(303), description: c.string(304) }
|
||||
}
|
||||
"BLOCKBASEPOINTPARAMETER" => ParameterKind::BasePoint { at: one() },
|
||||
"BLOCKPOLARPARAMETER" | "BLOCKXYPARAMETER" | "BLOCKROTATIONPARAMETER" | "BLOCKALIGNMENTPARAMETER" | "BLOCKFLIPPARAMETER" => {
|
||||
let (base, end) = two();
|
||||
match kind {
|
||||
"BLOCKPOLARPARAMETER" => ParameterKind::Polar { base, end },
|
||||
"BLOCKXYPARAMETER" => ParameterKind::Xy { base, end },
|
||||
"BLOCKROTATIONPARAMETER" => ParameterKind::Rotation { base, end },
|
||||
"BLOCKALIGNMENTPARAMETER" => ParameterKind::Alignment { base, end },
|
||||
_ => ParameterKind::Flip { base, end },
|
||||
}
|
||||
}
|
||||
other => ParameterKind::Other { dxf_type: other.to_string() },
|
||||
}
|
||||
}
|
||||
|
||||
/// A value set after a parameter's name and description: description (307), flags (96),
|
||||
/// minimum, maximum and increment (141–143), then the listed values (175 count, 144 each).
|
||||
fn value_set(c: &mut Cursor) -> ValueSet {
|
||||
c.next(307);
|
||||
let flags = c.int(96);
|
||||
let (minimum, maximum, increment) = (c.real(141), c.real(142), c.real(143));
|
||||
let n = c.count(175);
|
||||
let list = (0..n).filter_map(|_| c.next(144).and_then(|v| v.trim().parse::<f64>().ok()).filter(|v| v.is_finite())).collect();
|
||||
ValueSet { flags, minimum, maximum, increment, list }
|
||||
}
|
||||
|
||||
/// A visibility parameter: its name and description (301, 302), the objects it controls (93
|
||||
/// count, 331 each), then each state (92 count): name (303), visible objects (94, 332) and the
|
||||
/// parameters and actions active in it (95, 333).
|
||||
fn visibility(at: Vec3, tags: &[RawTag]) -> ParameterKind {
|
||||
let mut c = Cursor::new(tags);
|
||||
let label = c.string(301);
|
||||
let description = c.string(302);
|
||||
let n = c.count(93);
|
||||
let controlled = c.handles(331, n);
|
||||
let states = (0..c.count(92))
|
||||
.map(|_| {
|
||||
let name = c.string(303);
|
||||
let n = c.count(94);
|
||||
let visible = c.handles(332, n);
|
||||
let n = c.count(95);
|
||||
VisibilityState { name, visible, active: c.handles(333, n) }
|
||||
})
|
||||
.collect();
|
||||
ParameterKind::Visibility { at, label, description, controlled, states }
|
||||
}
|
||||
|
||||
fn action(handle: Handle, node: i32, name: String, kind: &str, secs: &[(&str, &[RawTag])]) -> Action {
|
||||
let mut c = Cursor::new(section(secs, "AcDbBlockAction"));
|
||||
c.next(70);
|
||||
let n = c.count(71);
|
||||
let selection = c.handles(330, n);
|
||||
let at = c.point(1010);
|
||||
let (connections, kind) = match kind {
|
||||
"BLOCKSTRETCHACTION" => stretch(section(secs, "AcDbBlockStretchAction")),
|
||||
"BLOCKLOOKUPACTION" => (Vec::new(), lookup(section(secs, "AcDbBlockLookupAction"))),
|
||||
"BLOCKMOVEACTION" => {
|
||||
let tags = section(secs, "AcDbBlockMoveAction");
|
||||
let mut c = Cursor::new(tags);
|
||||
let (multiplier, angle_offset) = (c.real(140), c.real(141));
|
||||
(two_connections(tags), ActionKind::Move { multiplier, angle_offset })
|
||||
}
|
||||
"BLOCKSCALEACTION" => (Vec::new(), ActionKind::Scale),
|
||||
"BLOCKROTATEACTION" => (Vec::new(), ActionKind::Rotate),
|
||||
"BLOCKFLIPACTION" => (Vec::new(), ActionKind::Flip),
|
||||
"BLOCKARRAYACTION" => (Vec::new(), ActionKind::Array),
|
||||
"BLOCKPOLARSTRETCHACTION" => (Vec::new(), ActionKind::PolarStretch),
|
||||
other => (Vec::new(), ActionKind::Other { dxf_type: other.to_string() }),
|
||||
};
|
||||
Action { node, handle, name, at, selection, connections, kind }
|
||||
}
|
||||
|
||||
/// Two connections: parameter ids (92, 93) and the values they read (301, 302). Writers order
|
||||
/// these groups differently (92 93 301 302, or 92 301 93 302), so each is read on its own.
|
||||
fn two_connections(tags: &[RawTag]) -> Vec<(i32, String)> {
|
||||
let first = |code: i32| Cursor::new(tags).next(code).unwrap_or_default().to_string();
|
||||
let int = |code: i32| first(code).trim().parse().unwrap_or(0);
|
||||
vec![(int(92), first(301)), (int(93), first(302))]
|
||||
}
|
||||
|
||||
/// A stretch action: its two connections, the frame (72 count, 1011/1021 points), the objects
|
||||
/// whose vertices it moves (73 count; each a 331 handle, a 74 count and 94 vertex indices), then
|
||||
/// the distance multiplier (140) and angle offset (141).
|
||||
fn stretch(tags: &[RawTag]) -> (Vec<(i32, String)>, ActionKind) {
|
||||
let connections = two_connections(tags);
|
||||
let mut c = Cursor::new(tags);
|
||||
let n = c.count(72);
|
||||
let frame = (0..n)
|
||||
.map(|_| {
|
||||
let x = c.real(1011);
|
||||
Vec2::new(x, c.real(1021))
|
||||
})
|
||||
.collect();
|
||||
let objects = c.count(73);
|
||||
let vertices = (0..objects)
|
||||
.filter_map(|_| {
|
||||
let h = c.handle(331)?;
|
||||
let n = c.count(74);
|
||||
Some((h, (0..n).map(|_| c.int(94)).collect()))
|
||||
})
|
||||
.collect();
|
||||
c.next(75);
|
||||
let (multiplier, angle_offset) = (c.real(140), c.real(141));
|
||||
(connections, ActionKind::Stretch { frame, vertices, multiplier, angle_offset })
|
||||
}
|
||||
|
||||
/// A lookup table: rows (92) and columns (93), the cells row by row (302, after a 301 header),
|
||||
/// then each column: unmatched name (303 then 305), parameter id (94), lookup flag (282) and the
|
||||
/// parameter value it stands for (304).
|
||||
fn lookup(tags: &[RawTag]) -> ActionKind {
|
||||
let mut c = Cursor::new(tags);
|
||||
let rows = c.count(92);
|
||||
let cols = c.count(93);
|
||||
c.next(301);
|
||||
let cells = rows.saturating_mul(cols).min(tags.len());
|
||||
let flat: Vec<String> = (0..cells).map(|_| c.string(302)).collect();
|
||||
let rows = flat.chunks(cols.max(1)).map(<[String]>::to_vec).collect();
|
||||
let columns = (0..cols)
|
||||
.map(|_| {
|
||||
c.next(303);
|
||||
let node = c.int(94);
|
||||
let lookup = c.int(282) != 0;
|
||||
let unmatched = c.string(305);
|
||||
let property = c.string(304);
|
||||
LookupColumn { node, property, lookup, unmatched }
|
||||
})
|
||||
.collect();
|
||||
ActionKind::Lookup { columns, rows }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tag(code: i32, value: &str) -> RawTag {
|
||||
RawTag { code, value: value.to_string() }
|
||||
}
|
||||
|
||||
/// Counts that promise groups the record doesn't have: each nested loop would rescan the
|
||||
/// record for them. The work budget keeps this linear in the record's size.
|
||||
#[test]
|
||||
fn hostile_counts_with_missing_groups_stay_linear() {
|
||||
let n = 20_000;
|
||||
let mut tags = vec![tag(93, "0"), tag(92, &n.to_string())];
|
||||
tags.extend((0..n).map(|_| tag(94, &n.to_string())));
|
||||
let ParameterKind::Visibility { states, .. } = visibility(Vec3::default(), &tags) else { panic!("a visibility parameter") };
|
||||
assert!(states.len() <= n && states.iter().all(|s| s.visible.is_empty()));
|
||||
|
||||
let mut tags = vec![tag(73, &n.to_string())];
|
||||
tags.extend((0..n).flat_map(|_| [tag(331, "1F"), tag(74, &n.to_string())]));
|
||||
let (_, ActionKind::Stretch { vertices, .. }) = stretch(&tags) else { panic!("a stretch action") };
|
||||
let ints: usize = vertices.iter().map(|(_, v)| v.len()).sum();
|
||||
assert!(ints <= 16 * tags.len() + 1024, "{ints}");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,215 +0,0 @@
|
||||
//! The grips of a dynamic block reference, as AutoCAD shows them on a selected reference: an
|
||||
//! arrow at each end of a distance (linear parameter), a square at a position (point parameter)
|
||||
//! and a down-pointing triangle for a list (visibility states, lookup values). Dragging an arrow
|
||||
//! or square is [`super::drag_grip`]; a list grip offers its property's choices.
|
||||
//!
|
||||
//! With a visibility parameter, a state lists the grips shown in it; the others are hidden.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use cadcraft_geom::{Vec2, Vec3};
|
||||
use serde::Serialize;
|
||||
|
||||
use super::{DynamicBlock, ParameterKind, Value, custom_properties, dynamic_block, reference};
|
||||
use crate::{Drawing, EntityKind, Handle};
|
||||
|
||||
/// Most grips listed for one reference.
|
||||
const MAX_GRIPS: usize = 200;
|
||||
|
||||
/// Which of a parameter's points a grip is at.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum GripPart {
|
||||
/// A distance's base point.
|
||||
Base,
|
||||
/// A distance's end point.
|
||||
End,
|
||||
/// A one-point parameter's point (a position, a visibility or lookup grip).
|
||||
At,
|
||||
}
|
||||
|
||||
impl GripPart {
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
GripPart::Base => "base",
|
||||
GripPart::End => "end",
|
||||
GripPart::At => "at",
|
||||
}
|
||||
}
|
||||
pub fn from_name(s: &str) -> Option<GripPart> {
|
||||
match s.trim().to_ascii_lowercase().as_str() {
|
||||
"base" => Some(GripPart::Base),
|
||||
"end" => Some(GripPart::End),
|
||||
"at" => Some(GripPart::At),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How a grip looks and what it does.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum GripShape {
|
||||
/// An arrow pointing away from the distance: drag it to change the distance.
|
||||
Linear,
|
||||
/// A square: drag it to move the position.
|
||||
Point,
|
||||
/// A down-pointing triangle: click it for the property's choices.
|
||||
List,
|
||||
}
|
||||
|
||||
/// One grip of a dynamic block reference.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DynGrip {
|
||||
/// The parameter's graph id.
|
||||
pub node: i32,
|
||||
pub part: GripPart,
|
||||
pub shape: GripShape,
|
||||
/// Where it is, in drawing coordinates.
|
||||
#[serde(serialize_with = "point")]
|
||||
pub at: Vec2,
|
||||
/// The way an arrow points (a unit vector in drawing coordinates; zero for the others).
|
||||
#[serde(serialize_with = "point")]
|
||||
pub dir: Vec2,
|
||||
/// The property it changes, as Properties names it.
|
||||
pub property: String,
|
||||
/// A list grip's choices.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub choices: Vec<String>,
|
||||
}
|
||||
|
||||
/// Which ends of two-point parameter `node` the block's actions follow, as (base, end): a
|
||||
/// connection to one point's change (BaseXDelta, EndYDelta, UpdatedEnd…) follows that end, one to
|
||||
/// the value itself (a distance, an angle) follows both. None when no action is attached to the
|
||||
/// parameter: its grips are all kept, as the block has them.
|
||||
fn ends_used(m: &DynamicBlock, node: i32) -> Option<(bool, bool)> {
|
||||
let mut used: Option<(bool, bool)> = None;
|
||||
for (n, name) in m.actions.iter().flat_map(|a| a.connections.iter()) {
|
||||
if *n != node {
|
||||
continue;
|
||||
}
|
||||
let c = name.to_ascii_lowercase();
|
||||
let (base, end) = used.unwrap_or((false, false));
|
||||
used = Some(if c.starts_with("base") || c.starts_with("updatedbase") {
|
||||
(true, end)
|
||||
} else if c.starts_with("end") || c.starts_with("updatedend") {
|
||||
(base, true)
|
||||
} else {
|
||||
(true, true)
|
||||
});
|
||||
}
|
||||
used
|
||||
}
|
||||
|
||||
fn point<S: serde::Serializer>(p: &Vec2, s: S) -> Result<S::Ok, S::Error> {
|
||||
[p.x, p.y].serialize(s)
|
||||
}
|
||||
|
||||
/// The grips of dynamic block reference `insert` (none when it is not one).
|
||||
pub fn grips(d: &Drawing, insert: Handle) -> Vec<DynGrip> {
|
||||
let Some(r) = reference(d, insert) else { return Vec::new() };
|
||||
let Some(m) = dynamic_block(d, &r.definition) else { return Vec::new() };
|
||||
let Some(EntityKind::Insert(ins)) = d.entity(insert).map(|e| &e.kind) else { return Vec::new() };
|
||||
let base = d.block(&r.definition).map(|b| b.base.xy()).unwrap_or_default();
|
||||
let xf = ins.transform(base);
|
||||
let world = |p: Vec3| xf.apply(p.xy());
|
||||
let toward = |p: Vec3, u: Vec3| {
|
||||
let v = xf.apply(p.xy() + u.xy()) - xf.apply(p.xy());
|
||||
if v.len() > 1e-12 { v * (1.0 / v.len()) } else { Vec2::ZERO }
|
||||
};
|
||||
// The grips the current visibility state shows (all of them without visibility states).
|
||||
let objects: HashMap<i32, Handle> = m.grip_objects.iter().copied().collect();
|
||||
let mut shown_in_state: Option<HashSet<Handle>> = None;
|
||||
let mut visibility_node = None;
|
||||
for p in &m.parameters {
|
||||
if let ParameterKind::Visibility { states, .. } = &p.kind {
|
||||
let current = match r.values.get(&p.node) {
|
||||
Some(Value::Text { value }) => states.iter().find(|s| s.name.eq_ignore_ascii_case(value)),
|
||||
_ => states.first(),
|
||||
};
|
||||
if let Some(s) = current {
|
||||
shown_in_state = Some(s.active.iter().copied().collect());
|
||||
}
|
||||
visibility_node = Some(p.node);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// A grip shows in the current visibility state when the state lists it, or lists its
|
||||
// parameter (AutoCAD shows a listed parameter's grips though the grips aren't listed).
|
||||
let param_handle: HashMap<i32, Handle> = m.parameters.iter().map(|p| (p.node, p.handle)).collect();
|
||||
let shown = |node: i32, id: i32| {
|
||||
id != 0
|
||||
&& (Some(node) == visibility_node
|
||||
|| shown_in_state
|
||||
.as_ref()
|
||||
.is_none_or(|a| objects.get(&id).is_some_and(|h| a.contains(h)) || param_handle.get(&node).is_some_and(|h| a.contains(h))))
|
||||
};
|
||||
let choices: HashMap<String, Vec<String>> =
|
||||
custom_properties(d, insert).map(|(_, ps)| ps.into_iter().map(|p| (p.name.to_ascii_uppercase(), p.choices)).collect()).unwrap_or_default();
|
||||
let mut out = Vec::new();
|
||||
for p in &m.parameters {
|
||||
let grip = |i: usize| p.grips.get(i).copied().unwrap_or(0);
|
||||
match &p.kind {
|
||||
ParameterKind::Linear { base, end, label, from_midpoint, .. } => {
|
||||
let (b, e) = match r.values.get(&p.node) {
|
||||
Some(Value::Points { base, end }) => (*base, *end),
|
||||
_ => (*base, *end),
|
||||
};
|
||||
let u = e - b;
|
||||
let u = if u.len() > 1e-12 { u * (1.0 / u.len()) } else { Vec3::new(1.0, 0.0, 0.0) };
|
||||
// An end no action follows has no grip (dragging it would change nothing); a
|
||||
// distance from its midpoint moves both ends.
|
||||
let used = match ends_used(&m, p.node) {
|
||||
Some((base, end)) if !*from_midpoint => [base, end],
|
||||
_ => [true, true],
|
||||
};
|
||||
for (i, part, at, way) in [(0, GripPart::Base, b, u * -1.0), (1, GripPart::End, e, u)] {
|
||||
if used[i] && shown(p.node, grip(i)) {
|
||||
let (at, dir) = (world(at), toward(at, way));
|
||||
out.push(DynGrip { node: p.node, part, shape: GripShape::Linear, at, dir, property: label.clone(), choices: Vec::new() });
|
||||
}
|
||||
}
|
||||
}
|
||||
ParameterKind::Point { at, label, .. } => {
|
||||
let at = match r.values.get(&p.node) {
|
||||
Some(Value::Point { at }) => *at,
|
||||
_ => *at,
|
||||
};
|
||||
if shown(p.node, grip(0)) {
|
||||
let at = world(at);
|
||||
out.push(DynGrip {
|
||||
node: p.node,
|
||||
part: GripPart::At,
|
||||
shape: GripShape::Point,
|
||||
at,
|
||||
dir: Vec2::ZERO,
|
||||
property: label.clone(),
|
||||
choices: Vec::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
ParameterKind::Visibility { at, label, .. } | ParameterKind::Lookup { at, label, .. } => {
|
||||
let Some(list) = choices.get(&label.to_ascii_uppercase()).filter(|c| !c.is_empty()) else { continue };
|
||||
if shown(p.node, grip(0)) {
|
||||
let at = world(*at);
|
||||
out.push(DynGrip {
|
||||
node: p.node,
|
||||
part: GripPart::At,
|
||||
shape: GripShape::List,
|
||||
at,
|
||||
dir: Vec2::ZERO,
|
||||
property: label.clone(),
|
||||
choices: list.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if out.len() >= MAX_GRIPS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out.retain(|g| g.at.is_finite());
|
||||
out
|
||||
}
|
||||
@@ -1,283 +0,0 @@
|
||||
//! Dynamic block references: which dynamic block a block reference shows, the values it was given
|
||||
//! and the custom properties those make.
|
||||
//!
|
||||
//! A reference whose values differ from the block's own refers to an anonymous block (`*U12`)
|
||||
//! holding the result, and its extension dictionary says what it is: `AcDbBlockRepresentation`
|
||||
//! has `AcDbRepData` (pointing at the dynamic block's BLOCK_RECORD) and an `AppDataCache` whose
|
||||
//! `ACAD_ENHANCEDBLOCKDATA` dictionary holds one XRECORD per parameter, keyed by its graph id, with
|
||||
//! the value in block coordinates. A reference to the dynamic block itself has the block's values.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
|
||||
use cadcraft_geom::Vec3;
|
||||
use serde::Serialize;
|
||||
|
||||
use super::{ActionKind, DynamicBlock, ParameterKind, dynamic_block};
|
||||
use crate::foreign::ForeignObject;
|
||||
use crate::{Drawing, EntityKind, Handle};
|
||||
|
||||
/// Most values one reference is read with.
|
||||
const MAX_VALUES: usize = 10_000;
|
||||
|
||||
/// A parameter's value on one reference, in block coordinates.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum Value {
|
||||
/// A visibility state's name or a lookup value.
|
||||
Text { value: String },
|
||||
/// A point parameter's position.
|
||||
Point { at: Vec3 },
|
||||
/// A two-point parameter's points.
|
||||
Points { base: Vec3, end: Vec3 },
|
||||
}
|
||||
|
||||
/// A dynamic block reference.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Reference {
|
||||
/// The dynamic block it shows (its name in the drawing).
|
||||
pub definition: String,
|
||||
/// The values it was given, by parameter graph id; parameters missing here have the block's
|
||||
/// own values.
|
||||
pub values: BTreeMap<i32, Value>,
|
||||
}
|
||||
|
||||
/// One custom property of a reference, as the Properties palette shows it.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CustomProperty {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
/// The values it may be set to, when it is a choice (visibility states, lookup values, a
|
||||
/// distance's list).
|
||||
pub choices: Vec<String>,
|
||||
/// CADCraft can change it ([`super::set_property`]): a visibility state, a distance, a
|
||||
/// position, or a lookup whose tables set only those.
|
||||
pub editable: bool,
|
||||
}
|
||||
|
||||
fn objects(d: &Drawing) -> HashMap<Handle, &ForeignObject> {
|
||||
d.foreign.objects.iter().map(|o| (o.handle, o)).collect()
|
||||
}
|
||||
|
||||
/// The object entry `key` of dictionary `dict` refers to.
|
||||
fn entry(objects: &HashMap<Handle, &ForeignObject>, dict: Handle, key: &str) -> Option<Handle> {
|
||||
let o = objects.get(&dict)?;
|
||||
let mut tags = o.tags.iter();
|
||||
while let Some(t) = tags.next() {
|
||||
if t.code == 3 && t.value.eq_ignore_ascii_case(key) {
|
||||
return tags.next().filter(|t| t.code == 350 || t.code == 360).and_then(|t| Handle::parse_hex(&t.value));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Every entry of dictionary `dict`: (key, object).
|
||||
fn entries(objects: &HashMap<Handle, &ForeignObject>, dict: Handle) -> Vec<(String, Handle)> {
|
||||
let Some(o) = objects.get(&dict) else { return Vec::new() };
|
||||
let mut out = Vec::new();
|
||||
let mut key: Option<&str> = None;
|
||||
for t in &o.tags {
|
||||
match t.code {
|
||||
3 => key = Some(t.value.as_str()),
|
||||
350 | 360 => {
|
||||
if let (Some(k), Some(h)) = (key.take(), Handle::parse_hex(&t.value)) {
|
||||
out.push((k.to_string(), h));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A value XRECORD: its text (group 1) when it has one, else its points (10/20/30).
|
||||
fn value(o: &ForeignObject) -> Option<Value> {
|
||||
if let Some(t) = o.tags.iter().find(|t| t.code == 1) {
|
||||
return Some(Value::Text { value: t.value.clone() });
|
||||
}
|
||||
let num = |code: i32, i: usize| {
|
||||
o.tags.iter().filter(|t| t.code == code).nth(i).and_then(|t| t.value.trim().parse::<f64>().ok()).filter(|v| v.is_finite())
|
||||
};
|
||||
let point = |i: usize| Some(Vec3::new(num(10, i)?, num(20, i)?, num(30, i).unwrap_or(0.0)));
|
||||
match (point(0), point(1)) {
|
||||
(Some(base), Some(end)) => Some(Value::Points { base, end }),
|
||||
(Some(at), None) => Some(Value::Point { at }),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The dynamic block reference `insert` is, or `None` when it isn't one.
|
||||
pub fn reference(d: &Drawing, insert: Handle) -> Option<Reference> {
|
||||
let EntityKind::Insert(ins) = &d.entity(insert)?.kind else { return None };
|
||||
let objects = objects(d);
|
||||
let rep = d.foreign.entities.get(&insert).and_then(|x| x.xdict).and_then(|x| entry(&objects, x, "AcDbBlockRepresentation"));
|
||||
let Some(rep) = rep else {
|
||||
// No representation: a reference to a dynamic block with its own values.
|
||||
return dynamic_block(d, &ins.block)
|
||||
.is_some()
|
||||
.then(|| Reference { definition: d.block(&ins.block).map_or_else(|| ins.block.clone(), |b| b.name.clone()), values: BTreeMap::new() });
|
||||
};
|
||||
let data = entry(&objects, rep, "AcDbRepData").and_then(|h| objects.get(&h).copied())?;
|
||||
let target = data.tags.iter().find(|t| t.code == 340).and_then(|t| Handle::parse_hex(&t.value))?;
|
||||
let (kind, name) = d.foreign.targets.get(&target)?;
|
||||
let definition = d.block(name).filter(|_| kind == "BLOCK_RECORD")?.name.clone();
|
||||
dynamic_block(d, &definition)?;
|
||||
let mut values = BTreeMap::new();
|
||||
let cache = entry(&objects, rep, "AppDataCache").and_then(|c| entry(&objects, c, "ACAD_ENHANCEDBLOCKDATA"));
|
||||
for (key, h) in cache.map(|c| entries(&objects, c)).unwrap_or_default().into_iter().take(MAX_VALUES) {
|
||||
if let (Ok(node), Some(v)) = (key.trim().parse::<i32>(), objects.get(&h).and_then(|o| value(o))) {
|
||||
values.insert(node, v);
|
||||
}
|
||||
}
|
||||
Some(Reference { definition, values })
|
||||
}
|
||||
|
||||
/// A number as the drawing shows lengths (LUPREC decimals).
|
||||
fn number(d: &Drawing, v: f64) -> String {
|
||||
let prec = usize::try_from(d.header.i64("LUPREC", 4).clamp(0, 8)).unwrap_or(4);
|
||||
format!("{v:.prec$}")
|
||||
}
|
||||
|
||||
/// Whether table cell `cell` reads as `value` (numbers compare as numbers).
|
||||
fn same(cell: &str, value: &str) -> bool {
|
||||
match (cell.trim().parse::<f64>(), value.trim().parse::<f64>()) {
|
||||
(Ok(a), Ok(b)) => (a - b).abs() <= 1e-6 * a.abs().max(b.abs()).max(1.0),
|
||||
_ => cell.trim().eq_ignore_ascii_case(value.trim()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The current values of a reference's parameters, for evaluating its lookup tables.
|
||||
struct Current<'a> {
|
||||
m: &'a DynamicBlock,
|
||||
values: &'a BTreeMap<i32, Value>,
|
||||
}
|
||||
|
||||
impl Current<'_> {
|
||||
/// Parameter `node`'s value of property `property`, as table cells write it.
|
||||
fn property(&self, node: i32, property: &str) -> Option<String> {
|
||||
let p = self.m.parameters.iter().find(|p| p.node == node)?;
|
||||
let v = self.values.get(&node);
|
||||
match (&p.kind, property.to_ascii_lowercase().as_str()) {
|
||||
(ParameterKind::Visibility { states, .. }, "visibilitystate") => match v {
|
||||
Some(Value::Text { value }) => Some(value.clone()),
|
||||
_ => states.first().map(|s| s.name.clone()),
|
||||
},
|
||||
(ParameterKind::Linear { base, end, .. }, "updateddistance" | "distance") => {
|
||||
let (b, e) = match v {
|
||||
Some(Value::Points { base, end }) => (*base, *end),
|
||||
_ => (*base, *end),
|
||||
};
|
||||
Some(format!("{}", (e - b).len()))
|
||||
}
|
||||
(ParameterKind::Lookup { .. }, "lookupstring") => match v {
|
||||
Some(Value::Text { value }) => Some(value.clone()),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Lookup parameter `node`'s value: the value every lookup table that sets it gives for the
|
||||
/// current inputs, or the tables' "no match" name ("Custom") when they don't agree or a table
|
||||
/// has no matching row. Also the values it may be set to.
|
||||
fn lookup(&self, node: i32) -> (String, Vec<String>) {
|
||||
let mut found: Option<Option<String>> = None;
|
||||
let mut unmatched = String::from("Custom");
|
||||
let mut choices: Vec<String> = Vec::new();
|
||||
for a in &self.m.actions {
|
||||
let ActionKind::Lookup { columns, rows } = &a.kind else { continue };
|
||||
let Some(out) = columns.iter().position(|c| c.lookup && c.node == node) else { continue };
|
||||
if rows.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(c) = columns.get(out) {
|
||||
unmatched = c.unmatched.clone();
|
||||
}
|
||||
for r in rows {
|
||||
if let Some(v) = r.get(out).filter(|v| !choices.contains(v)) {
|
||||
choices.push(v.clone());
|
||||
}
|
||||
}
|
||||
let hit = rows.iter().find(|r| {
|
||||
columns.iter().enumerate().filter(|(_, c)| !c.lookup).all(|(i, c)| match (r.get(i), self.property(c.node, &c.property)) {
|
||||
(Some(cell), Some(v)) => same(cell, &v),
|
||||
_ => false,
|
||||
})
|
||||
});
|
||||
let v = hit.and_then(|r| r.get(out).cloned());
|
||||
found = Some(match found {
|
||||
None => v,
|
||||
Some(prev) if prev == v => prev,
|
||||
Some(_) => None,
|
||||
});
|
||||
}
|
||||
(found.flatten().unwrap_or(unmatched), choices)
|
||||
}
|
||||
}
|
||||
|
||||
/// The custom properties block reference `insert` shows (the Properties palette's Custom
|
||||
/// section): its dynamic block's name and, in parameter order, each parameter shown in Properties
|
||||
/// with its value. Lengths and positions are in drawing units (block units × the reference's
|
||||
/// scale); positions are relative to the block's base point.
|
||||
pub fn custom_properties(d: &Drawing, insert: Handle) -> Option<(String, Vec<CustomProperty>)> {
|
||||
let r = reference(d, insert)?;
|
||||
let EntityKind::Insert(ins) = &d.entity(insert)?.kind else { return None };
|
||||
let m = dynamic_block(d, &r.definition)?;
|
||||
let base = d.block(&r.definition).map(|b| b.base).unwrap_or_default();
|
||||
let (sx, sy) = (ins.scale.x.abs(), ins.scale.y.abs());
|
||||
let cur = Current { m: &m, values: &r.values };
|
||||
let mut out = Vec::new();
|
||||
for p in m.parameters.iter().filter(|p| p.show_properties) {
|
||||
let v = r.values.get(&p.node);
|
||||
match &p.kind {
|
||||
ParameterKind::Linear { base: b, end: e, label, values, .. } => {
|
||||
let (b, e) = match v {
|
||||
Some(Value::Points { base, end }) => (*base, *end),
|
||||
_ => (*b, *e),
|
||||
};
|
||||
let choices = if values.is_list() { values.list.iter().map(|x| number(d, x * sx)).collect() } else { Vec::new() };
|
||||
out.push(CustomProperty { name: label.clone(), value: number(d, (e - b).len() * sx), choices, editable: true });
|
||||
}
|
||||
ParameterKind::Point { at, label, .. } => {
|
||||
let at = match v {
|
||||
Some(Value::Point { at }) => *at,
|
||||
_ => *at,
|
||||
};
|
||||
out.push(CustomProperty { name: format!("{label} X"), value: number(d, (at.x - base.x) * sx), choices: Vec::new(), editable: true });
|
||||
out.push(CustomProperty { name: format!("{label} Y"), value: number(d, (at.y - base.y) * sy), choices: Vec::new(), editable: true });
|
||||
}
|
||||
ParameterKind::Visibility { label, states, .. } => {
|
||||
let value = cur.property(p.node, "VisibilityState").unwrap_or_default();
|
||||
out.push(CustomProperty { name: label.clone(), value, choices: states.iter().map(|s| s.name.clone()).collect(), editable: true });
|
||||
}
|
||||
ParameterKind::Lookup { label, .. } => {
|
||||
let (value, choices) = cur.lookup(p.node);
|
||||
let editable = lookup_sets_what_changes(&m, p.node);
|
||||
out.push(CustomProperty { name: label.clone(), value, choices, editable });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some((r.definition, out))
|
||||
}
|
||||
|
||||
/// Whether every lookup table that sets lookup parameter `node` sets only what
|
||||
/// [`super::set_property`] can apply with it: visibility states and distances.
|
||||
fn lookup_sets_what_changes(m: &DynamicBlock, node: i32) -> bool {
|
||||
let mut any = false;
|
||||
for a in &m.actions {
|
||||
let ActionKind::Lookup { columns, rows } = &a.kind else { continue };
|
||||
if rows.is_empty() || !columns.iter().any(|c| c.lookup && c.node == node) {
|
||||
continue;
|
||||
}
|
||||
any = true;
|
||||
if !columns
|
||||
.iter()
|
||||
.all(|c| matches!(c.property.to_ascii_lowercase().as_str(), "lookupstring" | "visibilitystate" | "updateddistance" | "distance"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
any
|
||||
}
|
||||
+629
-234
File diff suppressed because it is too large
Load Diff
@@ -1,182 +0,0 @@
|
||||
use super::common::{Common, Lineweight};
|
||||
use super::text::{MText, Text};
|
||||
use super::{Color, Deserialize, EntityKind, Mat3, Serialize, Vec2, Vec3};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Attrib {
|
||||
pub tag: String,
|
||||
pub text: Text,
|
||||
#[serde(default)]
|
||||
pub invisible: bool,
|
||||
#[serde(default)]
|
||||
pub constant: bool,
|
||||
#[serde(default)]
|
||||
pub prompt: String,
|
||||
/// An attribute's own properties; each one left unset follows the attribute definition.
|
||||
#[serde(default, skip_serializing_if = "AttribProps::is_empty")]
|
||||
pub props: AttribProps,
|
||||
/// A multiline attribute (DXF attribute type 2, or 4 for a definition): its text as
|
||||
/// multiline text. `text.value` holds the plain text, which value edits change.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub multiline: Option<MultilineAttrib>,
|
||||
/// Its other modes: Verify, Preset and Lock position.
|
||||
#[serde(default, skip_serializing_if = "AttribModes::is_default")]
|
||||
pub modes: AttribModes,
|
||||
}
|
||||
|
||||
/// An attribute's modes beyond invisible and constant.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AttribModes {
|
||||
/// Verify: the value is asked for twice when the block is inserted (DXF flag 4).
|
||||
#[serde(default)]
|
||||
pub verify: bool,
|
||||
/// Preset: the default value is used without asking (DXF flag 8).
|
||||
#[serde(default)]
|
||||
pub preset: bool,
|
||||
/// Lock position: the attribute can't be moved on its own within a reference (DXF group
|
||||
/// 280 after the flags). A reference's attributes take it from their definitions.
|
||||
#[serde(default)]
|
||||
pub lock_position: bool,
|
||||
}
|
||||
|
||||
impl AttribModes {
|
||||
pub fn is_default(&self) -> bool {
|
||||
*self == Self::default()
|
||||
}
|
||||
/// The modes of a reference's attribute made from a definition with these: AutoCAD keeps
|
||||
/// only Lock position there.
|
||||
pub fn for_reference(self) -> Self {
|
||||
AttribModes { lock_position: self.lock_position, ..Self::default() }
|
||||
}
|
||||
}
|
||||
|
||||
/// The multiline text of a multiline attribute.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MultilineAttrib {
|
||||
pub mtext: MText,
|
||||
/// The plain text `mtext`'s contents read as: an attribute whose value differs was edited
|
||||
/// since, and its value replaces the contents.
|
||||
pub read_as: String,
|
||||
}
|
||||
|
||||
impl MultilineAttrib {
|
||||
/// This text placed by `m` (a block reference's transform, for its attribute made from a
|
||||
/// definition).
|
||||
pub fn transformed(&self, m: &Mat3) -> MultilineAttrib {
|
||||
let mut k = EntityKind::MText(self.mtext.clone());
|
||||
k.transform(m);
|
||||
let mtext = if let EntityKind::MText(t) = k { t } else { self.mtext.clone() };
|
||||
MultilineAttrib { mtext, read_as: self.read_as.clone() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Attrib {
|
||||
/// Move a multiline attribute's text by `m` along with the attribute (nothing for others).
|
||||
pub fn transform_multiline(&mut self, m: &Mat3) {
|
||||
if let Some(ml) = &mut self.multiline {
|
||||
*ml = ml.transformed(m);
|
||||
}
|
||||
}
|
||||
|
||||
/// A multiline attribute's text as drawn and saved: its multiline text, with the contents
|
||||
/// replaced by the value when the value was edited since it was read.
|
||||
pub fn multiline_text(&self) -> Option<MText> {
|
||||
let m = self.multiline.as_ref()?;
|
||||
let mut t = m.mtext.clone();
|
||||
if self.text.value != m.read_as {
|
||||
t.contents = mtext_literal(&self.text.value);
|
||||
}
|
||||
Some(t)
|
||||
}
|
||||
}
|
||||
|
||||
/// `s` as MTEXT contents that read back as `s`: backslashes and braces escaped, line breaks as
|
||||
/// paragraph breaks.
|
||||
pub fn mtext_literal(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'{' => out.push_str("\\{"),
|
||||
'}' => out.push_str("\\}"),
|
||||
'\n' => out.push_str("\\P"),
|
||||
'\r' => {}
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Properties set on one attribute of a block reference (an ATTRIB's layer, colour, linetype and
|
||||
/// lineweight), overriding those of its definition.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct AttribProps {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub layer: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub color: Option<Color>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub linetype: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub lineweight: Option<Lineweight>,
|
||||
}
|
||||
|
||||
impl AttribProps {
|
||||
/// Nothing set: the attribute is drawn with its definition's properties.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
*self == AttribProps::default()
|
||||
}
|
||||
/// `base` (the definition's properties) with the ones set here in place.
|
||||
pub fn over(&self, base: &Common) -> Common {
|
||||
Common {
|
||||
layer: self.layer.clone().unwrap_or_else(|| base.layer.clone()),
|
||||
color: self.color.unwrap_or(base.color),
|
||||
linetype: self.linetype.clone().unwrap_or_else(|| base.linetype.clone()),
|
||||
lineweight: self.lineweight.unwrap_or(base.lineweight),
|
||||
..base.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Insert {
|
||||
pub block: String,
|
||||
pub insert: Vec3,
|
||||
#[serde(default = "unit_scale")]
|
||||
pub scale: Vec3,
|
||||
#[serde(default)]
|
||||
pub rotation: f64,
|
||||
#[serde(default)]
|
||||
pub attribs: Vec<Attrib>,
|
||||
#[serde(default = "one_u")]
|
||||
pub cols: u32,
|
||||
#[serde(default = "one_u")]
|
||||
pub rows: u32,
|
||||
#[serde(default)]
|
||||
pub col_spacing: f64,
|
||||
#[serde(default)]
|
||||
pub row_spacing: f64,
|
||||
}
|
||||
|
||||
fn unit_scale() -> Vec3 {
|
||||
Vec3::new(1.0, 1.0, 1.0)
|
||||
}
|
||||
|
||||
fn one_u() -> u32 {
|
||||
1
|
||||
}
|
||||
|
||||
impl Insert {
|
||||
/// Block-to-world transform (2D part).
|
||||
pub fn transform(&self, base: Vec2) -> Mat3 {
|
||||
Mat3::translate(self.insert.xy())
|
||||
.then_before(Mat3::rotate(self.rotation))
|
||||
.then_before(Mat3::scale(self.scale.x, self.scale.y))
|
||||
.then_before(Mat3::translate(-base))
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
use super::{Color, Deserialize, Serialize, Vec3};
|
||||
|
||||
/// A database handle (DXF group 5), unique per drawing.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub struct Handle(pub u64);
|
||||
|
||||
impl Handle {
|
||||
pub fn hex(self) -> String {
|
||||
format!("{:X}", self.0)
|
||||
}
|
||||
pub fn parse_hex(s: &str) -> Option<Handle> {
|
||||
let s = s.trim();
|
||||
if !s.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
u64::from_str_radix(s, 16).ok().map(Handle)
|
||||
}
|
||||
}
|
||||
|
||||
/// Lineweight in hundredths of a millimetre, or a logical value.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Lineweight {
|
||||
#[default]
|
||||
ByLayer,
|
||||
ByBlock,
|
||||
Default,
|
||||
/// 0..=211 (hundredths of mm).
|
||||
Mm100(u16),
|
||||
}
|
||||
|
||||
impl Lineweight {
|
||||
/// The standard lineweight values (mm × 100).
|
||||
pub const STANDARD: [u16; 24] = [0, 5, 9, 13, 15, 18, 20, 25, 30, 35, 40, 50, 53, 60, 70, 80, 90, 100, 106, 120, 140, 158, 200, 211];
|
||||
pub fn to_dxf(self) -> i16 {
|
||||
match self {
|
||||
Lineweight::ByLayer => -1,
|
||||
Lineweight::ByBlock => -2,
|
||||
Lineweight::Default => -3,
|
||||
Lineweight::Mm100(v) => v.min(211) as i16,
|
||||
}
|
||||
}
|
||||
pub fn from_dxf(v: i16) -> Lineweight {
|
||||
match v {
|
||||
-2 => Lineweight::ByBlock,
|
||||
-3 => Lineweight::Default,
|
||||
0..=211 => Lineweight::Mm100(v as u16),
|
||||
_ => Lineweight::ByLayer,
|
||||
}
|
||||
}
|
||||
pub fn name(self) -> String {
|
||||
match self {
|
||||
Lineweight::ByLayer => "ByLayer".into(),
|
||||
Lineweight::ByBlock => "ByBlock".into(),
|
||||
Lineweight::Default => "Default".into(),
|
||||
Lineweight::Mm100(v) => format!("{:.2} mm", f64::from(v) / 100.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Transparency: ByLayer, ByBlock, or 0..=90 percent.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Transparency {
|
||||
#[default]
|
||||
ByLayer,
|
||||
ByBlock,
|
||||
Percent(u8),
|
||||
}
|
||||
|
||||
impl Transparency {
|
||||
/// "ByLayer", "ByBlock" or the percentage ("50").
|
||||
pub fn name(&self) -> String {
|
||||
match self {
|
||||
Transparency::ByLayer => "ByLayer".into(),
|
||||
Transparency::ByBlock => "ByBlock".into(),
|
||||
Transparency::Percent(p) => p.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Properties every entity has.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Common {
|
||||
pub layer: String,
|
||||
pub color: Color,
|
||||
pub linetype: String,
|
||||
pub lineweight: Lineweight,
|
||||
pub ltscale: f64,
|
||||
pub transparency: Transparency,
|
||||
pub visible: bool,
|
||||
pub thickness: f64,
|
||||
pub extrusion: Vec3,
|
||||
/// Named plot style: `ByLayer`, `ByBlock` or a style of the layout's named (`.stb`) plot
|
||||
/// style table.
|
||||
pub plot_style: String,
|
||||
/// The annotation scales an annotative object supports (empty: not annotative).
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub anno_scales: Vec<crate::AnnoScale>,
|
||||
/// Other applications' extended data (xdata) as read from the drawing file: whole
|
||||
/// applications, each from its `1001` group on. It is part of the object, so a copy of the
|
||||
/// object (COPY, MIRROR, ARRAY, paste) carries it, as in AutoCAD.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub xdata: Vec<RawTag>,
|
||||
}
|
||||
|
||||
impl Default for Common {
|
||||
fn default() -> Self {
|
||||
Common {
|
||||
layer: "0".into(),
|
||||
color: Color::ByLayer,
|
||||
linetype: "ByLayer".into(),
|
||||
lineweight: Lineweight::ByLayer,
|
||||
ltscale: 1.0,
|
||||
transparency: Transparency::ByLayer,
|
||||
visible: true,
|
||||
thickness: 0.0,
|
||||
extrusion: Vec3::Z,
|
||||
plot_style: "ByLayer".into(),
|
||||
anno_scales: Vec::new(),
|
||||
xdata: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A raw DXF group: kept for round-tripping entities we do not model.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RawTag {
|
||||
pub code: i32,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
impl Common {
|
||||
/// Whether "Match orientation to layout" is on for this annotative object (AutoCAD's
|
||||
/// `AcadAnnoPO` xdata = 1). AutoCAD draws such text upright and ignores its stored rotation.
|
||||
/// The object's own `AcadAnnotative` xdata never reaches [`Common::xdata`] (the reader drops
|
||||
/// it), so the `AcadAnnoPO` flag alone decides.
|
||||
pub fn matches_layout_orientation(&self) -> bool {
|
||||
let app = self.xdata.iter().position(|t| t.code == 1001 && t.value.trim().eq_ignore_ascii_case("AcadAnnoPO"));
|
||||
let Some(at) = app else { return false };
|
||||
self.xdata
|
||||
.iter()
|
||||
.skip(at + 1)
|
||||
.take_while(|t| t.code != 1001)
|
||||
.find(|t| t.code == 1070)
|
||||
.is_some_and(|t| t.value.trim().parse::<i64>().is_ok_and(|v| v != 0))
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
use super::{Deserialize, PolyVertex, Serialize, Vec3};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Line {
|
||||
pub a: Vec3,
|
||||
pub b: Vec3,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Point {
|
||||
pub p: Vec3,
|
||||
#[serde(default)]
|
||||
pub angle: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Circle {
|
||||
pub center: Vec3,
|
||||
pub radius: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Arc {
|
||||
pub center: Vec3,
|
||||
pub radius: f64,
|
||||
/// Radians, CCW.
|
||||
pub start: f64,
|
||||
pub end: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Ellipse {
|
||||
pub center: Vec3,
|
||||
pub major: Vec3,
|
||||
pub ratio: f64,
|
||||
pub start: f64,
|
||||
pub end: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct LwPolyline {
|
||||
pub vertices: Vec<PolyVertex>,
|
||||
pub closed: bool,
|
||||
#[serde(default)]
|
||||
pub const_width: f64,
|
||||
#[serde(default)]
|
||||
pub elevation: f64,
|
||||
/// Polyline linetype generation (PLINEGEN).
|
||||
#[serde(default)]
|
||||
pub plinegen: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Polyline3d {
|
||||
pub points: Vec<Vec3>,
|
||||
pub closed: bool,
|
||||
}
|
||||
|
||||
/// SOLID / TRACE: up to four corners (DXF order 1,2,4,3 when drawn).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Solid {
|
||||
pub corners: [Vec3; 4],
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Face3d {
|
||||
pub corners: [Vec3; 4],
|
||||
#[serde(default)]
|
||||
pub hidden_edges: u8,
|
||||
}
|
||||
|
||||
impl Face3d {
|
||||
/// The edges drawn: side i (corner i to the next) unless bit i of `hidden_edges` hides it,
|
||||
/// without the zero-length side of a triangle.
|
||||
pub fn visible_edges(&self) -> Vec<(Vec3, Vec3)> {
|
||||
let c = self.corners;
|
||||
(0..4).filter(|i| self.hidden_edges & (1 << i) == 0).map(|i| (c[i], c[(i + 1) % 4])).filter(|(a, b)| a != b).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// POLYLINE polyface mesh (DXF flag 64): its vertices, and faces naming three or four of them
|
||||
/// by 1-based index as DXF stores them (0: no fourth vertex; a negative index hides the edge
|
||||
/// that starts at that vertex).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PolyfaceMesh {
|
||||
pub vertices: Vec<Vec3>,
|
||||
pub faces: Vec<[i32; 4]>,
|
||||
}
|
||||
|
||||
impl PolyfaceMesh {
|
||||
/// The faces as 3D faces (what EXPLODE makes). Faces with fewer than three vertices or an
|
||||
/// index outside the vertex list are skipped.
|
||||
pub fn faces3d(&self) -> Vec<Face3d> {
|
||||
self.faces.iter().filter_map(|f| self.face3d(f)).collect()
|
||||
}
|
||||
/// One face record as a 3D face: `None` when it names fewer than three vertices before a 0,
|
||||
/// or one outside the vertex list.
|
||||
pub fn face3d(&self, f: &[i32; 4]) -> Option<Face3d> {
|
||||
let mut corners: Vec<Vec3> = Vec::with_capacity(4);
|
||||
let mut hidden = 0u8;
|
||||
for &ix in f.iter().take_while(|&&ix| ix != 0) {
|
||||
let at = usize::try_from(ix.unsigned_abs()).ok().and_then(|n| n.checked_sub(1));
|
||||
let p = *at.and_then(|n| self.vertices.get(n))?;
|
||||
if ix < 0 {
|
||||
hidden |= 1 << corners.len();
|
||||
}
|
||||
corners.push(p);
|
||||
}
|
||||
match corners.as_slice() {
|
||||
// A triangle's closing edge is the 3DFACE's fourth (the third is degenerate).
|
||||
[a, b, c] => Some(Face3d { corners: [*a, *b, *c, *c], hidden_edges: hidden | if hidden & 4 != 0 { 8 } else { 0 } }),
|
||||
[a, b, c, d] => Some(Face3d { corners: [*a, *b, *c, *d], hidden_edges: hidden }),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RayLine {
|
||||
pub base: Vec3,
|
||||
pub dir: Vec3,
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
use super::block::Insert;
|
||||
use super::common::Handle;
|
||||
use super::text::MText;
|
||||
use super::{Deserialize, GSpline, Serialize, Vec2, Vec3, one, standard, yes};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum DimKind {
|
||||
/// Rotated linear dimension (horizontal = 0, vertical = 90°).
|
||||
Linear {
|
||||
rotation: f64,
|
||||
},
|
||||
Aligned,
|
||||
Angular,
|
||||
Angular3P,
|
||||
Diameter,
|
||||
Radius,
|
||||
Ordinate {
|
||||
x_type: bool,
|
||||
},
|
||||
ArcLength,
|
||||
}
|
||||
|
||||
/// A dimension. Definition points follow DXF: `defpt` (10: dimension line point),
|
||||
/// `p13`/`p14` (extension origins), `p15`/`p16` (arc/angle points), `text_mid` (11).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Dimension {
|
||||
pub kind: DimKind,
|
||||
pub defpt: Vec3,
|
||||
pub text_mid: Vec3,
|
||||
#[serde(default)]
|
||||
pub p13: Vec3,
|
||||
#[serde(default)]
|
||||
pub p14: Vec3,
|
||||
#[serde(default)]
|
||||
pub p15: Vec3,
|
||||
#[serde(default)]
|
||||
pub p16: Vec3,
|
||||
/// "" = measured value, "<>" inside = measurement placeholder, " " = suppressed.
|
||||
#[serde(default)]
|
||||
pub text: String,
|
||||
#[serde(default = "standard")]
|
||||
pub style: String,
|
||||
#[serde(default)]
|
||||
pub measurement: f64,
|
||||
#[serde(default)]
|
||||
pub text_rotation: f64,
|
||||
/// User moved the text (text_mid is authoritative).
|
||||
#[serde(default)]
|
||||
pub user_text_pos: bool,
|
||||
/// Anonymous block with the rendered geometry (from files); regenerated when edited.
|
||||
#[serde(default)]
|
||||
pub block: Option<String>,
|
||||
/// Insertion point (DXF group 12): a dimension moved by an older AutoCAD keeps its offset
|
||||
/// here with the `*D` block's contents left where they were, and is drawn translated by it.
|
||||
/// Zero once the geometry is regenerated from the definition points.
|
||||
#[serde(default)]
|
||||
pub insertion_point: Vec3,
|
||||
/// Per-dimension style overrides (DIMOVERRIDE): DimStyle field names (camelCase) → values,
|
||||
/// merged over the named style.
|
||||
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
|
||||
pub overrides: serde_json::Map<String, serde_json::Value>,
|
||||
/// Associativity: which definition points follow which objects.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub assoc: Vec<DimAssoc>,
|
||||
/// Oblique extension lines, jogs, breaks and inspection frames.
|
||||
#[serde(default, skip_serializing_if = "DimMarks::is_empty")]
|
||||
pub marks: DimMarks,
|
||||
}
|
||||
|
||||
/// What dimension edits add to a dimension's drawing: DIMEDIT Oblique, DIMJOGLINE, DIMJOGGED,
|
||||
/// DIMBREAK and DIMINSPECT.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct DimMarks {
|
||||
/// Angle (radians) of the extension lines of a linear or aligned dimension; None =
|
||||
/// perpendicular to the dimension line.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub oblique: Option<f64>,
|
||||
/// A jog on the dimension line of a linear or aligned dimension, at this point (DIMJOGLINE).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub jog_line: Option<Vec3>,
|
||||
/// A jogged radius dimension (DIMJOGGED).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub jogged: Option<JoggedRadius>,
|
||||
/// Gaps in the dimension and extension lines (DIMBREAK).
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub breaks: Vec<DimBreak>,
|
||||
/// Inspection frame, label and rate (DIMINSPECT).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub inspect: Option<Inspection>,
|
||||
}
|
||||
|
||||
impl DimMarks {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
*self == DimMarks::default()
|
||||
}
|
||||
|
||||
/// Transform the points and angles (part of [`super::EntityKind::transform`]).
|
||||
pub(super) fn transform(&mut self, t3: &dyn Fn(&mut Vec3), rot: f64, s: f64, mirror: bool) {
|
||||
if let Some(a) = &mut self.oblique {
|
||||
*a = cadcraft_geom::norm_angle(if mirror { rot - (*a - rot) } else { *a + rot });
|
||||
}
|
||||
if let Some(j) = &mut self.jog_line {
|
||||
t3(j);
|
||||
}
|
||||
if let Some(j) = &mut self.jogged {
|
||||
t3(&mut j.center);
|
||||
t3(&mut j.jog);
|
||||
}
|
||||
for b in &mut self.breaks {
|
||||
t3(&mut b.at);
|
||||
b.size *= s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The definition of a jogged radius dimension: the dimension line starts at an overriding
|
||||
/// centre and jogs on its way to the arc.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct JoggedRadius {
|
||||
/// Where the dimension line starts instead of the true centre.
|
||||
pub center: Vec3,
|
||||
/// The middle of the jog.
|
||||
pub jog: Vec3,
|
||||
}
|
||||
|
||||
/// A gap in a dimension's lines: everything within `size / 2` of `at` is left out.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct DimBreak {
|
||||
pub at: Vec3,
|
||||
pub size: f64,
|
||||
/// Made by DIMBREAK Auto (recomputed by the next Auto) rather than picked by hand.
|
||||
pub auto: bool,
|
||||
}
|
||||
|
||||
/// An inspection dimension (DIMINSPECT).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Inspection {
|
||||
/// Frame shape: 0 round ends, 1 angular ends, 2 none.
|
||||
pub shape: u8,
|
||||
/// Text left of the value ("" = none).
|
||||
pub label: String,
|
||||
/// Inspection rate right of the value ("" = none).
|
||||
pub rate: String,
|
||||
}
|
||||
|
||||
/// A dimension definition point that follows an object (associative dimensions).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DimAssoc {
|
||||
/// Definition point: "defpt", "p13", "p14", "p15" or "p16".
|
||||
pub point: String,
|
||||
/// The object it is attached to.
|
||||
pub handle: Handle,
|
||||
pub snap: AssocSnap,
|
||||
}
|
||||
|
||||
/// Where on the object an associative definition point sits.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum AssocSnap {
|
||||
/// Start point of a line/arc (or polyline vertex 0).
|
||||
Start,
|
||||
/// End point of a line/arc.
|
||||
End,
|
||||
Mid,
|
||||
/// Centre of a circle/arc.
|
||||
Center,
|
||||
/// On a circle/arc at a fixed angle (radians) from its centre.
|
||||
OnCircle {
|
||||
angle: f64,
|
||||
},
|
||||
/// Intersection of this (line) object with another line.
|
||||
Intersection {
|
||||
other: Handle,
|
||||
},
|
||||
/// Vertex `index` of a polyline.
|
||||
Vertex {
|
||||
index: usize,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Leader {
|
||||
pub vertices: Vec<Vec3>,
|
||||
#[serde(default = "yes")]
|
||||
pub arrow: bool,
|
||||
#[serde(default)]
|
||||
pub spline: bool,
|
||||
#[serde(default = "standard")]
|
||||
pub style: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MLeader {
|
||||
pub leaders: Vec<Vec<Vec3>>,
|
||||
pub landing: Vec3,
|
||||
pub dogleg: f64,
|
||||
pub text: Option<MText>,
|
||||
#[serde(default = "standard")]
|
||||
pub style: String,
|
||||
#[serde(default = "one")]
|
||||
pub arrow_size: f64,
|
||||
/// Leader lines are smooth curves through their points (leader type Spline). An empty
|
||||
/// `leaders` is a multileader without a leader line (leader type None).
|
||||
#[serde(default)]
|
||||
pub spline: bool,
|
||||
/// Block content in place of the text: a block reference placed at the landing.
|
||||
#[serde(default)]
|
||||
pub block: Option<Insert>,
|
||||
}
|
||||
|
||||
impl MLeader {
|
||||
/// Each leader line as drawn: through its points to the landing, smoothed for spline leaders.
|
||||
pub fn leader_paths(&self) -> Vec<Vec<Vec2>> {
|
||||
self.leaders
|
||||
.iter()
|
||||
.map(|l| {
|
||||
let pts: Vec<Vec2> = l.iter().map(|v| v.xy()).chain(std::iter::once(self.landing.xy())).collect();
|
||||
if self.spline && pts.len() > 2 { GSpline::from_fit_points(&pts).tessellate(1e-3) } else { pts }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
use super::common::Handle;
|
||||
use super::{Color, Deserialize, PolyVertex, Serialize, Vec2, one};
|
||||
|
||||
/// A hatch boundary loop: closed polyline with bulges.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct HatchLoop {
|
||||
pub vertices: Vec<PolyVertex>,
|
||||
#[serde(default)]
|
||||
pub outer: bool,
|
||||
/// The objects the loop was made from (DXF group 97 count, 330 handles): an associative
|
||||
/// hatch re-fills the loop from them when they change.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub sources: Vec<Handle>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Hatch {
|
||||
pub pattern: String,
|
||||
pub solid: bool,
|
||||
pub loops: Vec<HatchLoop>,
|
||||
#[serde(default = "one")]
|
||||
pub scale: f64,
|
||||
#[serde(default)]
|
||||
pub angle: f64,
|
||||
#[serde(default)]
|
||||
pub associative: bool,
|
||||
/// 0 normal (odd parity), 1 outer, 2 ignore.
|
||||
#[serde(default)]
|
||||
pub style: u8,
|
||||
#[serde(default)]
|
||||
pub elevation: f64,
|
||||
/// Gradient fill: name + second colour.
|
||||
#[serde(default)]
|
||||
pub gradient: Option<Gradient>,
|
||||
#[serde(default)]
|
||||
pub origin: Vec2,
|
||||
#[serde(default)]
|
||||
pub background: Option<Color>,
|
||||
/// The pattern's line families as a file defined them, for a pattern that isn't in the
|
||||
/// standard library (unit scale and angle, like the library's; empty otherwise).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub pattern_lines: Vec<crate::library::PatternLine>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Gradient {
|
||||
pub name: String,
|
||||
pub color1: Color,
|
||||
pub color2: Color,
|
||||
#[serde(default)]
|
||||
pub angle: f64,
|
||||
#[serde(default)]
|
||||
pub centered: bool,
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
//! Helices (HELIX): a spiral round a vertical axis from a base radius to a top radius, drawn as
|
||||
//! the cubic spline through points on it (DXF Reference: HELIX, a SPLINE with AcDbHelix data).
|
||||
|
||||
use cadcraft_geom::{FitOptions, KnotParam, Spline as GSpline, TAU, Vec2, Vec3};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Most turns a helix has (as the reference program allows).
|
||||
pub const MAX_HELIX_TURNS: f64 = 500.0;
|
||||
/// Points on the helix per turn that its spline runs through.
|
||||
const POINTS_PER_TURN: f64 = 8.0;
|
||||
|
||||
/// What stays fixed when the helix is edited (DXF group 280).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum HelixConstrain {
|
||||
TurnHeight,
|
||||
Turns,
|
||||
#[default]
|
||||
Height,
|
||||
}
|
||||
|
||||
impl HelixConstrain {
|
||||
pub fn to_dxf(self) -> i64 {
|
||||
match self {
|
||||
HelixConstrain::TurnHeight => 0,
|
||||
HelixConstrain::Turns => 1,
|
||||
HelixConstrain::Height => 2,
|
||||
}
|
||||
}
|
||||
pub fn from_dxf(v: i64) -> HelixConstrain {
|
||||
match v {
|
||||
0 => HelixConstrain::TurnHeight,
|
||||
1 => HelixConstrain::Turns,
|
||||
_ => HelixConstrain::Height,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Helix {
|
||||
/// Centre of the base (the axis base point).
|
||||
pub center: Vec3,
|
||||
pub base_radius: f64,
|
||||
pub top_radius: f64,
|
||||
/// Along the axis (+Z); negative goes down.
|
||||
pub height: f64,
|
||||
pub turns: f64,
|
||||
/// Angle of the start point from the centre, radians.
|
||||
#[serde(default)]
|
||||
pub start_angle: f64,
|
||||
/// Twist: counter-clockwise seen from above (right-handed).
|
||||
#[serde(default = "yes")]
|
||||
pub ccw: bool,
|
||||
#[serde(default)]
|
||||
pub constrain: HelixConstrain,
|
||||
}
|
||||
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Helix {
|
||||
/// The values made safe: finite, radii >= 0, turns within (0, 500].
|
||||
pub fn sanitized(mut self) -> Helix {
|
||||
let fin = |v: f64, d: f64| if v.is_finite() { v } else { d };
|
||||
self.center = Vec3::new(fin(self.center.x, 0.0), fin(self.center.y, 0.0), fin(self.center.z, 0.0));
|
||||
self.base_radius = fin(self.base_radius, 1.0).abs().min(1e12);
|
||||
self.top_radius = fin(self.top_radius, self.base_radius).abs().min(1e12);
|
||||
self.height = fin(self.height, 0.0).clamp(-1e12, 1e12);
|
||||
self.turns = fin(self.turns, 3.0).clamp(1e-3, MAX_HELIX_TURNS);
|
||||
self.start_angle = fin(self.start_angle, 0.0);
|
||||
self
|
||||
}
|
||||
|
||||
/// Distance between turns.
|
||||
pub fn turn_height(&self) -> f64 {
|
||||
if self.turns > 0.0 { self.height / self.turns } else { 0.0 }
|
||||
}
|
||||
|
||||
/// The point at `t` (0 = start, 1 = end) along the helix.
|
||||
pub fn point_at(&self, t: f64) -> Vec3 {
|
||||
let sign = if self.ccw { 1.0 } else { -1.0 };
|
||||
let a = self.start_angle + sign * TAU * self.turns * t;
|
||||
let r = self.base_radius + (self.top_radius - self.base_radius) * t;
|
||||
let p = Vec2::polar(self.center.xy(), r, a);
|
||||
Vec3::new(p.x, p.y, self.center.z + self.height * t)
|
||||
}
|
||||
|
||||
pub fn start_point(&self) -> Vec3 {
|
||||
self.point_at(0.0)
|
||||
}
|
||||
pub fn end_point(&self) -> Vec3 {
|
||||
self.point_at(1.0)
|
||||
}
|
||||
|
||||
/// The points the spline runs through, evenly spaced in angle.
|
||||
pub fn fit_points(&self) -> Vec<Vec3> {
|
||||
let turns = if self.turns.is_finite() { self.turns.clamp(1e-3, MAX_HELIX_TURNS) } else { 1.0 };
|
||||
let n = ((turns * POINTS_PER_TURN).ceil() as usize).max(4);
|
||||
(0..=n).map(|i| self.point_at(i as f64 / n as f64)).collect()
|
||||
}
|
||||
|
||||
/// The helix as a cubic spline seen from above, and the height of each control point: the
|
||||
/// spline through [`Helix::fit_points`] with evenly spaced knots, so the height, linear in
|
||||
/// the parameter, is exact at each control point's Greville abscissa.
|
||||
pub fn spline3(&self) -> (GSpline, Vec<f64>) {
|
||||
let fit = self.fit_points();
|
||||
let xy: Vec<Vec2> = fit.iter().map(|p| p.xy()).collect();
|
||||
let sp = GSpline::fit_with(&xy, false, FitOptions { knots: KnotParam::Uniform, ..FitOptions::default() });
|
||||
let (lo, hi) = sp.domain();
|
||||
let span = if hi - lo > 1e-12 { hi - lo } else { 1.0 };
|
||||
let p = sp.degree.max(1);
|
||||
let z = (0..sp.control.len())
|
||||
.map(|j| {
|
||||
let g = (1..=p).map(|k| sp.knots.get(j + k).copied().unwrap_or(hi)).sum::<f64>() / p as f64;
|
||||
self.center.z + self.height * ((g - lo) / span).clamp(0.0, 1.0)
|
||||
})
|
||||
.collect();
|
||||
(sp, z)
|
||||
}
|
||||
|
||||
/// The helix seen from above.
|
||||
pub fn spline(&self) -> GSpline {
|
||||
self.spline3().0
|
||||
}
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
use super::common::Handle;
|
||||
use super::{Color, Deserialize, Serialize, Vec2, Vec3, standard, yes};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Viewport {
|
||||
pub center: Vec3,
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
pub view_center: Vec2,
|
||||
pub view_height: f64,
|
||||
#[serde(default)]
|
||||
pub id: u32,
|
||||
#[serde(default)]
|
||||
pub locked: bool,
|
||||
#[serde(default)]
|
||||
pub frozen_layers: Vec<String>,
|
||||
/// Per-viewport layer colour overrides (VP Color in the Layer Properties Manager).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub layer_colors: Vec<(String, Color)>,
|
||||
/// The paper-space entity whose outline clips the viewport (a closed polyline, circle,
|
||||
/// ellipse or spline; DXF group 340). `None`: the rectangle `center`/`width`/`height`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub clip: Option<Handle>,
|
||||
/// Display on (MVIEW ON/OFF). An off viewport shows its border but no model space.
|
||||
#[serde(default = "yes")]
|
||||
pub on: bool,
|
||||
}
|
||||
|
||||
impl Default for Viewport {
|
||||
fn default() -> Self {
|
||||
Viewport {
|
||||
center: Vec3::ZERO,
|
||||
width: 1.0,
|
||||
height: 1.0,
|
||||
view_center: Vec2::ZERO,
|
||||
view_height: 1.0,
|
||||
id: 0,
|
||||
locked: false,
|
||||
frozen_layers: Vec::new(),
|
||||
layer_colors: Vec::new(),
|
||||
clip: None,
|
||||
on: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Image {
|
||||
pub insert: Vec3,
|
||||
/// One pixel's width (U) and height (V) vectors in drawing units.
|
||||
pub u: Vec3,
|
||||
pub v: Vec3,
|
||||
/// Image size in pixels.
|
||||
pub size: Vec2,
|
||||
/// The image file (its IMAGEDEF object's path).
|
||||
pub path: String,
|
||||
/// The image definition's name (its key in the drawing's image dictionary).
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub name: String,
|
||||
/// Clip boundary in pixel coordinates (origin at the top-left corner of the image, y down):
|
||||
/// two opposite corners of a rectangle, or a polygon's vertices. Empty: the whole image.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub clip: Vec<Vec2>,
|
||||
/// The clip boundary is applied.
|
||||
#[serde(default)]
|
||||
pub clipping: bool,
|
||||
/// Display flags: 1 show the image, 2 show it when not aligned with the screen, 4 use the
|
||||
/// clip boundary, 8 transparency on.
|
||||
#[serde(default = "image_display")]
|
||||
pub display: u16,
|
||||
/// Brightness, contrast and fade, 0..=100.
|
||||
#[serde(default = "image_fifty")]
|
||||
pub brightness: u8,
|
||||
#[serde(default = "image_fifty")]
|
||||
pub contrast: u8,
|
||||
#[serde(default)]
|
||||
pub fade: u8,
|
||||
/// The definition's default size of one pixel and its resolution unit (0 none, 2 cm, 5 inch).
|
||||
#[serde(default = "image_pixel")]
|
||||
pub pixel_size: Vec2,
|
||||
#[serde(default)]
|
||||
pub resolution_units: u8,
|
||||
}
|
||||
|
||||
fn image_display() -> u16 {
|
||||
7
|
||||
}
|
||||
|
||||
fn image_fifty() -> u8 {
|
||||
50
|
||||
}
|
||||
|
||||
fn image_pixel() -> Vec2 {
|
||||
Vec2::new(1.0, 1.0)
|
||||
}
|
||||
|
||||
impl Default for Image {
|
||||
fn default() -> Self {
|
||||
Image {
|
||||
insert: Vec3::ZERO,
|
||||
u: Vec3::new(1.0, 0.0, 0.0),
|
||||
v: Vec3::new(0.0, 1.0, 0.0),
|
||||
size: Vec2::new(1.0, 1.0),
|
||||
path: String::new(),
|
||||
name: String::new(),
|
||||
clip: Vec::new(),
|
||||
clipping: false,
|
||||
display: image_display(),
|
||||
brightness: image_fifty(),
|
||||
contrast: image_fifty(),
|
||||
fade: 0,
|
||||
pixel_size: image_pixel(),
|
||||
resolution_units: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TableCell {
|
||||
pub text: String,
|
||||
#[serde(default)]
|
||||
pub merged: Option<(u32, u32)>,
|
||||
/// The cell's own cell style; empty: its row's.
|
||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||
pub style: String,
|
||||
/// A formula (`=Sum(A1:B3)`); `text` holds its value.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub formula: Option<String>,
|
||||
/// The cell's own text height (DXF 140 in the cell record), before the table's scale;
|
||||
/// `None`: its cell style's.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub text_height: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Table {
|
||||
pub insert: Vec3,
|
||||
pub col_widths: Vec<f64>,
|
||||
pub row_heights: Vec<f64>,
|
||||
pub cells: Vec<Vec<TableCell>>,
|
||||
#[serde(default = "standard")]
|
||||
pub style: String,
|
||||
#[serde(default = "default_text_h")]
|
||||
pub text_height: f64,
|
||||
/// First row is a title (merged across, larger text).
|
||||
#[serde(default)]
|
||||
pub title: bool,
|
||||
/// The row after the title holds column headers.
|
||||
#[serde(default)]
|
||||
pub header: bool,
|
||||
/// The cell style of each row; missing or empty: by position (Title, Header, Data).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub row_styles: Vec<String>,
|
||||
/// Table breaking (TABLEBREAK): the table drawn in several parts.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub breaks: Option<crate::TableBreak>,
|
||||
/// The CSV file the cells come from (DATALINK).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub data_link: Option<crate::DataLink>,
|
||||
}
|
||||
|
||||
fn default_text_h() -> f64 {
|
||||
0.18
|
||||
}
|
||||
@@ -1,428 +0,0 @@
|
||||
//! Multilines (MLINE) and multiline styles (MLINESTYLE): parallel lines — the style's
|
||||
//! elements — run at fixed offsets along a path of vertices, with optional caps, joints and a
|
||||
//! fill between the outermost elements (DXF Reference: MLINE entity, MLINESTYLE object).
|
||||
//!
|
||||
//! An [`MLine`] carries a copy of its style ([`MLine::def`]), so its geometry needs nothing
|
||||
//! else from the drawing: snapping, selection and trimming work from the entity alone.
|
||||
|
||||
use cadcraft_color::Color;
|
||||
use cadcraft_geom::{Arc as GArc, Line as GLine, PI, Segment, Vec2, Vec3};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Most elements a multiline style has (as in the style dialog of the reference program).
|
||||
pub const MAX_MLINE_ELEMENTS: usize = 16;
|
||||
/// Most vertices of one multiline (hostile files).
|
||||
pub const MAX_MLINE_VERTICES: usize = 100_000;
|
||||
/// Most breaks per vertex (all elements together).
|
||||
pub const MAX_MLINE_CUTS: usize = 1_000;
|
||||
/// The style every drawing has.
|
||||
pub const STANDARD_MLINE_STYLE: &str = "STANDARD";
|
||||
|
||||
/// One line of a multiline style: its offset from the style's zero line (+ = left of the
|
||||
/// drawing direction), colour and linetype.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct MLineElement {
|
||||
pub offset: f64,
|
||||
pub color: Color,
|
||||
pub linetype: String,
|
||||
}
|
||||
|
||||
impl Default for MLineElement {
|
||||
fn default() -> Self {
|
||||
MLineElement { offset: 0.0, color: Color::ByLayer, linetype: "ByLayer".into() }
|
||||
}
|
||||
}
|
||||
|
||||
/// The caps at one end of a multiline.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct MLineCaps {
|
||||
/// A line across all elements.
|
||||
pub line: bool,
|
||||
/// A half circle joining the outermost elements.
|
||||
pub outer_arc: bool,
|
||||
/// Half circles joining pairs of inner elements.
|
||||
pub inner_arcs: bool,
|
||||
/// Angle of the end against the drawing direction, radians (π/2 = square).
|
||||
pub angle: f64,
|
||||
}
|
||||
|
||||
impl Default for MLineCaps {
|
||||
fn default() -> Self {
|
||||
MLineCaps { line: false, outer_arc: false, inner_arcs: false, angle: PI / 2.0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// A multiline style (MLSTYLE).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct MLineStyle {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
/// Elements from the largest offset down.
|
||||
pub elements: Vec<MLineElement>,
|
||||
pub start_caps: MLineCaps,
|
||||
pub end_caps: MLineCaps,
|
||||
/// Lines across all elements at every vertex ("display joints").
|
||||
pub joints: bool,
|
||||
/// Fill colour between the outermost elements; `None` = no fill.
|
||||
pub fill: Option<Color>,
|
||||
}
|
||||
|
||||
impl Default for MLineStyle {
|
||||
/// The STANDARD style: two lines one unit apart, no caps, no fill.
|
||||
fn default() -> Self {
|
||||
MLineStyle {
|
||||
name: STANDARD_MLINE_STYLE.into(),
|
||||
description: String::new(),
|
||||
elements: vec![MLineElement { offset: 0.5, ..Default::default() }, MLineElement { offset: -0.5, ..Default::default() }],
|
||||
start_caps: MLineCaps::default(),
|
||||
end_caps: MLineCaps::default(),
|
||||
joints: false,
|
||||
fill: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MLineStyle {
|
||||
/// The style with hostile values made safe: 1..=16 finite elements sorted by offset (largest
|
||||
/// first), cap angles within 10°..170°.
|
||||
pub fn sanitized(mut self) -> MLineStyle {
|
||||
self.elements.retain(|e| e.offset.is_finite() && e.offset.abs() < 1e12);
|
||||
self.elements.truncate(MAX_MLINE_ELEMENTS);
|
||||
if self.elements.is_empty() {
|
||||
self.elements.push(MLineElement::default());
|
||||
}
|
||||
self.elements.sort_by(|a, b| b.offset.total_cmp(&a.offset));
|
||||
for c in [&mut self.start_caps, &mut self.end_caps] {
|
||||
c.angle = if c.angle.is_finite() { c.angle.clamp(10f64.to_radians(), 170f64.to_radians()) } else { PI / 2.0 };
|
||||
}
|
||||
self
|
||||
}
|
||||
pub fn max_offset(&self) -> f64 {
|
||||
self.elements.iter().map(|e| e.offset).fold(f64::NEG_INFINITY, f64::max).max(-1e12)
|
||||
}
|
||||
pub fn min_offset(&self) -> f64 {
|
||||
self.elements.iter().map(|e| e.offset).fold(f64::INFINITY, f64::min).min(1e12)
|
||||
}
|
||||
}
|
||||
|
||||
/// Which line of the style runs through the picked points.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum MLineJust {
|
||||
/// The element with the largest offset.
|
||||
#[default]
|
||||
Top,
|
||||
/// The style's zero line.
|
||||
Zero,
|
||||
/// The element with the smallest offset.
|
||||
Bottom,
|
||||
}
|
||||
|
||||
impl MLineJust {
|
||||
/// DXF group 70 of MLINE (and CMLJUST).
|
||||
pub fn to_dxf(self) -> i64 {
|
||||
match self {
|
||||
MLineJust::Top => 0,
|
||||
MLineJust::Zero => 1,
|
||||
MLineJust::Bottom => 2,
|
||||
}
|
||||
}
|
||||
pub fn from_dxf(v: i64) -> MLineJust {
|
||||
match v {
|
||||
1 => MLineJust::Zero,
|
||||
2 => MLineJust::Bottom,
|
||||
_ => MLineJust::Top,
|
||||
}
|
||||
}
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
MLineJust::Top => "Top",
|
||||
MLineJust::Zero => "Zero",
|
||||
MLineJust::Bottom => "Bottom",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A break in one element along the segment that starts at a vertex: distances from the
|
||||
/// element's start on that segment (MLEDIT Cut).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MLineCut {
|
||||
pub element: usize,
|
||||
pub start: f64,
|
||||
pub end: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MLineVertex {
|
||||
pub p: Vec3,
|
||||
/// Breaks in the segment that starts here.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub cuts: Vec<MLineCut>,
|
||||
}
|
||||
|
||||
impl MLineVertex {
|
||||
pub fn new(p: Vec3) -> Self {
|
||||
MLineVertex { p, cuts: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
/// A multiline.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MLine {
|
||||
/// Style name (the MLINESTYLE it was drawn with).
|
||||
pub style: String,
|
||||
/// The style's elements, caps, joints and fill as this multiline uses them.
|
||||
#[serde(default)]
|
||||
pub def: MLineStyle,
|
||||
/// Element offsets are multiplied by this; negative flips the elements to the other side.
|
||||
#[serde(default = "one")]
|
||||
pub scale: f64,
|
||||
#[serde(default)]
|
||||
pub justification: MLineJust,
|
||||
#[serde(default)]
|
||||
pub closed: bool,
|
||||
#[serde(default)]
|
||||
pub no_start_caps: bool,
|
||||
#[serde(default)]
|
||||
pub no_end_caps: bool,
|
||||
pub vertices: Vec<MLineVertex>,
|
||||
}
|
||||
|
||||
fn one() -> f64 {
|
||||
1.0
|
||||
}
|
||||
|
||||
/// A multiline's drawn geometry.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct MLineGeometry {
|
||||
/// Element pieces: (element index into `def.elements`, from, to).
|
||||
pub lines: Vec<(usize, Vec2, Vec2)>,
|
||||
/// Caps and joints, drawn with the multiline's own properties.
|
||||
pub caps: Vec<Segment>,
|
||||
/// The fill between the outermost elements (even-odd loops); empty without fill.
|
||||
pub fill: Vec<Vec<Vec2>>,
|
||||
}
|
||||
|
||||
impl MLine {
|
||||
/// A multiline through `pts` in style `def`.
|
||||
pub fn new(def: &MLineStyle, pts: &[Vec2], closed: bool, just: MLineJust, scale: f64) -> MLine {
|
||||
MLine {
|
||||
style: def.name.clone(),
|
||||
def: def.clone(),
|
||||
scale,
|
||||
justification: just,
|
||||
closed,
|
||||
no_start_caps: false,
|
||||
no_end_caps: false,
|
||||
vertices: pts.iter().map(|p| MLineVertex::new(p.to3(0.0))).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The offset the vertex path sits at, in style units.
|
||||
pub fn ref_offset(&self) -> f64 {
|
||||
match self.justification {
|
||||
MLineJust::Top => self.def.max_offset(),
|
||||
MLineJust::Zero => 0.0,
|
||||
MLineJust::Bottom => self.def.min_offset(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Each element's distance from the vertex path (+ = left of the drawing direction).
|
||||
pub fn offsets(&self) -> Vec<f64> {
|
||||
let r = self.ref_offset();
|
||||
let s = if self.scale.is_finite() { self.scale } else { 1.0 };
|
||||
self.def.elements.iter().map(|e| (e.offset - r) * s).collect()
|
||||
}
|
||||
|
||||
pub fn points(&self) -> Vec<Vec2> {
|
||||
self.vertices.iter().map(|v| v.p.xy()).collect()
|
||||
}
|
||||
|
||||
/// Number of segments (the closing one included).
|
||||
pub fn segment_count(&self) -> usize {
|
||||
let n = self.vertices.len();
|
||||
if n < 2 {
|
||||
0
|
||||
} else if self.closed {
|
||||
n
|
||||
} else {
|
||||
n - 1
|
||||
}
|
||||
}
|
||||
|
||||
/// Unit direction of each segment (zero for a degenerate one).
|
||||
pub fn directions(&self) -> Vec<Vec2> {
|
||||
let pts = self.points();
|
||||
let n = pts.len();
|
||||
(0..self.segment_count())
|
||||
.map(|i| (pts.get((i + 1) % n.max(1)).copied().unwrap_or_default() - pts.get(i).copied().unwrap_or_default()).normalized())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The miter vector at each vertex: the element at offset `d` passes through `v + m * d`.
|
||||
pub fn miters(&self) -> Vec<Vec2> {
|
||||
let dirs = self.directions();
|
||||
let n = self.vertices.len();
|
||||
let nseg = dirs.len();
|
||||
// A degenerate segment takes its neighbour's direction.
|
||||
let dir = |i: usize| -> Vec2 {
|
||||
(0..nseg).map(|k| dirs.get((i + k) % nseg.max(1)).copied().unwrap_or_default()).find(|d| *d != Vec2::ZERO).unwrap_or(Vec2::X)
|
||||
};
|
||||
let dir_back = |i: usize| -> Vec2 {
|
||||
(0..nseg)
|
||||
.map(|k| dirs.get((i + nseg - k % nseg) % nseg.max(1)).copied().unwrap_or_default())
|
||||
.find(|d| *d != Vec2::ZERO)
|
||||
.unwrap_or(Vec2::X)
|
||||
};
|
||||
let end = |d: Vec2, angle: f64| {
|
||||
let u = d.rotate(angle);
|
||||
let s = angle.sin();
|
||||
if s.abs() < 1e-6 { d.perp() } else { u / s }
|
||||
};
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
if nseg == 0 {
|
||||
return Vec2::Y;
|
||||
}
|
||||
let open_end = !self.closed && (i == 0 || i + 1 == n);
|
||||
if open_end {
|
||||
return if i == 0 { end(dir(0), self.def.start_caps.angle) } else { end(dir_back(n - 2), PI - self.def.end_caps.angle) };
|
||||
}
|
||||
let d_in = dir_back((i + nseg - 1) % nseg);
|
||||
let d_out = dir(i % nseg);
|
||||
let (n1, n2) = (d_in.perp(), d_out.perp());
|
||||
let b = (n1 + n2).normalized();
|
||||
if b == Vec2::ZERO {
|
||||
return n1;
|
||||
}
|
||||
// Keep sharp corners finite.
|
||||
b / b.dot(n1).max(0.05)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The drawn geometry: element pieces (breaks taken out), caps, joints and fill.
|
||||
pub fn geometry(&self) -> MLineGeometry {
|
||||
let mut g = MLineGeometry::default();
|
||||
let pts = self.points();
|
||||
let n = pts.len();
|
||||
let nseg = self.segment_count();
|
||||
if nseg == 0 || pts.iter().any(|p| !p.is_finite()) {
|
||||
return g;
|
||||
}
|
||||
let miters = self.miters();
|
||||
let offs = self.offsets();
|
||||
let at = |k: usize, d: f64| pts.get(k % n).copied().unwrap_or_default() + miters.get(k % n).copied().unwrap_or_default() * d;
|
||||
for k in 0..nseg {
|
||||
let cuts = self.vertices.get(k).map(|v| v.cuts.as_slice()).unwrap_or(&[]);
|
||||
for (e, d) in offs.iter().enumerate() {
|
||||
let (a, b) = (at(k, *d), at(k + 1, *d));
|
||||
let len = a.dist(b);
|
||||
if len <= 1e-12 {
|
||||
continue;
|
||||
}
|
||||
let dir = (b - a) / len;
|
||||
for (s, t) in pieces(len, cuts.iter().filter(|c| c.element == e).map(|c| (c.start, c.end))) {
|
||||
g.lines.push((e, a + dir * s, a + dir * t));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Elements by offset, largest (leftmost) first.
|
||||
let mut order: Vec<usize> = (0..offs.len()).collect();
|
||||
order.sort_by(|a, b| offs.get(*b).copied().unwrap_or(0.0).total_cmp(&offs.get(*a).copied().unwrap_or(0.0)));
|
||||
let (Some(&top), Some(&bottom)) = (order.first(), order.last()) else { return g };
|
||||
let off = |e: usize| offs.get(e).copied().unwrap_or(0.0);
|
||||
let across = |k: usize| Segment::Line(GLine::new(at(k, off(top)), at(k, off(bottom))));
|
||||
if !self.closed {
|
||||
for (k, caps, skip, start) in [(0, self.def.start_caps, self.no_start_caps, true), (n - 1, self.def.end_caps, self.no_end_caps, false)] {
|
||||
if skip {
|
||||
continue;
|
||||
}
|
||||
if caps.line && order.len() > 1 {
|
||||
g.caps.push(across(k));
|
||||
}
|
||||
let pairs = order.len() / 2;
|
||||
for i in 0..pairs {
|
||||
if (i == 0 && !caps.outer_arc) || (i > 0 && !caps.inner_arcs) {
|
||||
continue;
|
||||
}
|
||||
let (Some(&ea), Some(&eb)) = (order.get(i), order.get(order.len() - 1 - i)) else { continue };
|
||||
let (a, b) = (at(k, off(ea)), at(k, off(eb)));
|
||||
if let Some(arc) = half_circle(a, b, start) {
|
||||
g.caps.push(arc);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.def.joints && order.len() > 1 {
|
||||
let inner = if self.closed { 0..n } else { 1..n.saturating_sub(1) };
|
||||
for k in inner {
|
||||
g.caps.push(across(k));
|
||||
}
|
||||
}
|
||||
if self.def.fill.is_some() {
|
||||
let side = |e: usize| (0..n).map(|k| at(k, off(e))).collect::<Vec<Vec2>>();
|
||||
if self.closed {
|
||||
g.fill = vec![side(top), side(bottom)];
|
||||
} else {
|
||||
let mut poly = side(top);
|
||||
poly.extend(side(bottom).into_iter().rev());
|
||||
g.fill = vec![poly];
|
||||
}
|
||||
}
|
||||
g
|
||||
}
|
||||
|
||||
/// Segments of every element piece, cap and joint.
|
||||
pub fn segments(&self) -> Vec<Segment> {
|
||||
let g = self.geometry();
|
||||
g.lines.iter().map(|(_, a, b)| Segment::Line(GLine::new(*a, *b))).chain(g.caps).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// The parts of `[0, len]` left after taking out the `gaps` (each `(start, end)`, any order).
|
||||
fn pieces(len: f64, gaps: impl Iterator<Item = (f64, f64)>) -> Vec<(f64, f64)> {
|
||||
let mut gaps: Vec<(f64, f64)> =
|
||||
gaps.filter(|(a, b)| a.is_finite() && b.is_finite()).map(|(a, b)| (a.min(b).max(0.0), a.max(b).min(len))).filter(|(a, b)| b > a).collect();
|
||||
gaps.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
let mut out = Vec::new();
|
||||
let mut from = 0.0;
|
||||
for (a, b) in gaps {
|
||||
if a > from + 1e-12 {
|
||||
out.push((from, a));
|
||||
}
|
||||
from = f64::max(from, b);
|
||||
}
|
||||
if len > from + 1e-12 {
|
||||
out.push((from, len));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A half circle from `a` to `b` bulging backwards (`start`) or forwards along the multiline.
|
||||
fn half_circle(a: Vec2, b: Vec2, start: bool) -> Option<Segment> {
|
||||
let r = a.dist(b) / 2.0;
|
||||
if r <= 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let c = a.mid(b);
|
||||
let (sa, sb) = (c.angle_to(a), c.angle_to(b));
|
||||
// From the left element to the right one: counter-clockwise passes behind the start.
|
||||
Some(if start { Segment::Arc { arc: GArc::new(c, r, sa, sb), ccw: true } } else { Segment::Arc { arc: GArc::new(c, r, sb, sa), ccw: false } })
|
||||
}
|
||||
|
||||
impl crate::Drawing {
|
||||
/// The multiline style named `name` (any case).
|
||||
pub fn mline_style(&self, name: &str) -> Option<&MLineStyle> {
|
||||
self.mline_styles.iter().find(|s| s.name.eq_ignore_ascii_case(name.trim()))
|
||||
}
|
||||
/// The current multiline style (CMLSTYLE), STANDARD when it is missing.
|
||||
pub fn current_mline_style(&self) -> MLineStyle {
|
||||
let name = self.header.str("CMLSTYLE", STANDARD_MLINE_STYLE);
|
||||
self.mline_style(&name).or_else(|| self.mline_style(STANDARD_MLINE_STYLE)).cloned().unwrap_or_default()
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
//! Regions (REGION): a closed planar area bounded by loops of lines and arcs. Outer loops add
|
||||
//! area, island loops (holes) take it away; loops don't cross.
|
||||
|
||||
use cadcraft_geom::{Bounds2, PolyVertex, Polyline, Segment, Vec2, point_in_polygon};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::HatchLoop;
|
||||
|
||||
/// Most loops one region has (hostile files and edits).
|
||||
pub const MAX_REGION_LOOPS: usize = 10_000;
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Region {
|
||||
/// Closed loops with bulges; `outer` false for islands.
|
||||
pub loops: Vec<HatchLoop>,
|
||||
}
|
||||
|
||||
/// Area properties of a region (MASSPROP), in drawing units.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct RegionProps {
|
||||
pub area: f64,
|
||||
pub perimeter: f64,
|
||||
pub bounds: Bounds2,
|
||||
pub centroid: Vec2,
|
||||
/// ∫y² dA and ∫x² dA about the origin.
|
||||
pub ixx: f64,
|
||||
pub iyy: f64,
|
||||
/// ∫xy dA about the origin.
|
||||
pub ixy: f64,
|
||||
/// The same three about the centroid.
|
||||
pub centroid_moments: [f64; 3],
|
||||
}
|
||||
|
||||
impl Region {
|
||||
/// A region bounded by one closed loop.
|
||||
pub fn from_loop(vertices: Vec<PolyVertex>) -> Region {
|
||||
Region { loops: vec![HatchLoop { vertices, outer: true, sources: Vec::new() }] }
|
||||
}
|
||||
|
||||
pub fn polylines(&self) -> Vec<Polyline> {
|
||||
self.loops.iter().map(|l| Polyline { vertices: l.vertices.clone(), closed: true }).collect()
|
||||
}
|
||||
|
||||
/// The boundary: every loop's lines and arcs.
|
||||
pub fn segments(&self) -> Vec<Segment> {
|
||||
self.polylines().iter().flat_map(Polyline::segments).collect()
|
||||
}
|
||||
|
||||
/// Each loop as points, arcs within `tol`.
|
||||
pub fn tessellate(&self, tol: f64) -> Vec<Vec<Vec2>> {
|
||||
self.polylines().iter().map(|p| p.tessellate(tol)).collect()
|
||||
}
|
||||
|
||||
pub fn bounds(&self) -> Bounds2 {
|
||||
self.polylines().iter().fold(Bounds2::EMPTY, |b, p| b.union(&p.bounds()))
|
||||
}
|
||||
|
||||
/// Outer loops' area less the islands'.
|
||||
pub fn area(&self) -> f64 {
|
||||
self.loops.iter().map(|l| Polyline { vertices: l.vertices.clone(), closed: true }.area().abs() * if l.outer { 1.0 } else { -1.0 }).sum()
|
||||
}
|
||||
|
||||
pub fn perimeter(&self) -> f64 {
|
||||
self.polylines().iter().map(Polyline::len).sum()
|
||||
}
|
||||
|
||||
/// A chord tolerance fine enough for measuring: 1e-5 of the region's size.
|
||||
fn fine_tol(&self) -> f64 {
|
||||
let b = self.bounds();
|
||||
if b.is_empty() { 1e-6 } else { ((b.width() + b.height()) * 1e-5).max(1e-12) }
|
||||
}
|
||||
|
||||
/// Whether `p` lies inside the region (odd parity over its loops).
|
||||
pub fn contains(&self, p: Vec2) -> bool {
|
||||
self.tessellate(self.fine_tol()).iter().filter(|l| point_in_polygon(l, p)).count() % 2 == 1
|
||||
}
|
||||
|
||||
/// Set each loop's `outer` flag from how deeply other loops enclose it: an even depth is
|
||||
/// an outer loop, odd an island.
|
||||
pub fn renest(&mut self) {
|
||||
let polys = self.tessellate(self.fine_tol());
|
||||
let depth: Vec<usize> = polys
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, l)| {
|
||||
let Some(p) = probe(l) else { return 0 };
|
||||
polys.iter().enumerate().filter(|(j, o)| *j != i && point_in_polygon(o, p)).count()
|
||||
})
|
||||
.collect();
|
||||
for (l, d) in self.loops.iter_mut().zip(depth) {
|
||||
l.outer = d % 2 == 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Area, perimeter, bounding box, centroid and second moments (outer loops counted
|
||||
/// counter-clockwise, islands clockwise).
|
||||
pub fn props(&self) -> RegionProps {
|
||||
let tol = self.fine_tol();
|
||||
let (mut a, mut cx, mut cy, mut ixx, mut iyy, mut ixy) = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
|
||||
for (l, pts) in self.loops.iter().zip(self.tessellate(tol)) {
|
||||
let signed = cadcraft_geom::shoelace(&pts);
|
||||
// Outer loops add, islands subtract, whatever way they run.
|
||||
let flip = if (signed >= 0.0) == l.outer { 1.0 } else { -1.0 };
|
||||
let n = pts.len();
|
||||
for i in 0..n {
|
||||
let (Some(p), Some(q)) = (pts.get(i), pts.get((i + 1) % n)) else { continue };
|
||||
let c = (p.x * q.y - q.x * p.y) * flip;
|
||||
a += c / 2.0;
|
||||
cx += (p.x + q.x) * c / 6.0;
|
||||
cy += (p.y + q.y) * c / 6.0;
|
||||
ixx += (p.y * p.y + p.y * q.y + q.y * q.y) * c / 12.0;
|
||||
iyy += (p.x * p.x + p.x * q.x + q.x * q.x) * c / 12.0;
|
||||
ixy += (p.x * q.y + 2.0 * p.x * p.y + 2.0 * q.x * q.y + q.x * p.y) * c / 24.0;
|
||||
}
|
||||
}
|
||||
let c = if a.abs() > 1e-300 { Vec2::new(cx / a, cy / a) } else { Vec2::ZERO };
|
||||
// Shifted with the area of the same outline the moments were taken from.
|
||||
let centroid_moments = [ixx - a * c.y * c.y, iyy - a * c.x * c.x, ixy - a * c.x * c.y];
|
||||
RegionProps { area: self.area(), perimeter: self.perimeter(), bounds: self.bounds(), centroid: c, ixx, iyy, ixy, centroid_moments }
|
||||
}
|
||||
}
|
||||
|
||||
/// A point of a loop to test its nesting with: the middle of its first non-degenerate edge.
|
||||
fn probe(l: &[Vec2]) -> Option<Vec2> {
|
||||
let n = l.len();
|
||||
(0..n).find_map(|i| {
|
||||
let (a, b) = (*l.get(i)?, *l.get((i + 1) % n)?);
|
||||
(a.dist(b) > 1e-12).then(|| (a + b) * 0.5).filter(|m| m.is_finite())
|
||||
})
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
use super::{Deserialize, Serialize, Vec2, Vec3, one, standard};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum HAlign {
|
||||
#[default]
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
Aligned,
|
||||
Middle,
|
||||
Fit,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum VAlign {
|
||||
#[default]
|
||||
Baseline,
|
||||
Bottom,
|
||||
Middle,
|
||||
Top,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Text {
|
||||
pub insert: Vec3,
|
||||
#[serde(default)]
|
||||
pub align_pt: Option<Vec3>,
|
||||
pub height: f64,
|
||||
pub value: String,
|
||||
#[serde(default)]
|
||||
pub rotation: f64,
|
||||
#[serde(default = "one")]
|
||||
pub width_factor: f64,
|
||||
#[serde(default)]
|
||||
pub oblique: f64,
|
||||
#[serde(default = "standard")]
|
||||
pub style: String,
|
||||
#[serde(default)]
|
||||
pub halign: HAlign,
|
||||
#[serde(default)]
|
||||
pub valign: VAlign,
|
||||
}
|
||||
|
||||
impl Text {
|
||||
/// The point the text is placed by, as the Insertion snap and the grip show it: the alignment
|
||||
/// point (DXF group 11) of justified text, the start point (group 10) of left-baseline,
|
||||
/// aligned and fit text.
|
||||
pub fn justify_point(&self) -> Vec2 {
|
||||
match (self.halign, self.valign) {
|
||||
(HAlign::Left, VAlign::Baseline) | (HAlign::Aligned | HAlign::Fit, _) => self.insert.xy(),
|
||||
_ => self.align_pt.unwrap_or(self.insert).xy(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MText {
|
||||
pub insert: Vec3,
|
||||
pub height: f64,
|
||||
/// Reference rectangle width (0 = no wrapping).
|
||||
#[serde(default)]
|
||||
pub width: f64,
|
||||
/// 1..=9: TL TC TR ML MC MR BL BC BR.
|
||||
#[serde(default = "attach_tl")]
|
||||
pub attach: u8,
|
||||
#[serde(default)]
|
||||
pub rotation: f64,
|
||||
#[serde(default = "standard")]
|
||||
pub style: String,
|
||||
/// Raw MTEXT contents with inline codes (\P, {\fArial|b1;...}, \S1/2; …).
|
||||
pub contents: String,
|
||||
#[serde(default = "one")]
|
||||
pub line_spacing: f64,
|
||||
/// Line spacing style (DXF 73): Exactly (`true`) keeps `line_spacing` whatever the character
|
||||
/// heights; At least (`false`, the default) lets taller characters push lines apart.
|
||||
#[serde(default)]
|
||||
pub line_spacing_exact: bool,
|
||||
/// Columns the text flows through, each `width` wide (DXF 75/76/78/79/48/49/50); `None`:
|
||||
/// one column.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub columns: Option<MTextColumns>,
|
||||
}
|
||||
|
||||
fn attach_tl() -> u8 {
|
||||
1
|
||||
}
|
||||
|
||||
/// Static columns: a fixed number of columns of one height. Dynamic columns: as many columns
|
||||
/// as the text needs, of one height (auto height) or each its own (manual heights).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum MTextColumnType {
|
||||
#[default]
|
||||
Static,
|
||||
Dynamic,
|
||||
}
|
||||
|
||||
/// MTEXT columns (MTEXT Columns option, DXF Reference MTEXT groups 75–79, 48–50).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct MTextColumns {
|
||||
#[serde(rename = "type")]
|
||||
pub kind: MTextColumnType,
|
||||
/// Number of columns (static columns: the columns there are).
|
||||
pub count: u16,
|
||||
/// Space between two columns.
|
||||
pub gutter: f64,
|
||||
/// Height of every column (static, and dynamic with auto height).
|
||||
pub height: f64,
|
||||
/// Dynamic columns: one height for all columns (`height`) instead of `heights`.
|
||||
pub auto_height: bool,
|
||||
/// Dynamic columns with manual heights: the height of each column.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub heights: Vec<f64>,
|
||||
/// Columns run right to left.
|
||||
pub flow_reversed: bool,
|
||||
}
|
||||
|
||||
impl MTextColumns {
|
||||
/// The width of `count` columns of `column_width` and their gutters.
|
||||
pub fn total_width(&self, column_width: f64) -> f64 {
|
||||
let n = f64::from(self.count.max(1));
|
||||
column_width * n + self.gutter.max(0.0) * (n - 1.0)
|
||||
}
|
||||
|
||||
/// The tallest column.
|
||||
pub fn max_height(&self) -> f64 {
|
||||
self.heights.iter().copied().fold(self.height, f64::max).max(0.0)
|
||||
}
|
||||
|
||||
/// Scale every length (SCALE, block insertion).
|
||||
pub fn scale(&mut self, s: f64) {
|
||||
self.gutter *= s;
|
||||
self.height *= s;
|
||||
self.heights.iter_mut().for_each(|h| *h *= s);
|
||||
}
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
//! WIPEOUT is always a closed masking polygon, unlike a polyline with an open/closed flag.
|
||||
//! One exact terminal copy of the first point is a closure marker, not an editable corner.
|
||||
//! Preserve logical corners when editing, adding a separate marker if the endpoints meet.
|
||||
//! Grips, edits and explicit outlines share this rule; other duplicates are not simplified.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use cadcraft_geom::Vec2;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Wipeout {
|
||||
/// Ordered boundary, implicitly closed or ending in a repeated first point.
|
||||
pub boundary: Vec<Vec2>,
|
||||
}
|
||||
|
||||
impl Wipeout {
|
||||
/// Editable vertices, excluding one exactly repeated closing point, without changing storage.
|
||||
pub fn vertices(&self) -> &[Vec2] {
|
||||
match self.boundary.split_last() {
|
||||
Some((last, vertices)) if vertices.first() == Some(last) => vertices,
|
||||
_ => &self.boundary,
|
||||
}
|
||||
}
|
||||
|
||||
/// An explicitly closed outline for geometry, rendering and export. Already closed or
|
||||
/// empty/single-point boundaries are borrowed; otherwise one closing point is appended.
|
||||
pub fn closed_boundary(&self) -> Cow<'_, [Vec2]> {
|
||||
if self.vertices().len() < self.boundary.len() || self.boundary.len() < 2 {
|
||||
Cow::Borrowed(&self.boundary)
|
||||
} else {
|
||||
let mut points = self.boundary.clone();
|
||||
points.extend(self.boundary.first());
|
||||
Cow::Owned(points)
|
||||
}
|
||||
}
|
||||
|
||||
/// Move an editable vertex, carrying an existing closing marker with vertex zero. If an
|
||||
/// implicit boundary's endpoints meet, add a marker so both corners remain addressable.
|
||||
/// Invalid indices/non-finite points leave the boundary unchanged.
|
||||
pub fn set_vertex(&mut self, index: usize, to: Vec2) -> Option<()> {
|
||||
let count = self.vertices().len();
|
||||
if index >= count || !to.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let explicit = count < self.boundary.len();
|
||||
*self.boundary.get_mut(index)? = to;
|
||||
if explicit
|
||||
&& index == 0
|
||||
&& let Some(last) = self.boundary.last_mut()
|
||||
{
|
||||
*last = to;
|
||||
}
|
||||
if !explicit && self.vertices().len() < count {
|
||||
// The moved endpoint is a logical corner, not the newly implied closing marker.
|
||||
if let Some(first) = self.boundary.first().copied() {
|
||||
self.boundary.push(first);
|
||||
}
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::EntityKind;
|
||||
|
||||
#[test]
|
||||
fn vertices_and_grips_exclude_only_the_exact_closing_point() {
|
||||
let (a, b, c) = (Vec2::ZERO, Vec2::new(7.0, 1.0), Vec2::new(3.0, 7.0));
|
||||
let near_a = Vec2::new(1e-13, 0.0);
|
||||
for (boundary, expected) in [
|
||||
(vec![], vec![]),
|
||||
(vec![a], vec![a]),
|
||||
(vec![a, a], vec![a]),
|
||||
(vec![a, b, c], vec![a, b, c]),
|
||||
(vec![a, b, c, a], vec![a, b, c]),
|
||||
(vec![a, b, a, c, a], vec![a, b, a, c]),
|
||||
(vec![a, b, c, a, a], vec![a, b, c, a]),
|
||||
(vec![a, b, c, near_a], vec![a, b, c, near_a]),
|
||||
] {
|
||||
let wipeout = Wipeout { boundary: boundary.clone() };
|
||||
assert_eq!(wipeout.vertices(), expected);
|
||||
assert_eq!(EntityKind::Wipeout(wipeout.clone()).grips(), expected);
|
||||
assert_eq!(wipeout.boundary, boundary);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_outline_is_stable_and_does_not_add_zero_length_closing_edges() {
|
||||
let points = vec![Vec2::ZERO, Vec2::new(7.0, 1.0), Vec2::new(3.0, 7.0)];
|
||||
let implicit = Wipeout { boundary: points.clone() };
|
||||
let mut expected = points.clone();
|
||||
expected.push(points[0]);
|
||||
assert!(matches!(implicit.closed_boundary(), Cow::Owned(_)));
|
||||
assert_eq!(implicit.closed_boundary().as_ref(), expected);
|
||||
assert_eq!(implicit.boundary, points);
|
||||
let explicit = Wipeout { boundary: expected.clone() };
|
||||
assert!(matches!(explicit.closed_boundary(), Cow::Borrowed(_)));
|
||||
assert_eq!(explicit.closed_boundary().as_ref(), expected);
|
||||
let a = EntityKind::Wipeout(implicit).prims();
|
||||
let b = EntityKind::Wipeout(explicit).prims();
|
||||
assert_eq!(a, b);
|
||||
assert_eq!(a.len(), 3);
|
||||
for boundary in [vec![], vec![Vec2::ZERO]] {
|
||||
let wipeout = Wipeout { boundary: boundary.clone() };
|
||||
assert!(matches!(wipeout.closed_boundary(), Cow::Borrowed(_)));
|
||||
assert_eq!(wipeout.closed_boundary().as_ref(), boundary);
|
||||
assert!(EntityKind::Wipeout(wipeout).prims().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_endpoints_together_keeps_both_corners_editable() {
|
||||
let points = [Vec2::ZERO, Vec2::new(8.0, 0.0), Vec2::new(8.0, 6.0), Vec2::new(0.0, 6.0)];
|
||||
for explicit in [false, true] {
|
||||
for index in [0, 3] {
|
||||
let other = 3 - index;
|
||||
let mut boundary = points.to_vec();
|
||||
if explicit {
|
||||
boundary.push(points[0]);
|
||||
}
|
||||
let mut wipeout = Wipeout { boundary };
|
||||
let mut expected = points;
|
||||
expected[index] = points[other];
|
||||
for _ in 0..2 {
|
||||
assert_eq!(wipeout.set_vertex(index, points[other]), Some(()));
|
||||
assert_eq!(wipeout.vertices(), expected);
|
||||
assert_eq!(wipeout.boundary.len(), 5, "one separate closing marker, no growth");
|
||||
}
|
||||
expected[other] = Vec2::new(-2.0, 4.0);
|
||||
assert_eq!(wipeout.set_vertex(other, expected[other]), Some(()));
|
||||
assert_eq!(wipeout.vertices(), expected);
|
||||
assert_eq!(wipeout.boundary.len(), 5);
|
||||
assert_eq!(wipeout.boundary.first(), wipeout.boundary.last());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_keeps_the_closure_but_not_other_coincident_vertices_attached() {
|
||||
let (a, b, c, to) = (Vec2::ZERO, Vec2::new(7.0, 1.0), Vec2::new(3.0, 7.0), Vec2::new(2.0, 3.0));
|
||||
for explicit in [false, true] {
|
||||
let mut boundary = vec![a, b, a, c];
|
||||
if explicit {
|
||||
boundary.push(a);
|
||||
}
|
||||
let mut wipeout = Wipeout { boundary };
|
||||
assert_eq!(wipeout.set_vertex(0, to), Some(()));
|
||||
let mut expected = vec![to, b, a, c];
|
||||
if explicit {
|
||||
expected.push(to);
|
||||
}
|
||||
assert_eq!(wipeout.boundary, expected);
|
||||
for (index, point) in [(4, a), (usize::MAX, a), (0, Vec2::new(f64::NAN, 0.0)), (0, Vec2::new(0.0, f64::INFINITY))] {
|
||||
assert_eq!(wipeout.set_vertex(index, point), None);
|
||||
assert_eq!(wipeout.boundary, expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
-206
@@ -36,26 +36,11 @@ fn text_box(t: &crate::Text, len: usize) -> Bounds2 {
|
||||
/// as when drawing. Dimensional constraints of dynamic blocks (layer `*ADSK_CONSTRAINTS`) and the
|
||||
/// entities a visibility state hides are invisible this way, and they sit far from the block's
|
||||
/// geometry in real drawings. Layer "0" takes the insert's layer, which the caller has checked.
|
||||
/// `off_counts`: a layer turned off counts too, as in the drawing's extents ([`extent_bounds`]).
|
||||
fn in_block_visible(d: &Drawing, e: &Entity, off_counts: bool) -> bool {
|
||||
fn in_block_visible(d: &Drawing, e: &Entity) -> bool {
|
||||
// Attribute definitions are not drawn in an insert (its attribs are, see below).
|
||||
!matches!(e.kind, EntityKind::AttDef(_)) && e.common.visible && layer_counts(d, &e.common.layer, off_counts)
|
||||
}
|
||||
|
||||
/// Whether what lies on `layer` inside an insert counts: layer "0" takes the insert's layer, which
|
||||
/// the caller has checked; a frozen layer never counts, one turned off only with `off_counts`.
|
||||
fn layer_counts(d: &Drawing, layer: &str, off_counts: bool) -> bool {
|
||||
layer == "0" || d.layer(layer).is_none_or(|l| !l.frozen && (l.on || off_counts))
|
||||
}
|
||||
|
||||
/// The layer an insert's attribute draws on, as the renderer takes it: its own, else its
|
||||
/// definition's (layer "0" being the insert's).
|
||||
fn attrib_layer<'a>(d: &'a Drawing, ins: &crate::Insert, a: &'a crate::Attrib) -> &'a str {
|
||||
a.props.layer.as_deref().unwrap_or_else(|| {
|
||||
d.block(&ins.block)
|
||||
.and_then(|blk| blk.entities.iter().find(|be| matches!(&be.kind, EntityKind::AttDef(ad) if ad.tag.eq_ignore_ascii_case(&a.tag))))
|
||||
.map_or("0", |be| be.common.layer.as_str())
|
||||
})
|
||||
!matches!(e.kind, EntityKind::AttDef(_))
|
||||
&& e.common.visible
|
||||
&& (e.common.layer == "0" || d.layer(&e.common.layer).is_none_or(crate::Layer::visible))
|
||||
}
|
||||
|
||||
/// A text that draws no glyph (empty, or only blanks and non-breaking spaces) takes no room.
|
||||
@@ -63,77 +48,40 @@ fn blank(s: &str) -> bool {
|
||||
s.replace("\\~", " ").chars().all(char::is_whitespace)
|
||||
}
|
||||
|
||||
/// Approximate box of an MTEXT, from its attachment point, width and line count.
|
||||
fn mtext_box(t: &crate::MText) -> Bounds2 {
|
||||
let lines = t.contents.split("\\P").count().max(1);
|
||||
let longest = t.contents.split("\\P").map(|l| l.chars().count()).max().unwrap_or(1);
|
||||
let estimate = t.height * 0.8 * longest as f64;
|
||||
// The wrap width only narrows the box: a garbage width must not stretch the drawing extents (#2465).
|
||||
let mut w = if t.width > 0.0 { t.width.min(estimate) } else { estimate };
|
||||
let mut h = t.height * 1.66 * lines as f64;
|
||||
if let Some(c) = t.columns.as_ref().filter(|_| t.width > 0.0) {
|
||||
// Columns side by side, the text flowing down each in turn.
|
||||
w = c.total_width(t.width);
|
||||
h = if c.max_height() > 0.0 { c.max_height() } else { h };
|
||||
}
|
||||
let (dx, dy) = match t.attach {
|
||||
2 | 5 | 8 => (-w / 2.0, 0.0),
|
||||
3 | 6 | 9 => (-w, 0.0),
|
||||
_ => (0.0, 0.0),
|
||||
};
|
||||
let top = match t.attach {
|
||||
4..=6 => h / 2.0,
|
||||
7..=9 => h,
|
||||
_ => 0.0,
|
||||
};
|
||||
let o = t.insert.xy();
|
||||
let pts = [Vec2::new(dx, top + dy), Vec2::new(dx + w, top + dy), Vec2::new(dx + w, top - h + dy), Vec2::new(dx, top - h + dy)]
|
||||
.map(|p| o + p.rotate(t.rotation));
|
||||
Bounds2::from_points(pts)
|
||||
}
|
||||
|
||||
/// The box of what `e` draws (block contents on layers turned off left out).
|
||||
pub fn entity_bounds(d: &Drawing, e: &Entity, depth: usize) -> Bounds2 {
|
||||
bounds_of(d, e, depth, false)
|
||||
}
|
||||
|
||||
/// The box `e` takes in the drawing's extents: as [`entity_bounds`], but block contents on a layer
|
||||
/// turned off count, as AutoCAD 2024's ZOOM Extents counts them (a frozen layer's do not).
|
||||
pub fn extent_bounds(d: &Drawing, e: &Entity) -> Bounds2 {
|
||||
bounds_of(d, e, 0, true)
|
||||
}
|
||||
|
||||
fn bounds_of(d: &Drawing, e: &Entity, depth: usize, off_counts: bool) -> Bounds2 {
|
||||
// A top-level annotative object measures as it draws at the current annotation scale.
|
||||
if depth == 0 {
|
||||
match d.anno_draw(e) {
|
||||
crate::AnnoDraw::Plain => {}
|
||||
crate::AnnoDraw::Hidden => return Bounds2::EMPTY,
|
||||
crate::AnnoDraw::Scaled(f) => return bounds_in(d, &crate::anno_scaled(e, f), 0, &mut HashMap::new(), off_counts),
|
||||
}
|
||||
}
|
||||
bounds_in(d, e, depth, &mut HashMap::new(), off_counts)
|
||||
bounds_in(d, e, depth, &mut HashMap::new())
|
||||
}
|
||||
|
||||
/// [`entity_bounds`] with the bounds of block contents (in block coordinates) already computed,
|
||||
/// by block name and depth: every further reference to a block at that depth reuses them instead of
|
||||
/// expanding the block again, so nested or self-referencing blocks cost at most one pass over each
|
||||
/// definition per depth instead of one per reference (which grows exponentially with the nesting
|
||||
/// depth). The depth is part of the key because [`MAX_BLOCK_DEPTH`] cuts a deep visit short: its
|
||||
/// bounds must not stand in for a shallower reference to the same block (#706).
|
||||
fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<(&'a str, usize), Bounds2>, off_counts: bool) -> Bounds2 {
|
||||
/// by block name: every further reference to a block reuses them instead of expanding the block
|
||||
/// again, so nested or self-referencing blocks cost one pass over each definition instead of one
|
||||
/// per reference (which grows exponentially with the nesting depth).
|
||||
fn bounds_in(d: &Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<String, Bounds2>) -> Bounds2 {
|
||||
match &e.kind {
|
||||
EntityKind::Table(t) if t.is_broken() => t.bounds(),
|
||||
EntityKind::Text(t) if blank(&t.value) => Bounds2::EMPTY,
|
||||
EntityKind::MText(t) if blank(&t.contents) => Bounds2::EMPTY,
|
||||
// Text that matches the layout's orientation is drawn upright (#2498).
|
||||
EntityKind::Text(t) if e.common.matches_layout_orientation() => {
|
||||
text_box(&crate::Text { rotation: 0.0, ..t.clone() }, t.value.chars().count())
|
||||
}
|
||||
EntityKind::MText(t) if e.common.matches_layout_orientation() => mtext_box(&crate::MText { rotation: 0.0, ..t.clone() }),
|
||||
EntityKind::Text(t) => text_box(t, t.value.chars().count()),
|
||||
EntityKind::AttDef(a) => text_box(&a.text, a.tag.chars().count()),
|
||||
EntityKind::MText(t) => mtext_box(t),
|
||||
EntityKind::MText(t) => {
|
||||
let lines = t.contents.split("\\P").count().max(1);
|
||||
let longest = t.contents.split("\\P").map(|l| l.chars().count()).max().unwrap_or(1);
|
||||
let w = if t.width > 0.0 { t.width } else { t.height * 0.8 * longest as f64 };
|
||||
let h = t.height * 1.66 * lines as f64;
|
||||
let (dx, dy) = match t.attach {
|
||||
2 | 5 | 8 => (-w / 2.0, 0.0),
|
||||
3 | 6 | 9 => (-w, 0.0),
|
||||
_ => (0.0, 0.0),
|
||||
};
|
||||
let top = match t.attach {
|
||||
4..=6 => h / 2.0,
|
||||
7..=9 => h,
|
||||
_ => 0.0,
|
||||
};
|
||||
let o = t.insert.xy();
|
||||
let pts = [Vec2::new(dx, top + dy), Vec2::new(dx + w, top + dy), Vec2::new(dx + w, top - h + dy), Vec2::new(dx, top - h + dy)]
|
||||
.map(|p| o + p.rotate(t.rotation));
|
||||
Bounds2::from_points(pts)
|
||||
}
|
||||
EntityKind::Insert(ins) => {
|
||||
if depth >= MAX_BLOCK_DEPTH {
|
||||
return Bounds2::EMPTY;
|
||||
@@ -141,34 +89,28 @@ fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<
|
||||
// A reference to a missing block (an unloaded xref, a purged definition) or to an empty
|
||||
// one draws nothing, so it contributes nothing: ZOOM Extents must not fly off to its
|
||||
// insertion point. Attributes below still count.
|
||||
let mut b = Bounds2::EMPTY;
|
||||
if let Some(blk) = d.block(&ins.block) {
|
||||
let m = ins.transform(blk.base.xy());
|
||||
let inner = match blocks.get(&(blk.name.as_str(), depth)) {
|
||||
Some(b) => *b,
|
||||
None => {
|
||||
let mut inner = Bounds2::EMPTY;
|
||||
for be in blk.entities.iter().filter(|be| in_block_visible(d, be, off_counts)) {
|
||||
inner = inner.union(&bounds_in(d, be, depth + 1, blocks, off_counts));
|
||||
}
|
||||
blocks.insert((blk.name.as_str(), depth), inner);
|
||||
inner
|
||||
let Some(blk) = d.block(&ins.block) else { return Bounds2::EMPTY };
|
||||
let m = ins.transform(blk.base.xy());
|
||||
let inner = match blocks.get(&blk.name) {
|
||||
Some(b) => *b,
|
||||
None => {
|
||||
let mut inner = Bounds2::EMPTY;
|
||||
for be in blk.entities.iter().filter(|be| in_block_visible(d, be)) {
|
||||
inner = inner.union(&bounds_in(d, be, depth + 1, blocks));
|
||||
}
|
||||
};
|
||||
b = if inner.is_empty() { Bounds2::EMPTY } else { Bounds2::from_points(inner.corners().map(|c| m.apply(c))) };
|
||||
if ins.cols > 1 || ins.rows > 1 {
|
||||
let off =
|
||||
Vec2::new(ins.col_spacing * f64::from(ins.cols.saturating_sub(1)), ins.row_spacing * f64::from(ins.rows.saturating_sub(1)))
|
||||
.rotate(ins.rotation);
|
||||
b = b.union(&Bounds2::from_points(b.corners().map(|c| c + off)));
|
||||
blocks.insert(blk.name.clone(), inner);
|
||||
inner
|
||||
}
|
||||
};
|
||||
let mut b = if inner.is_empty() { Bounds2::EMPTY } else { Bounds2::from_points(inner.corners().map(|c| m.apply(c))) };
|
||||
if ins.cols > 1 || ins.rows > 1 {
|
||||
let off = Vec2::new(ins.col_spacing * f64::from(ins.cols.saturating_sub(1)), ins.row_spacing * f64::from(ins.rows.saturating_sub(1)))
|
||||
.rotate(ins.rotation);
|
||||
b = b.union(&Bounds2::from_points(b.corners().map(|c| c + off)));
|
||||
}
|
||||
for a in &ins.attribs {
|
||||
if !a.invisible && layer_counts(d, attrib_layer(d, ins, a), off_counts) {
|
||||
b = b.union(&match a.multiline_text() {
|
||||
Some(m) => mtext_box(&m),
|
||||
None => text_box(&a.text, a.text.value.chars().count()),
|
||||
});
|
||||
if !a.invisible {
|
||||
b = b.union(&text_box(&a.text, a.text.value.chars().count()));
|
||||
}
|
||||
}
|
||||
b
|
||||
@@ -198,25 +140,9 @@ fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<
|
||||
if let Some(blk) = dm.block.as_ref().and_then(|n| d.block(n))
|
||||
&& depth < MAX_BLOCK_DEPTH
|
||||
{
|
||||
// Reuse the shared block-bounds cache (as the Insert branch does) so a dimension
|
||||
// block with branching or self-referencing children costs one pass per definition
|
||||
// per depth, not one per reference (which grows exponentially) (CWE-400).
|
||||
let inner = match blocks.get(&(blk.name.as_str(), depth)) {
|
||||
Some(cached) => *cached,
|
||||
None => {
|
||||
let mut inner = Bounds2::EMPTY;
|
||||
for be in blk.entities.iter().filter(|be| in_block_visible(d, be, off_counts)) {
|
||||
inner = inner.union(&bounds_in(d, be, depth + 1, blocks, off_counts));
|
||||
}
|
||||
blocks.insert((blk.name.as_str(), depth), inner);
|
||||
inner
|
||||
}
|
||||
};
|
||||
// The block draws translated by the insertion point (group 12), so its bounds
|
||||
// move with it (#2634); the cache above keeps the block's own bounds.
|
||||
let off = dm.insertion_point.xy();
|
||||
let inner = if off == Vec2::ZERO { inner } else { Bounds2 { min: inner.min + off, max: inner.max + off } };
|
||||
b = b.union(&inner);
|
||||
for be in blk.entities.iter().filter(|be| in_block_visible(d, be)) {
|
||||
b = b.union(&bounds_in(d, be, depth + 1, blocks));
|
||||
}
|
||||
}
|
||||
b
|
||||
}
|
||||
@@ -224,13 +150,6 @@ fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<
|
||||
let mut b = Bounds2::from_points(m.leaders.iter().flatten().map(|v| v.xy()).chain(std::iter::once(m.landing.xy())));
|
||||
if let Some(t) = &m.text {
|
||||
b.add(t.insert.xy());
|
||||
if !blank(&t.contents) {
|
||||
b = b.union(&mtext_box(t));
|
||||
}
|
||||
}
|
||||
if let Some(ins) = &m.block {
|
||||
let be = Entity { handle: e.handle, common: e.common.clone(), kind: EntityKind::Insert(ins.clone()) };
|
||||
b = b.union(&bounds_in(d, &be, depth, blocks, off_counts));
|
||||
}
|
||||
b
|
||||
}
|
||||
@@ -239,8 +158,6 @@ fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<
|
||||
let h: f64 = t.row_heights.iter().sum();
|
||||
Bounds2::new(t.insert.xy(), t.insert.xy() + Vec2::new(w, -h))
|
||||
}
|
||||
// Text widths estimated like `text_box`; the renderer measures the font.
|
||||
EntityKind::Tolerance(t) => t.layout(d, &|s, h| h * 0.9 * s.chars().count() as f64).bounds,
|
||||
EntityKind::Image(i) => {
|
||||
let o = i.insert.xy();
|
||||
let u = i.u.xy() * i.size.x;
|
||||
@@ -346,37 +263,6 @@ mod tests {
|
||||
entity_bounds(&Drawing::default(), &Entity::new(crate::Handle(1), kind), 0)
|
||||
}
|
||||
|
||||
fn mtext(contents: &str, width: f64) -> crate::MText {
|
||||
crate::MText {
|
||||
insert: Vec3::new(10.0, 10.0, 0.0),
|
||||
height: 2.5,
|
||||
width,
|
||||
attach: 1,
|
||||
rotation: 0.0,
|
||||
style: "Standard".into(),
|
||||
contents: contents.into(),
|
||||
line_spacing: 1.0,
|
||||
line_spacing_exact: false,
|
||||
columns: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A garbage wrap width only narrows an MTEXT's box, never widens it: a huge group 41 must not
|
||||
/// stretch the drawing extents (#2465).
|
||||
#[test]
|
||||
fn a_huge_mtext_wrap_width_does_not_inflate_the_extents() {
|
||||
let b = bounds(EntityKind::MText(mtext("AB", 300_000_009_519_104.0)));
|
||||
// "AB" at height 2.5 is 2 chars * 0.8 * 2.5 = 4 wide.
|
||||
assert!(b.max.x <= 10.0 + 4.0 + 1e-9, "box ends at x = {}", b.max.x);
|
||||
assert!(b.min.x >= 10.0 - 1e-9);
|
||||
}
|
||||
|
||||
/// A wrap width narrower than the text still bounds the box to that width.
|
||||
#[test]
|
||||
fn a_narrow_mtext_wrap_width_still_sets_the_box_width() {
|
||||
let b = bounds(EntityKind::MText(mtext("A LONG LINE OF WRAPPING TEXT", 5.0)));
|
||||
assert!((b.max.x - b.min.x - 5.0).abs() < 1e-9, "width {}", b.max.x - b.min.x);
|
||||
}
|
||||
#[test]
|
||||
fn right_justified_text_is_left_of_alignment_point() {
|
||||
let b = bounds(EntityKind::Text(text(HAlign::Right, VAlign::Baseline)));
|
||||
@@ -397,49 +283,8 @@ mod tests {
|
||||
#[test]
|
||||
fn right_justified_attdef_is_left_of_alignment_point() {
|
||||
let text = text(HAlign::Right, VAlign::Baseline);
|
||||
let a = crate::Attrib {
|
||||
tag: "HELLO".into(),
|
||||
text,
|
||||
invisible: false,
|
||||
constant: false,
|
||||
prompt: String::new(),
|
||||
props: Default::default(),
|
||||
multiline: None,
|
||||
modes: Default::default(),
|
||||
};
|
||||
let a = crate::Attrib { tag: "HELLO".into(), text, invisible: false, constant: false, prompt: String::new(), props: Default::default() };
|
||||
let b = bounds(EntityKind::AttDef(a));
|
||||
assert!(b.min.x <= -4.4 && b.max.x < 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multileader_text_is_inside_its_bounds() {
|
||||
let mtext = crate::MText {
|
||||
insert: Vec3::new(25.45, 0.0, 0.0),
|
||||
height: 5.0,
|
||||
width: 0.0,
|
||||
attach: 4,
|
||||
rotation: 0.0,
|
||||
style: "Standard".into(),
|
||||
contents: "N".into(),
|
||||
line_spacing: 1.0,
|
||||
line_spacing_exact: false,
|
||||
columns: None,
|
||||
};
|
||||
let ml = crate::MLeader {
|
||||
leaders: vec![vec![Vec3::new(0.0, 0.0, 0.0)]],
|
||||
landing: Vec3::new(25.0, 0.0, 0.0),
|
||||
dogleg: 0.45,
|
||||
text: Some(mtext.clone()),
|
||||
style: "Standard".into(),
|
||||
arrow_size: 1.0,
|
||||
spline: false,
|
||||
block: None,
|
||||
};
|
||||
let b = bounds(EntityKind::MLeader(ml.clone()));
|
||||
// The glyph spans x 25.45..28.78, y -2.5..2.5.
|
||||
assert!(b.min.x <= 0.0 && b.max.x >= 28.79 && b.min.y <= -2.5 && b.max.y >= 2.5);
|
||||
let mut empty = ml;
|
||||
empty.text = Some(crate::MText { contents: " ".into(), ..mtext });
|
||||
assert!(bounds(EntityKind::MLeader(empty)).max.x < 26.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
//! Data from a drawing file that CADCraft does not model, kept so that saving the drawing writes
|
||||
//! it back: other programs' objects, extension dictionaries, xdata and properties (#528).
|
||||
//!
|
||||
//! It is kept as the file's own DXF groups, with the file's handles. The file reader fills it and
|
||||
//! the writer puts it back, pointing references at whatever now has the referenced role (the
|
||||
//! named object dictionary, a table record, a block, an entity); references to objects that are
|
||||
//! gone are dropped. Commands don't edit it.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use crate::{Handle, RawTag};
|
||||
|
||||
/// What a file handle stood for when the object it named is one CADCraft writes itself (under a
|
||||
/// new handle on save): a kind and an upper-case name, e.g. `("LAYER", "WALLS")`,
|
||||
/// `("TABLE", "LAYER")`, `("BLOCK", "DOOR")`, `("NOD", "")` for the named object dictionary and
|
||||
/// `("NOD", "ACAD_GROUP")` for one of its entries.
|
||||
pub type ForeignKey = (String, String);
|
||||
|
||||
/// A non-graphical object of a type or role CADCraft does not model.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ForeignObject {
|
||||
/// Its handle in the file it came from (kept on save).
|
||||
pub handle: Handle,
|
||||
/// Its DXF type (group 0), e.g. `XRECORD`.
|
||||
pub kind: String,
|
||||
/// Its groups after the type, as read.
|
||||
pub tags: Vec<RawTag>,
|
||||
}
|
||||
|
||||
/// What an entity, table record or block carries beyond what CADCraft models.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ForeignExtra {
|
||||
/// Its extension dictionary (one of [`Foreign::objects`]).
|
||||
pub xdict: Option<Handle>,
|
||||
/// Objects of [`Foreign::objects`] that react to it (persistent reactors).
|
||||
pub reactors: Vec<Handle>,
|
||||
/// Other applications' extended data of a table record or block: groups from `1001` on. An
|
||||
/// entity's is part of the entity ([`crate::Common::xdata`]), so it follows the entity's copies.
|
||||
pub xdata: Vec<RawTag>,
|
||||
/// The colour book colour (group 430) and the true colour (group 420) it was read with: it is
|
||||
/// written back while the colour is unchanged.
|
||||
pub color_book: Option<(String, String)>,
|
||||
/// Other unmodelled property groups (material 347, shadow mode 284).
|
||||
pub props: Vec<RawTag>,
|
||||
}
|
||||
|
||||
impl ForeignExtra {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.xdict.is_none() && self.reactors.is_empty() && self.xdata.is_empty() && self.color_book.is_none() && self.props.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything of a file CADCraft keeps without modelling it.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Foreign {
|
||||
/// CLASS records (groups after `0 CLASS`) of the kept objects' and entities' types.
|
||||
pub classes: Vec<Vec<RawTag>>,
|
||||
/// Kept objects, in file order.
|
||||
pub objects: Vec<ForeignObject>,
|
||||
/// Entries of the named object dictionary that CADCraft doesn't write itself: (key, object).
|
||||
pub dictionary: Vec<(String, Handle)>,
|
||||
/// Extras of entities, by handle.
|
||||
pub entities: BTreeMap<Handle, ForeignExtra>,
|
||||
/// Extras of table records (`("LAYER", "WALLS")`), tables (`("TABLE", "LAYER")`) and blocks
|
||||
/// (`("BLOCK", "DOOR")`), by [`ForeignKey`].
|
||||
pub records: BTreeMap<ForeignKey, ForeignExtra>,
|
||||
/// The roles of the file handles the kept data refers to, for objects CADCraft rewrites.
|
||||
pub targets: BTreeMap<Handle, ForeignKey>,
|
||||
/// The upper-case names of the file's classes in its CLASSES order, kept when the file has
|
||||
/// proxy objects or entities: a proxy's class number (group 91) is 500 plus its class's place
|
||||
/// here, and saving renumbers it to the written CLASSES (#1812).
|
||||
pub class_order: Vec<String>,
|
||||
/// The file's data store section (ACDSDATA, AutoCAD 2013 and later), as read: its groups
|
||||
/// after the section name. It holds the ACIS geometry of 3D solids, regions, bodies and
|
||||
/// surfaces, which from 2013 on those entities don't carry themselves: each `ACDSRECORD`
|
||||
/// names its entity with a 320 handle (see [`Foreign::data_store_entities`]).
|
||||
pub data_store: Vec<RawTag>,
|
||||
}
|
||||
|
||||
impl Foreign {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.objects.is_empty()
|
||||
&& self.dictionary.is_empty()
|
||||
&& self.entities.is_empty()
|
||||
&& self.records.is_empty()
|
||||
&& self.classes.is_empty()
|
||||
&& self.targets.is_empty()
|
||||
&& self.class_order.is_empty()
|
||||
&& self.data_store.is_empty()
|
||||
}
|
||||
|
||||
/// The entities with a geometry record in the data store: an `ACDSRECORD` naming the entity
|
||||
/// (320) that holds its `ASM_Data`. A 320 without one doesn't keep an entity's geometry.
|
||||
pub fn data_store_entities(&self) -> std::collections::BTreeSet<Handle> {
|
||||
let mut out = std::collections::BTreeSet::new();
|
||||
for rec in data_store_records(&self.data_store) {
|
||||
let is_record = rec.first().is_some_and(|t| t.value.trim().eq_ignore_ascii_case("ACDSRECORD"));
|
||||
if is_record && rec.iter().any(|t| t.code == 2 && t.value.trim().eq_ignore_ascii_case("ASM_Data")) {
|
||||
out.extend(data_store_owner(rec).and_then(|t| Handle::parse_hex(t.value.trim())));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a solid modeler entity (3DSOLID, REGION, BODY) holds its ACIS geometry as SAT text
|
||||
/// (groups 1 and 3), which DXF 2013 and later no longer has: those files keep it in the data
|
||||
/// store section, so an entity with SAT text and no record there can't be written to one (#2334).
|
||||
pub fn sat_text_acis(u: &crate::Unknown, handle: Handle, stored: &std::collections::BTreeSet<Handle>) -> bool {
|
||||
["3DSOLID", "REGION", "BODY"].iter().any(|t| u.dxf_type.eq_ignore_ascii_case(t)) && !stored.contains(&handle) && has_sat_text(&u.tags)
|
||||
}
|
||||
|
||||
/// The entities a DXF of 2013 or later leaves out because they hold SAT-text ACIS data
|
||||
/// ([`sat_text_acis`]), counted by DXF type, across model space, layouts and block definitions.
|
||||
pub fn sat_text_dropped(d: &crate::Drawing) -> BTreeMap<String, usize> {
|
||||
let stored = d.foreign.data_store_entities();
|
||||
let mut out = BTreeMap::new();
|
||||
// The writer saves xrefs as references: their blocks (and `xref|` ones) hold nothing in the file.
|
||||
let own_blocks = d.blocks.values().filter(|b| b.xref_path.is_none() && !xref_dependent(&b.name));
|
||||
let stores = std::iter::once(&d.model).chain(d.layouts.iter().map(|l| &l.entities)).chain(own_blocks.map(|b| &b.entities));
|
||||
for e in stores.flat_map(|s| s.iter()) {
|
||||
if let crate::EntityKind::Unknown(u) = &e.kind
|
||||
&& sat_text_acis(u, e.handle, &stored)
|
||||
{
|
||||
let n: &mut usize = out.entry(u.dxf_type.to_ascii_uppercase()).or_default();
|
||||
*n = n.saturating_add(1);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether a symbol name belongs to an attached xref (`xref|name`): the DXF writer leaves such
|
||||
/// symbols out, and the contents of xref blocks too ([`Block::xref_path`](crate::Block::xref_path)).
|
||||
pub fn xref_dependent(name: &str) -> bool {
|
||||
name.contains('|')
|
||||
}
|
||||
|
||||
/// The records of a data store section's groups: each starts at a `0` group and runs to the next
|
||||
/// (the section's own groups before the first record are not part of one).
|
||||
pub fn data_store_records(tags: &[RawTag]) -> Vec<&[RawTag]> {
|
||||
let mut out = Vec::new();
|
||||
let mut start = tags.iter().position(|t| t.code == 0).unwrap_or(tags.len());
|
||||
while start < tags.len() {
|
||||
let end = tags.iter().skip(start + 1).position(|t| t.code == 0).map_or(tags.len(), |k| start + 1 + k);
|
||||
out.push(tags.get(start..end).unwrap_or(&[]));
|
||||
start = end;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether an entity's groups hold SAT text: groups 1 and 3 inside its `AcDbModelerGeometry`
|
||||
/// subclass. A file of a version before the subclass markers has no `100` groups: all of its
|
||||
/// groups (before the extended data) are the entity's.
|
||||
pub fn has_sat_text(tags: &[RawTag]) -> bool {
|
||||
let own = tags.iter().take_while(|r| r.code < 1000);
|
||||
let marked = tags.iter().take_while(|r| r.code < 1000).any(|r| r.code == 100);
|
||||
let mut in_modeler = !marked;
|
||||
for r in own {
|
||||
if r.code == 100 {
|
||||
in_modeler = r.value.trim().eq_ignore_ascii_case("AcDbModelerGeometry");
|
||||
} else if in_modeler && (r.code == 1 || r.code == 3) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// The group naming the entity a data store record belongs to: its first `320`. A record is kept
|
||||
/// or dropped by this handle alone, in the writer and wherever the store is read.
|
||||
pub fn data_store_owner(record: &[RawTag]) -> Option<&RawTag> {
|
||||
record.iter().find(|t| t.code == 320)
|
||||
}
|
||||
|
||||
impl Foreign {
|
||||
/// The kept objects by handle (the first of any that share one).
|
||||
fn object_index(&self) -> BTreeMap<Handle, &ForeignObject> {
|
||||
let mut m = BTreeMap::new();
|
||||
for o in &self.objects {
|
||||
m.entry(o.handle).or_insert(o);
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// The kept object `root` and the kept objects it owns, directly or not (dictionary entries
|
||||
/// and child pointers, groups 350 and 360), `root` first.
|
||||
pub fn tree(&self, root: Handle) -> Vec<Handle> {
|
||||
// Indexed once: a file can keep many objects, and a lookup per pointer would grow with
|
||||
// their product.
|
||||
let by_handle = self.object_index();
|
||||
let mut out = vec![root];
|
||||
let mut seen = BTreeSet::from([root]);
|
||||
let mut i = 0;
|
||||
while let Some(&h) = out.get(i) {
|
||||
i += 1;
|
||||
let Some(o) = by_handle.get(&h) else { continue };
|
||||
for c in o.tags.iter().filter(|t| matches!(t.code, 350 | 360)).filter_map(|t| Handle::parse_hex(t.value.trim())) {
|
||||
if out.len() < 4096 && by_handle.contains_key(&c) && seen.insert(c) {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Copy the objects of `tree` (see [`Foreign::tree`]) of `src` into this one under the handles
|
||||
/// `fresh` (the same order; two more follow for a field list), `owner` taking the place of the
|
||||
/// root's owner. References to objects outside the tree become null. Fields keep a field
|
||||
/// list: this one gets a copy of the source's if it has none.
|
||||
pub fn adopt_tree(&mut self, src: &Foreign, tree: &[Handle], fresh: &[Handle], owner: Handle) {
|
||||
if fresh.len() < tree.len() + 2 {
|
||||
return;
|
||||
}
|
||||
let map: BTreeMap<Handle, Handle> = tree.iter().copied().zip(fresh.iter().copied()).collect();
|
||||
let by_handle = src.object_index();
|
||||
let mut kinds = Vec::new();
|
||||
for (n, h) in tree.iter().enumerate() {
|
||||
let Some(o) = by_handle.get(h) else { continue };
|
||||
let mut c = (*o).clone();
|
||||
let Some(&new) = fresh.get(n) else { continue };
|
||||
c.handle = new;
|
||||
for t in &mut c.tags {
|
||||
if t.code == 5 {
|
||||
t.value = new.hex();
|
||||
} else if matches!(t.code, 320..=369 | 390..=399) {
|
||||
let to = Handle::parse_hex(t.value.trim()).and_then(|h| map.get(&h).copied()).or((n == 0 && t.code == 330).then_some(owner));
|
||||
t.value = to.map_or_else(|| "0".to_string(), Handle::hex);
|
||||
}
|
||||
}
|
||||
kinds.push(c.kind.clone());
|
||||
self.objects.push(c);
|
||||
}
|
||||
if let Some(list) = src.objects.iter().find(|o| o.kind == "FIELDLIST").filter(|_| !self.objects.iter().any(|o| o.kind == "FIELDLIST")) {
|
||||
let (Some(&h), Some(&nod)) = (fresh.get(tree.len()), fresh.get(tree.len() + 1)) else { return };
|
||||
let mut c = list.clone();
|
||||
c.handle = h;
|
||||
for t in &mut c.tags {
|
||||
match t.code {
|
||||
5 => t.value = h.hex(),
|
||||
330 => t.value = "0".to_string(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(t) = c.tags.iter_mut().find(|t| t.code == 330) {
|
||||
t.value = nod.hex();
|
||||
}
|
||||
self.targets.insert(nod, ("NOD".to_string(), String::new()));
|
||||
self.dictionary.push(("ACAD_FIELDLIST".to_string(), h));
|
||||
kinds.push(c.kind.clone());
|
||||
self.objects.push(c);
|
||||
}
|
||||
for k in kinds {
|
||||
let named = |c: &Vec<RawTag>| c.iter().any(|t| t.code == 1 && t.value.eq_ignore_ascii_case(&k));
|
||||
if !self.classes.iter().any(named) {
|
||||
self.classes.extend(src.classes.iter().filter(|c| named(c)).cloned());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
use std::path::Path;
|
||||
|
||||
/// A file format, chosen by the file name's extension (ASCII case-insensitive).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Format {
|
||||
Dxf,
|
||||
Dwg,
|
||||
/// A drawing template: DWG content.
|
||||
Dwt,
|
||||
Svg,
|
||||
/// Gzip-compressed SVG.
|
||||
Svgz,
|
||||
Png,
|
||||
Pdf,
|
||||
}
|
||||
|
||||
impl Format {
|
||||
/// The formats a drawing can be read from by name. DXF and DWG are also recognised by content.
|
||||
pub const READ: &'static [Format] = &[Format::Dxf, Format::Dwg, Format::Svg, Format::Svgz];
|
||||
/// The formats a drawing can be written to.
|
||||
pub const WRITE: &'static [Format] = &[Format::Dxf, Format::Dwg, Format::Dwt, Format::Svg, Format::Png, Format::Pdf];
|
||||
|
||||
/// The format `path`'s extension names. A name without an extension is DXF; an unknown
|
||||
/// extension is `None`.
|
||||
pub fn from_path(p: impl AsRef<Path>) -> Option<Format> {
|
||||
let ext = p.as_ref().extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
|
||||
Some(match ext.as_str() {
|
||||
"dxf" | "" => Format::Dxf,
|
||||
"dwg" => Format::Dwg,
|
||||
"dwt" => Format::Dwt,
|
||||
"svg" => Format::Svg,
|
||||
"svgz" => Format::Svgz,
|
||||
"png" => Format::Png,
|
||||
"pdf" => Format::Pdf,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The extension, lower case and without the dot.
|
||||
pub fn extension(self) -> &'static str {
|
||||
match self {
|
||||
Format::Dxf => "dxf",
|
||||
Format::Dwg => "dwg",
|
||||
Format::Dwt => "dwt",
|
||||
Format::Svg => "svg",
|
||||
Format::Svgz => "svgz",
|
||||
Format::Png => "png",
|
||||
Format::Pdf => "pdf",
|
||||
}
|
||||
}
|
||||
|
||||
/// Written as an image (model space fitted to a frame), so framing applies. SVGZ is read only.
|
||||
pub fn is_image(self) -> bool {
|
||||
matches!(self, Format::Svg | Format::Png | Format::Pdf)
|
||||
}
|
||||
|
||||
/// A drawing file (DXF, DWG, template) rather than an image.
|
||||
pub fn is_drawing(self) -> bool {
|
||||
matches!(self, Format::Dxf | Format::Dwg | Format::Dwt)
|
||||
}
|
||||
|
||||
/// Written as DWG, which drops the object types the DWG writer doesn't know.
|
||||
pub fn drops_unknown(self) -> bool {
|
||||
matches!(self, Format::Dwg | Format::Dwt)
|
||||
}
|
||||
|
||||
/// `formats` as "dxf, dwg, svg": the list help text shows.
|
||||
pub fn list(formats: &[Format]) -> String {
|
||||
formats.iter().map(|f| f.extension()).collect::<Vec<_>>().join(", ")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn from_path_by_extension() {
|
||||
for f in [Format::Dxf, Format::Dwg, Format::Dwt, Format::Svg, Format::Svgz, Format::Png, Format::Pdf] {
|
||||
assert_eq!(Format::from_path(format!("a/b.{}", f.extension())), Some(f));
|
||||
assert_eq!(Format::from_path(format!("B.{}", f.extension().to_ascii_uppercase())), Some(f));
|
||||
}
|
||||
assert_eq!(Format::from_path("Drawing1"), Some(Format::Dxf), "no extension is DXF");
|
||||
assert_eq!(Format::from_path("dir.dwg/Drawing1"), Some(Format::Dxf));
|
||||
assert_eq!(Format::from_path("a."), Some(Format::Dxf));
|
||||
assert_eq!(Format::from_path("a.txt"), None);
|
||||
assert_eq!(Format::from_path("a.dxf.bak"), None);
|
||||
assert_eq!(Format::from_path(std::path::Path::new("x.PnG")), Some(Format::Png));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classes() {
|
||||
assert!(Format::READ.contains(&Format::Svgz) && !Format::WRITE.contains(&Format::Svgz));
|
||||
assert!(Format::WRITE.contains(&Format::Dwt) && !Format::READ.contains(&Format::Dwt));
|
||||
assert!(Format::WRITE.iter().filter(|f| f.is_image()).count() == 3 && !Format::Svgz.is_image());
|
||||
assert!(Format::Dwt.drops_unknown() && Format::Dwg.drops_unknown() && !Format::Dxf.drops_unknown());
|
||||
assert!(Format::Dwt.is_drawing() && !Format::Pdf.is_drawing());
|
||||
assert_eq!(Format::list(Format::READ), "dxf, dwg, svg, svgz");
|
||||
}
|
||||
}
|
||||
@@ -1,626 +0,0 @@
|
||||
//! Geometric tolerancing (GD&T): the TOLERANCE entity (feature control frames) and its layout.
|
||||
//!
|
||||
//! The frame is stored as the DXF Reference's "visual representation string" (TOLERANCE group 1):
|
||||
//! frame lines separated by `^J`, compartments separated by `%%v`, and the geometric symbols as
|
||||
//! characters of the `gdt` font inside `{\Fgdt;…}` runs. CADCraft never uses that font: every
|
||||
//! symbol is drawn by [`symbol_prims`] from our own geometry (standard ISO/ASME symbol shapes).
|
||||
|
||||
use cadcraft_geom::{Bounds2, Vec2, Vec3};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Caps for hostile frame strings (files, command parameters).
|
||||
pub const MAX_FRAME_CHARS: usize = 4096;
|
||||
pub const MAX_FRAME_ROWS: usize = 8;
|
||||
pub const MAX_FRAME_CELLS: usize = 12;
|
||||
|
||||
/// A TOLERANCE entity: a feature control frame.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Tolerance {
|
||||
/// Insertion point: the middle of the left edge of the first frame line.
|
||||
pub insert: Vec3,
|
||||
/// The frame's X-axis direction (DXF group 11).
|
||||
#[serde(default = "x_axis")]
|
||||
pub dir: Vec3,
|
||||
/// Dimension style: text height (DIMTXT × DIMSCALE), gap, colours, text style.
|
||||
#[serde(default = "standard")]
|
||||
pub style: String,
|
||||
/// The frame contents (DXF group 1).
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
fn x_axis() -> Vec3 {
|
||||
Vec3::new(1.0, 0.0, 0.0)
|
||||
}
|
||||
fn standard() -> String {
|
||||
"Standard".into()
|
||||
}
|
||||
|
||||
impl Tolerance {
|
||||
/// The frame's rotation (radians from +X); 0 when the direction is unusable.
|
||||
pub fn rotation(&self) -> f64 {
|
||||
let d = self.dir.xy();
|
||||
if d.is_finite() && d.len() > 1e-12 { d.angle() } else { 0.0 }
|
||||
}
|
||||
/// Text height and padding from the dimension style: DIMTXT and DIMGAP times the style's
|
||||
/// DIMSCALE (the drawing's when 0), or the text style's fixed height.
|
||||
pub fn text_size(&self, d: &crate::Drawing) -> (f64, f64) {
|
||||
let st = d.dim_style(&self.style).cloned().unwrap_or_default();
|
||||
let k = st.effective_scale(d.header.f64("DIMSCALE", 1.0));
|
||||
let fixed = d.text_style(&st.text_style).map(|t| t.height).filter(|h| h.is_finite() && *h > 0.0);
|
||||
let h = fixed.unwrap_or(st.text_height * k);
|
||||
(if h.is_finite() && h > 0.0 { h } else { 0.18 }, st.text_gap * k)
|
||||
}
|
||||
/// The frame laid out with `measure(text, height)` as the width of plain text.
|
||||
pub fn layout(&self, d: &crate::Drawing, measure: &dyn Fn(&str, f64) -> f64) -> GdtLayout {
|
||||
let (h, gap) = self.text_size(d);
|
||||
layout_frame(&self.text, self.insert.xy(), self.rotation(), h, gap, measure)
|
||||
}
|
||||
}
|
||||
|
||||
/// A geometric characteristic, material condition or zone symbol.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum GdtSymbol {
|
||||
Position,
|
||||
Concentricity,
|
||||
Symmetry,
|
||||
Parallelism,
|
||||
Perpendicularity,
|
||||
Angularity,
|
||||
Cylindricity,
|
||||
Flatness,
|
||||
Circularity,
|
||||
Straightness,
|
||||
SurfaceProfile,
|
||||
LineProfile,
|
||||
CircularRunout,
|
||||
TotalRunout,
|
||||
Diameter,
|
||||
/// Maximum material condition (circled M).
|
||||
Mmc,
|
||||
/// Least material condition (circled L).
|
||||
Lmc,
|
||||
/// Regardless of feature size (circled S).
|
||||
Rfs,
|
||||
/// Projected tolerance zone (circled P).
|
||||
Projected,
|
||||
}
|
||||
|
||||
/// Every symbol with its `gdt` font character and its name.
|
||||
const SYMBOLS: [(GdtSymbol, char, &str); 19] = [
|
||||
(GdtSymbol::Position, 'j', "position"),
|
||||
(GdtSymbol::Concentricity, 'r', "concentricity"),
|
||||
(GdtSymbol::Symmetry, 'i', "symmetry"),
|
||||
(GdtSymbol::Parallelism, 'f', "parallelism"),
|
||||
(GdtSymbol::Perpendicularity, 'b', "perpendicularity"),
|
||||
(GdtSymbol::Angularity, 'a', "angularity"),
|
||||
(GdtSymbol::Cylindricity, 'g', "cylindricity"),
|
||||
(GdtSymbol::Flatness, 'c', "flatness"),
|
||||
(GdtSymbol::Circularity, 'e', "circularity"),
|
||||
(GdtSymbol::Straightness, 'u', "straightness"),
|
||||
(GdtSymbol::SurfaceProfile, 'd', "surfaceprofile"),
|
||||
(GdtSymbol::LineProfile, 'k', "lineprofile"),
|
||||
(GdtSymbol::CircularRunout, 'h', "circularrunout"),
|
||||
(GdtSymbol::TotalRunout, 't', "totalrunout"),
|
||||
(GdtSymbol::Diameter, 'n', "diameter"),
|
||||
(GdtSymbol::Mmc, 'm', "mmc"),
|
||||
(GdtSymbol::Lmc, 'l', "lmc"),
|
||||
(GdtSymbol::Rfs, 's', "rfs"),
|
||||
(GdtSymbol::Projected, 'p', "projected"),
|
||||
];
|
||||
|
||||
impl GdtSymbol {
|
||||
/// The symbol's character in the frame string's `{\Fgdt;…}` runs.
|
||||
pub fn code(self) -> char {
|
||||
SYMBOLS.iter().find(|(s, _, _)| *s == self).map(|(_, c, _)| *c).unwrap_or('j')
|
||||
}
|
||||
pub fn from_code(c: char) -> Option<GdtSymbol> {
|
||||
SYMBOLS.iter().find(|(_, k, _)| *k == c).map(|(s, _, _)| *s)
|
||||
}
|
||||
/// Lower-case name ("position", "mmc", …).
|
||||
pub fn name(self) -> &'static str {
|
||||
SYMBOLS.iter().find(|(s, _, _)| *s == self).map(|(_, _, n)| *n).unwrap_or("position")
|
||||
}
|
||||
/// A symbol by name (case, spaces, dashes and underscores ignored) or by a few common
|
||||
/// alternatives ("true position", "runout", "profile", "M", "L", "S").
|
||||
pub fn parse(s: &str) -> Option<GdtSymbol> {
|
||||
let k: String = s.chars().filter(|c| !matches!(c, ' ' | '-' | '_')).flat_map(char::to_lowercase).collect();
|
||||
let alias = match k.as_str() {
|
||||
"trueposition" | "pos" => Some(GdtSymbol::Position),
|
||||
"coaxiality" => Some(GdtSymbol::Concentricity),
|
||||
"roundness" => Some(GdtSymbol::Circularity),
|
||||
"profile" | "surface" | "profileofasurface" => Some(GdtSymbol::SurfaceProfile),
|
||||
"line" | "profileofaline" => Some(GdtSymbol::LineProfile),
|
||||
"runout" => Some(GdtSymbol::CircularRunout),
|
||||
"total" => Some(GdtSymbol::TotalRunout),
|
||||
"m" => Some(GdtSymbol::Mmc),
|
||||
"l" => Some(GdtSymbol::Lmc),
|
||||
"s" => Some(GdtSymbol::Rfs),
|
||||
"dia" | "ø" | "⌀" | "%%c" => Some(GdtSymbol::Diameter),
|
||||
_ => None,
|
||||
};
|
||||
alias.or_else(|| SYMBOLS.iter().find(|(_, _, n)| *n == k).map(|(s, _, _)| *s))
|
||||
}
|
||||
/// A geometric characteristic (the first compartment of a frame line).
|
||||
pub fn is_characteristic(self) -> bool {
|
||||
!matches!(self, GdtSymbol::Diameter | GdtSymbol::Mmc | GdtSymbol::Lmc | GdtSymbol::Rfs | GdtSymbol::Projected)
|
||||
}
|
||||
/// A material condition modifier.
|
||||
pub fn is_material(self) -> bool {
|
||||
matches!(self, GdtSymbol::Mmc | GdtSymbol::Lmc | GdtSymbol::Rfs)
|
||||
}
|
||||
}
|
||||
|
||||
/// A piece of a compartment: plain text or a symbol.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum GdtRun {
|
||||
Text(String),
|
||||
Symbol(GdtSymbol),
|
||||
}
|
||||
|
||||
/// One compartment's runs.
|
||||
pub type Compartment = Vec<GdtRun>;
|
||||
|
||||
/// Parse a frame string into lines of compartments. Unknown formatting codes are skipped, an
|
||||
/// unterminated code ends the string, and the sizes are capped ([`MAX_FRAME_CHARS`],
|
||||
/// [`MAX_FRAME_ROWS`], [`MAX_FRAME_CELLS`]).
|
||||
pub fn parse_frame(text: &str) -> Vec<Vec<Compartment>> {
|
||||
let cs: Vec<char> = text.chars().take(MAX_FRAME_CHARS).collect();
|
||||
let at = |i: usize| cs.get(i).copied();
|
||||
let mut rows: Vec<Vec<Compartment>> = vec![vec![Vec::new()]];
|
||||
// Font stack: whether the `gdt` font is current, per brace level.
|
||||
let mut gdt = false;
|
||||
let mut stack: Vec<bool> = Vec::new();
|
||||
let push = |rows: &mut Vec<Vec<Compartment>>, run: GdtRun| {
|
||||
let Some(cell) = rows.last_mut().and_then(|r| r.last_mut()) else { return };
|
||||
match (cell.last_mut(), run) {
|
||||
(Some(GdtRun::Text(t)), GdtRun::Text(s)) => t.push_str(&s),
|
||||
(_, run) => cell.push(run),
|
||||
}
|
||||
};
|
||||
let mut i = 0;
|
||||
while let Some(c) = at(i) {
|
||||
let next = at(i + 1);
|
||||
let lower = |k: usize| at(k).map(|c| c.to_ascii_lowercase());
|
||||
// Line break: `^J`, `\P` or a raw newline.
|
||||
if (c == '^' && matches!(next, Some('J' | 'j'))) || (c == '\\' && next == Some('P')) || c == '\n' {
|
||||
if rows.len() >= MAX_FRAME_ROWS {
|
||||
break;
|
||||
}
|
||||
rows.push(vec![Vec::new()]);
|
||||
i += if c == '\n' { 1 } else { 2 };
|
||||
continue;
|
||||
}
|
||||
if c == '\r' {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if c == '%' && next == Some('%') {
|
||||
match lower(i + 2) {
|
||||
Some('v') => {
|
||||
if let Some(r) = rows.last_mut()
|
||||
&& r.len() < MAX_FRAME_CELLS
|
||||
{
|
||||
r.push(Vec::new());
|
||||
}
|
||||
}
|
||||
Some('c') => push(&mut rows, GdtRun::Symbol(GdtSymbol::Diameter)),
|
||||
Some('d') => push(&mut rows, GdtRun::Text("°".into())),
|
||||
Some('p') => push(&mut rows, GdtRun::Text("±".into())),
|
||||
Some('%') => push(&mut rows, GdtRun::Text("%".into())),
|
||||
Some(o) => push(&mut rows, GdtRun::Text(format!("%%{o}"))),
|
||||
None => break,
|
||||
}
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
match c {
|
||||
'{' => {
|
||||
if stack.len() < 64 {
|
||||
stack.push(gdt);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
'}' => {
|
||||
gdt = stack.pop().unwrap_or(false);
|
||||
i += 1;
|
||||
}
|
||||
'\\' => match next {
|
||||
Some('\\' | '{' | '}') => {
|
||||
if let Some(n) = next {
|
||||
push(&mut rows, GdtRun::Text(n.to_string()));
|
||||
}
|
||||
i += 2;
|
||||
}
|
||||
// Font switch `\Fname;` / `\fname|b0|i0;`: the gdt font draws symbols.
|
||||
Some('F' | 'f') => {
|
||||
let Some(end) = (i + 2..cs.len()).find(|k| at(*k) == Some(';')) else { break };
|
||||
let name: String = cs.get(i + 2..end).unwrap_or(&[]).iter().take_while(|c| **c != '|').collect();
|
||||
gdt = name.trim().to_ascii_lowercase().starts_with("gdt");
|
||||
i = end + 1;
|
||||
}
|
||||
// Codes with a value up to `;` (height, colour, tracking, width, oblique, alignment…).
|
||||
Some('A' | 'C' | 'c' | 'H' | 'Q' | 'T' | 'W' | 'S') => {
|
||||
let Some(end) = (i + 2..cs.len()).find(|k| at(*k) == Some(';')) else { break };
|
||||
i = end + 1;
|
||||
}
|
||||
// On/off toggles (underline, overline, strike-through) and the non-breaking space.
|
||||
Some('L' | 'l' | 'O' | 'o' | 'K' | 'k') => i += 2,
|
||||
Some('~') => {
|
||||
push(&mut rows, GdtRun::Text(" ".into()));
|
||||
i += 2;
|
||||
}
|
||||
Some(_) => {
|
||||
push(&mut rows, GdtRun::Text("\\".into()));
|
||||
i += 1;
|
||||
}
|
||||
None => break,
|
||||
},
|
||||
c if gdt => {
|
||||
push(&mut rows, GdtSymbol::from_code(c).map(GdtRun::Symbol).unwrap_or_else(|| GdtRun::Text(c.to_string())));
|
||||
i += 1;
|
||||
}
|
||||
c => {
|
||||
push(&mut rows, GdtRun::Text(c.to_string()));
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
/// A compartment with nothing to show.
|
||||
pub fn compartment_is_empty(c: &Compartment) -> bool {
|
||||
c.iter().all(|r| matches!(r, GdtRun::Text(t) if t.trim().is_empty()))
|
||||
}
|
||||
|
||||
/// A tolerance value: optional diameter symbol, the value, optional material condition.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GdtTol {
|
||||
#[serde(default)]
|
||||
pub diameter: bool,
|
||||
#[serde(default)]
|
||||
pub value: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub material: Option<String>,
|
||||
}
|
||||
|
||||
/// A datum reference: the letter(s) and an optional material condition.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GdtDatum {
|
||||
#[serde(default)]
|
||||
pub letter: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub material: Option<String>,
|
||||
}
|
||||
|
||||
/// One frame line as the Geometric Tolerance dialog fills it.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct GdtLine {
|
||||
pub symbol: Option<GdtSymbol>,
|
||||
pub tol1: Option<GdtTol>,
|
||||
pub tol2: Option<GdtTol>,
|
||||
pub datums: Vec<GdtDatum>,
|
||||
}
|
||||
|
||||
/// A whole feature control frame: up to two tolerance lines, the projected tolerance zone and
|
||||
/// the datum identifier.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct GdtFrame {
|
||||
pub lines: Vec<GdtLine>,
|
||||
/// Projected tolerance zone height ("" = none).
|
||||
pub height: String,
|
||||
pub projected: bool,
|
||||
/// Datum identifier ("" = none).
|
||||
pub datum_id: String,
|
||||
}
|
||||
|
||||
/// Escape free text for a frame string (no line or compartment breaks, no formatting codes).
|
||||
fn escape(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for c in s.chars().take(256) {
|
||||
match c {
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'{' => out.push_str("\\{"),
|
||||
'}' => out.push_str("\\}"),
|
||||
// `%%%` is a literal percent sign (a lone `%` could start a `%%` code).
|
||||
'%' => out.push_str("%%%"),
|
||||
'\n' | '\r' | '^' => out.push(' '),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn sym(s: GdtSymbol) -> String {
|
||||
format!("{{\\Fgdt;{}}}", s.code())
|
||||
}
|
||||
|
||||
/// The material condition symbol named by `m` ("M", "L", "S", "mmc"…).
|
||||
fn material(m: &Option<String>) -> Option<GdtSymbol> {
|
||||
m.as_deref().and_then(GdtSymbol::parse).filter(|s| s.is_material())
|
||||
}
|
||||
|
||||
/// The frame string for `f` (DXF group 1 of a TOLERANCE).
|
||||
pub fn compose_frame(f: &GdtFrame) -> String {
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
let tol = |t: &Option<GdtTol>| -> String {
|
||||
let Some(t) = t else { return String::new() };
|
||||
let mut s = String::new();
|
||||
if t.diameter {
|
||||
s.push_str(&sym(GdtSymbol::Diameter));
|
||||
}
|
||||
s.push_str(&escape(t.value.trim()));
|
||||
if let Some(m) = material(&t.material) {
|
||||
s.push_str(&sym(m));
|
||||
}
|
||||
if t.value.trim().is_empty() && !t.diameter && material(&t.material).is_none() { String::new() } else { s }
|
||||
};
|
||||
for l in f.lines.iter().take(MAX_FRAME_ROWS) {
|
||||
let mut cells = vec![l.symbol.map(sym).unwrap_or_default(), tol(&l.tol1), tol(&l.tol2)];
|
||||
for d in l.datums.iter().take(3) {
|
||||
let mut s = escape(d.letter.trim());
|
||||
if let Some(m) = material(&d.material) {
|
||||
s.push_str(&sym(m));
|
||||
}
|
||||
cells.push(s);
|
||||
}
|
||||
while cells.last().is_some_and(String::is_empty) {
|
||||
cells.pop();
|
||||
}
|
||||
if !cells.is_empty() {
|
||||
lines.push(cells.join("%%v"));
|
||||
}
|
||||
}
|
||||
if !f.height.trim().is_empty() || f.projected {
|
||||
let mut s = escape(f.height.trim());
|
||||
if f.projected {
|
||||
s.push_str(&sym(GdtSymbol::Projected));
|
||||
}
|
||||
lines.push(s);
|
||||
}
|
||||
if !f.datum_id.trim().is_empty() {
|
||||
lines.push(escape(f.datum_id.trim()));
|
||||
}
|
||||
lines.join("^J")
|
||||
}
|
||||
|
||||
/// A primitive of a laid-out frame, in WCS.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum GdtPrim {
|
||||
Line(Vec<Vec2>),
|
||||
Circle {
|
||||
center: Vec2,
|
||||
radius: f64,
|
||||
},
|
||||
/// Counter-clockwise from `start` to `end` (radians).
|
||||
Arc {
|
||||
center: Vec2,
|
||||
radius: f64,
|
||||
start: f64,
|
||||
end: f64,
|
||||
},
|
||||
/// A filled triangle (arrowheads).
|
||||
Fill([Vec2; 3]),
|
||||
}
|
||||
|
||||
/// A text run of a laid-out frame: left baseline point (WCS), height and rotation.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct GdtText {
|
||||
pub pos: Vec2,
|
||||
pub height: f64,
|
||||
pub rotation: f64,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
/// A laid-out feature control frame.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct GdtLayout {
|
||||
/// The frame's boxes (drawn with the dimension line colour).
|
||||
pub frame: Vec<GdtPrim>,
|
||||
/// The symbols (drawn with the text colour).
|
||||
pub symbols: Vec<GdtPrim>,
|
||||
pub texts: Vec<GdtText>,
|
||||
pub bounds: Bounds2,
|
||||
}
|
||||
|
||||
/// Lay out the frame `text` at `insert`, rotated by `rotation`, with text height `h` and
|
||||
/// horizontal padding `gap`. `measure(s, h)` is the width of plain text `s` at height `h`.
|
||||
pub fn layout_frame(text: &str, insert: Vec2, rotation: f64, h: f64, gap: f64, measure: &dyn Fn(&str, f64) -> f64) -> GdtLayout {
|
||||
let h = if h.is_finite() && h > 0.0 { h.min(1e12) } else { 0.18 };
|
||||
let gap = if gap.is_finite() { gap.abs().clamp(h * 0.25, h * 2.0) } else { h * 0.5 };
|
||||
let rotation = if rotation.is_finite() { rotation } else { 0.0 };
|
||||
let (insert, row_h) = (if insert.is_finite() { insert } else { Vec2::ZERO }, 2.0 * h);
|
||||
let to_w = |p: Vec2| insert + p.rotate(rotation);
|
||||
let mut out = GdtLayout::default();
|
||||
let mut prev_width = 0.0;
|
||||
let rows: Vec<Vec<Compartment>> = parse_frame(text)
|
||||
.into_iter()
|
||||
.map(|r| r.into_iter().filter(|c| !compartment_is_empty(c)).collect::<Vec<_>>())
|
||||
.filter(|r| !r.is_empty())
|
||||
.collect();
|
||||
let run_w = |r: &GdtRun| match r {
|
||||
GdtRun::Symbol(_) => h,
|
||||
GdtRun::Text(t) => {
|
||||
let w = measure(t, h);
|
||||
if w.is_finite() { w.clamp(0.0, h * 4096.0) } else { 0.0 }
|
||||
}
|
||||
};
|
||||
for (k, row) in rows.iter().enumerate() {
|
||||
let top = h - k as f64 * row_h;
|
||||
let (bot, mid) = (top - row_h, top - h);
|
||||
let mut x = 0.0;
|
||||
for cell in row {
|
||||
// Runs a little apart, so a modifier doesn't touch the value before it.
|
||||
let sep = 0.25 * h;
|
||||
let w: f64 = cell.iter().map(run_w).sum::<f64>() + sep * cell.len().saturating_sub(1) as f64;
|
||||
let cw = (w + 2.0 * gap).max(row_h);
|
||||
// The separator on the cell's left (the frame's left edge for the first cell).
|
||||
out.frame.push(GdtPrim::Line(vec![to_w(Vec2::new(x, top)), to_w(Vec2::new(x, bot))]));
|
||||
let mut cx = x + (cw - w) / 2.0;
|
||||
for r in cell {
|
||||
match r {
|
||||
GdtRun::Symbol(s) => {
|
||||
let o = Vec2::new(cx, mid - h / 2.0);
|
||||
out.symbols.extend(symbol_prims(*s).into_iter().map(|p| map_prim(p, |q| to_w(o + q * h), h, rotation)));
|
||||
}
|
||||
GdtRun::Text(t) => {
|
||||
out.texts.push(GdtText { pos: to_w(Vec2::new(cx, mid - h / 2.0)), height: h, rotation, value: t.clone() });
|
||||
}
|
||||
}
|
||||
cx += run_w(r) + sep;
|
||||
}
|
||||
x += cw;
|
||||
}
|
||||
out.frame.push(GdtPrim::Line(vec![to_w(Vec2::new(x, top)), to_w(Vec2::new(x, bot))]));
|
||||
// Top edge: the first line's, or the part sticking out past the line above.
|
||||
if k == 0 {
|
||||
out.frame.push(GdtPrim::Line(vec![to_w(Vec2::new(0.0, top)), to_w(Vec2::new(x, top))]));
|
||||
} else if x > prev_width {
|
||||
out.frame.push(GdtPrim::Line(vec![to_w(Vec2::new(prev_width, top)), to_w(Vec2::new(x, top))]));
|
||||
}
|
||||
out.frame.push(GdtPrim::Line(vec![to_w(Vec2::new(0.0, bot)), to_w(Vec2::new(x, bot))]));
|
||||
out.bounds = out.bounds.union(&Bounds2::from_points([
|
||||
to_w(Vec2::new(0.0, top)),
|
||||
to_w(Vec2::new(x, bot)),
|
||||
to_w(Vec2::new(0.0, bot)),
|
||||
to_w(Vec2::new(x, top)),
|
||||
]));
|
||||
prev_width = x;
|
||||
}
|
||||
if rows.is_empty() {
|
||||
out.bounds = Bounds2::from_points([insert]);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Map a unit-box primitive through `f` (scale `s`, rotation `rot`).
|
||||
fn map_prim(p: GdtPrim, f: impl Fn(Vec2) -> Vec2, s: f64, rot: f64) -> GdtPrim {
|
||||
match p {
|
||||
GdtPrim::Line(v) => GdtPrim::Line(v.into_iter().map(f).collect()),
|
||||
GdtPrim::Circle { center, radius } => GdtPrim::Circle { center: f(center), radius: radius * s },
|
||||
GdtPrim::Arc { center, radius, start, end } => GdtPrim::Arc { center: f(center), radius: radius * s, start: start + rot, end: end + rot },
|
||||
GdtPrim::Fill(t) => GdtPrim::Fill(t.map(f)),
|
||||
}
|
||||
}
|
||||
|
||||
/// An arrowhead at `tip` pointing along `d` (unit box units).
|
||||
fn arrow(tip: Vec2, d: Vec2) -> GdtPrim {
|
||||
let d = d.normalized();
|
||||
let back = tip - d * 0.3;
|
||||
GdtPrim::Fill([tip, back + d.perp() * 0.1, back - d.perp() * 0.1])
|
||||
}
|
||||
|
||||
/// A symbol drawn in a unit box (x and y in 0..1, the baseline at y = 0).
|
||||
pub fn symbol_prims(s: GdtSymbol) -> Vec<GdtPrim> {
|
||||
use std::f64::consts::{FRAC_PI_2, PI};
|
||||
let v = Vec2::new;
|
||||
let line = |pts: &[(f64, f64)]| GdtPrim::Line(pts.iter().map(|(x, y)| v(*x, *y)).collect());
|
||||
let circle = |x: f64, y: f64, r: f64| GdtPrim::Circle { center: v(x, y), radius: r };
|
||||
// Circled letters: the circle plus the letter's strokes.
|
||||
let circled = |mut letter: Vec<GdtPrim>| {
|
||||
letter.insert(0, circle(0.5, 0.5, 0.5));
|
||||
letter
|
||||
};
|
||||
match s {
|
||||
GdtSymbol::Position => vec![circle(0.5, 0.5, 0.3), line(&[(0.0, 0.5), (1.0, 0.5)]), line(&[(0.5, 0.0), (0.5, 1.0)])],
|
||||
GdtSymbol::Concentricity => vec![circle(0.5, 0.5, 0.45), circle(0.5, 0.5, 0.25)],
|
||||
GdtSymbol::Symmetry => vec![line(&[(0.0, 0.5), (1.0, 0.5)]), line(&[(0.2, 0.2), (0.8, 0.2)]), line(&[(0.2, 0.8), (0.8, 0.8)])],
|
||||
GdtSymbol::Parallelism => vec![line(&[(0.1, 0.05), (0.5, 0.95)]), line(&[(0.5, 0.05), (0.9, 0.95)])],
|
||||
GdtSymbol::Perpendicularity => vec![line(&[(0.05, 0.05), (0.95, 0.05)]), line(&[(0.5, 0.05), (0.5, 0.95)])],
|
||||
GdtSymbol::Angularity => vec![line(&[(0.95, 0.05), (0.05, 0.05), (0.85, 0.75)])],
|
||||
GdtSymbol::Cylindricity => {
|
||||
let (c, r) = (v(0.5, 0.5), 0.3);
|
||||
let d = Vec2::from_angle(65f64.to_radians());
|
||||
let n = d.perp() * r;
|
||||
vec![circle(0.5, 0.5, r), GdtPrim::Line(vec![c + n - d * 0.5, c + n + d * 0.5]), GdtPrim::Line(vec![c - n - d * 0.5, c - n + d * 0.5])]
|
||||
}
|
||||
GdtSymbol::Flatness => vec![line(&[(0.0, 0.25), (0.7, 0.25), (1.0, 0.75), (0.3, 0.75), (0.0, 0.25)])],
|
||||
GdtSymbol::Circularity => vec![circle(0.5, 0.5, 0.45)],
|
||||
GdtSymbol::Straightness => vec![line(&[(0.0, 0.5), (1.0, 0.5)])],
|
||||
GdtSymbol::SurfaceProfile => {
|
||||
vec![GdtPrim::Arc { center: v(0.5, 0.2), radius: 0.45, start: 0.0, end: PI }, line(&[(0.05, 0.2), (0.95, 0.2)])]
|
||||
}
|
||||
GdtSymbol::LineProfile => vec![GdtPrim::Arc { center: v(0.5, 0.2), radius: 0.45, start: 0.0, end: PI }],
|
||||
GdtSymbol::CircularRunout => vec![line(&[(0.2, 0.05), (0.7, 0.95)]), arrow(v(0.7, 0.95), v(0.5, 0.9))],
|
||||
GdtSymbol::TotalRunout => vec![
|
||||
line(&[(0.05, 0.05), (0.5, 0.05)]),
|
||||
line(&[(0.05, 0.05), (0.45, 0.95)]),
|
||||
line(&[(0.5, 0.05), (0.9, 0.95)]),
|
||||
arrow(v(0.45, 0.95), v(0.4, 0.9)),
|
||||
arrow(v(0.9, 0.95), v(0.4, 0.9)),
|
||||
],
|
||||
GdtSymbol::Diameter => vec![circle(0.5, 0.5, 0.35), line(&[(0.1, 0.0), (0.9, 1.0)])],
|
||||
GdtSymbol::Mmc => circled(vec![line(&[(0.3, 0.25), (0.3, 0.75), (0.5, 0.45), (0.7, 0.75), (0.7, 0.25)])]),
|
||||
GdtSymbol::Lmc => circled(vec![line(&[(0.38, 0.75), (0.38, 0.25), (0.65, 0.25)])]),
|
||||
GdtSymbol::Rfs => circled(vec![
|
||||
GdtPrim::Arc { center: v(0.5, 0.625), radius: 0.125, start: 20f64.to_radians(), end: 3.0 * FRAC_PI_2 },
|
||||
GdtPrim::Arc { center: v(0.5, 0.375), radius: 0.125, start: 200f64.to_radians(), end: FRAC_PI_2 },
|
||||
]),
|
||||
GdtSymbol::Projected => circled(vec![
|
||||
line(&[(0.5, 0.5), (0.38, 0.5), (0.38, 0.75), (0.5, 0.75)]),
|
||||
GdtPrim::Arc { center: v(0.5, 0.625), radius: 0.125, start: -FRAC_PI_2, end: FRAC_PI_2 },
|
||||
line(&[(0.38, 0.5), (0.38, 0.25)]),
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_symbols_compartments_and_lines() {
|
||||
let rows = parse_frame("{\\Fgdt;j}%%v{\\Fgdt;n}0.1{\\Fgdt;m}%%vA%%vB^J2.5{\\Fgdt;p}");
|
||||
assert_eq!(rows.len(), 2);
|
||||
let r0 = &rows[0];
|
||||
assert_eq!(r0.len(), 4);
|
||||
assert_eq!(r0[0], vec![GdtRun::Symbol(GdtSymbol::Position)]);
|
||||
assert_eq!(r0[1], vec![GdtRun::Symbol(GdtSymbol::Diameter), GdtRun::Text("0.1".into()), GdtRun::Symbol(GdtSymbol::Mmc)]);
|
||||
assert_eq!(r0[3], vec![GdtRun::Text("B".into())]);
|
||||
assert_eq!(rows[1][0], vec![GdtRun::Text("2.5".into()), GdtRun::Symbol(GdtSymbol::Projected)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_then_parse() {
|
||||
let f = GdtFrame {
|
||||
lines: vec![GdtLine {
|
||||
symbol: Some(GdtSymbol::Flatness),
|
||||
tol1: Some(GdtTol { diameter: false, value: "0.05".into(), material: None }),
|
||||
tol2: None,
|
||||
datums: vec![GdtDatum { letter: "A".into(), material: Some("L".into()) }],
|
||||
}],
|
||||
height: String::new(),
|
||||
projected: false,
|
||||
datum_id: "B".into(),
|
||||
};
|
||||
let s = compose_frame(&f);
|
||||
assert_eq!(s, "{\\Fgdt;c}%%v0.05%%v%%vA{\\Fgdt;l}^JB");
|
||||
let rows = parse_frame(&s);
|
||||
assert_eq!(rows[0][3], vec![GdtRun::Text("A".into()), GdtRun::Symbol(GdtSymbol::Lmc)]);
|
||||
assert_eq!(rows[1][0], vec![GdtRun::Text("B".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_strings_do_not_panic() {
|
||||
let huge = "%%v{\\Fgdt;j}".repeat(100_000);
|
||||
for s in ["{\\Fgdt;", "{\\Fgdt", "\\", "%%", "%", "{{{{", "}}}}", "^", "\\P\\P\\P", "{\\H", &huge, "\u{0}%%v\u{ffff}"] {
|
||||
let rows = parse_frame(s);
|
||||
assert!(rows.len() <= MAX_FRAME_ROWS && rows.iter().all(|r| r.len() <= MAX_FRAME_CELLS));
|
||||
let l = layout_frame(s, Vec2::new(f64::NAN, 1.0), f64::INFINITY, f64::NAN, -1.0, &|t, h| t.len() as f64 * h);
|
||||
assert!(l.frame.iter().all(|p| matches!(p, GdtPrim::Line(v) if v.iter().all(|q| q.is_finite()))));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_boxes_each_compartment() {
|
||||
let l = layout_frame("{\\Fgdt;j}%%v0.1%%vA", Vec2::ZERO, 0.0, 1.0, 0.5, &|t, h| t.chars().count() as f64 * h);
|
||||
// Three cells: four separators, top and bottom.
|
||||
assert_eq!(l.frame.len(), 6);
|
||||
assert_eq!(l.texts.len(), 2);
|
||||
assert!((l.bounds.min.y + 1.0).abs() < 1e-9 && (l.bounds.max.y - 1.0).abs() < 1e-9);
|
||||
assert!(l.bounds.min.x.abs() < 1e-9 && l.bounds.max.x > 6.0);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use cadcraft_geom::{Vec2, Vec3};
|
||||
use cadcraft_geom::Vec3;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A header variable value.
|
||||
@@ -76,20 +76,6 @@ impl Header {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// Drawing snap spacing; malformed or missing values use the drafting default.
|
||||
pub fn snap_unit(&self) -> Vec2 {
|
||||
self.spacing("SNAPUNIT", false)
|
||||
}
|
||||
/// Drawing grid spacing. A zero component follows the corresponding snap spacing.
|
||||
pub fn grid_unit(&self) -> Vec2 {
|
||||
self.spacing("GRIDUNIT", true)
|
||||
}
|
||||
fn spacing(&self, name: &str, allow_zero: bool) -> Vec2 {
|
||||
self.point(name)
|
||||
.map(|p| p.xy())
|
||||
.filter(|p| p.is_finite() && if allow_zero { p.x >= 0.0 && p.y >= 0.0 } else { p.x > 0.0 && p.y > 0.0 })
|
||||
.unwrap_or(Vec2::new(0.5, 0.5))
|
||||
}
|
||||
pub fn set_f64(&mut self, name: &str, v: f64) {
|
||||
self.set(name, HVal::Real(v));
|
||||
}
|
||||
@@ -132,11 +118,6 @@ impl Header {
|
||||
// Snap grid origin and rotation (SNAPANG in degrees).
|
||||
h.set("SNAPBASE", HVal::Point(Vec3::ZERO));
|
||||
h.set_f64("SNAPANG", 0.0);
|
||||
h.set("SNAPUNIT", HVal::Point(Vec3::new(0.5, 0.5, 0.0)));
|
||||
h.set("GRIDUNIT", HVal::Point(Vec3::new(0.5, 0.5, 0.0)));
|
||||
// Annotation scale of model space; annotative objects of other scales show (1) or not.
|
||||
h.set_str("CANNOSCALE", "1:1");
|
||||
h.set_i64("ANNOALLVISIBLE", 1);
|
||||
h
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user