mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-12 11:41:56 +00:00
Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e37a587251 | |||
| d6349b00e4 | |||
| 90bb901ed1 | |||
| e81520bcfc | |||
| 8d90a2c89f | |||
| d37bd9dfef | |||
| e68c50a213 | |||
| fa1bd45ac0 | |||
| 829ce9c005 | |||
| 8ebd56d334 | |||
| b46bbe0735 | |||
| 2220e049bc | |||
| 9fd103a0f3 | |||
| 64a994f165 | |||
| 09d64fd4dc | |||
| a1b6fcf788 | |||
| 5c6e753309 | |||
| 4b3ce9dfd4 | |||
| 3d9725dc82 | |||
| 4acb6aa1a4 | |||
| 12f5bc9252 | |||
| 892c6370d1 | |||
| 20c15af4e7 | |||
| 7da403034d | |||
| 23d61d7023 | |||
| 7e0e63504e | |||
| 14a9cf73f3 | |||
| 7d48e91b39 | |||
| f84ef28205 | |||
| ef542c2fe6 | |||
| b532aa94b6 | |||
| d5c63b1577 | |||
| 9bdb116cea | |||
| cf5df700fb | |||
| dacbb52a76 | |||
| 386da25848 | |||
| 8bb806564c | |||
| 3096171e65 | |||
| a2dd5a3baf | |||
| 2b1cc1d77a | |||
| f5a74ee7d4 | |||
| ff166a3401 | |||
| 47ef5cb5b6 | |||
| 3c93d21ca4 | |||
| 6754aa1a0e | |||
| b1ebc2c2da | |||
| e21c78b940 | |||
| 330c859f11 | |||
| 3453963cf3 | |||
| c678287d5f | |||
| e9158d51fb | |||
| a88a5b782c | |||
| af078d1867 | |||
| 30c28c11ea | |||
| 01c86d9cb1 | |||
| e64c6af4d4 | |||
| 295a5e1410 | |||
| 81afe5b90d | |||
| 937722290a | |||
| fdc13eaef5 | |||
| fa3d016da8 | |||
| 185667c004 | |||
| b935118729 | |||
| 5cd9b8ffc6 | |||
| 02afa23329 | |||
| db742b4a8f | |||
| 0371e7d2d1 | |||
| 270573eb0b | |||
| 1155cdd918 | |||
| 21c7836995 | |||
| b4e1178bdd |
@@ -9,3 +9,9 @@ self-hosted-runner:
|
||||
- blacksmith-16vcpu-ubuntu-2404
|
||||
- blacksmith-32vcpu-ubuntu-2404
|
||||
- blacksmith-8vcpu-windows-2025
|
||||
# Release builds (workflows/release.yml).
|
||||
- blacksmith-16vcpu-ubuntu-2204
|
||||
- blacksmith-16vcpu-ubuntu-2204-arm
|
||||
- blacksmith-4vcpu-ubuntu-2404-arm
|
||||
- blacksmith-6vcpu-macos-15
|
||||
- blacksmith-12vcpu-macos-15
|
||||
|
||||
@@ -34,8 +34,9 @@ env:
|
||||
jobs:
|
||||
build:
|
||||
if: ${{ !github.event.repository.private }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
# Blacksmith, as CI: the web app's release build outgrew 60 minutes on GitHub's 4-core runner.
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@1.98.0
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
# The macOS app is signed and notarized when the APPLE_* secrets exist. The Windows binaries and
|
||||
# MSI are Authenticode-signed through SignPath Foundation when the SIGNPATH_API_TOKEN secret exists
|
||||
# (see the windows job). Nothing else is signed yet.
|
||||
# Runners: Blacksmith (blacksmith-<N>vcpu-<os>), as CI uses, except the `version` and `windows`
|
||||
# jobs, which stay GitHub-hosted: SignPath Foundation only signs a request when every job leading
|
||||
# up to it ran on a GitHub-hosted runner.
|
||||
#
|
||||
# Linux x86_64, aarch64 AppImage (+ .zsync), .deb, .rpm, .flatpak, .snap, .tar.gz packaging/linux
|
||||
# Arch Linux x86_64, aarch64 .pkg.tar.zst, repackaged from the .tar.gz the arch job
|
||||
@@ -114,40 +117,18 @@ jobs:
|
||||
{ echo "version=$VERSION"; echo "date=$(date -u +%Y-%m-%d)"; echo "prerelease=$pre"; } >>"$GITHUB_OUTPUT"
|
||||
echo "### SolidCraft v$VERSION (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# The app icon in every size, drawn by solidcraft-cli (no image files are committed), for the
|
||||
# Windows jobs: the ARM64 build is cross-compiled and can't run there to draw its own.
|
||||
icons:
|
||||
needs: version
|
||||
if: needs.version.outputs.build == 'true'
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: dtolnay/rust-toolchain@1.98.0
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: release-icons
|
||||
- run: cargo build --release --locked -p solidcraft-cli
|
||||
- run: |
|
||||
. packaging/env.sh
|
||||
make_icons target/release/solidcraft-cli icons
|
||||
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: icons
|
||||
path: icons
|
||||
if-no-files-found: error
|
||||
|
||||
linux:
|
||||
name: Linux ${{ matrix.arch }}
|
||||
needs: version
|
||||
if: needs.version.outputs.build == 'true'
|
||||
# The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35.
|
||||
# Ubuntu 22.04, the oldest image, so the binaries only need glibc >= 2.35.
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
|
||||
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
|
||||
- { arch: x86_64, runner: blacksmith-16vcpu-ubuntu-2204, deb: amd64 }
|
||||
- { arch: aarch64, runner: blacksmith-16vcpu-ubuntu-2204-arm, deb: arm64 }
|
||||
env:
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
@@ -203,8 +184,8 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: ubuntu-24.04 }
|
||||
- { arch: aarch64, runner: ubuntu-24.04-arm }
|
||||
- { arch: x86_64, runner: blacksmith-4vcpu-ubuntu-2404 }
|
||||
- { arch: aarch64, runner: blacksmith-4vcpu-ubuntu-2404-arm }
|
||||
env:
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
@@ -233,7 +214,7 @@ jobs:
|
||||
needs: [version, linux]
|
||||
if: needs.version.outputs.build == 'true'
|
||||
# makepkg only repackages the tarball's prebuilt binaries, so both architectures build here.
|
||||
runs-on: ubuntu-24.04
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
# A pre-release or nightly without an Arch package is still worth having; a stable release isn't.
|
||||
continue-on-error: ${{ contains(needs.version.outputs.version, '-') }}
|
||||
strategy:
|
||||
@@ -303,8 +284,8 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { arch: x86_64, runner: ubuntu-24.04 }
|
||||
- { arch: aarch64, runner: ubuntu-24.04-arm }
|
||||
- { arch: x86_64, runner: blacksmith-4vcpu-ubuntu-2404 }
|
||||
- { arch: aarch64, runner: blacksmith-4vcpu-ubuntu-2404-arm }
|
||||
env:
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
@@ -349,8 +330,8 @@ jobs:
|
||||
name: FreeBSD x86_64
|
||||
needs: version
|
||||
if: needs.version.outputs.build == 'true'
|
||||
# GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM.
|
||||
runs-on: ubuntu-latest
|
||||
# There are no FreeBSD runners: build in a FreeBSD 14.3 VM (Blacksmith's x64 runners have KVM).
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
timeout-minutes: 180
|
||||
env:
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
@@ -363,8 +344,8 @@ jobs:
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
cpu: 14
|
||||
mem: 40960
|
||||
# Passed into the VM by name; never templated into the script below.
|
||||
envs: "SOLIDCRAFT_VERSION SOLIDCRAFT_BUILD_DATE"
|
||||
prepare: |
|
||||
@@ -374,9 +355,9 @@ jobs:
|
||||
set -e
|
||||
. "$HOME/.cargo/env"
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# Build outside the checkout so only dist/ is copied back out of the VM. 4 jobs: more
|
||||
# runs the VM out of memory.
|
||||
export CARGO_TARGET_DIR=/var/tmp/solidcraft-target CARGO_BUILD_JOBS=4
|
||||
# Build outside the checkout so only dist/ is copied back out of the VM. About 3 GB of
|
||||
# VM memory per job: 4 jobs in 12 GB was the most that fit before.
|
||||
export CARGO_TARGET_DIR=/var/tmp/solidcraft-target CARGO_BUILD_JOBS=12
|
||||
bash packaging/freebsd/package.sh
|
||||
tar -tzf dist/release/solidcraft-*-freebsd-x86_64.tar.gz \
|
||||
| grep -E '/bin/solidcraft$|/bin/solidcraft-cli$|/share/applications/io\.tangencylabs\.solidcraft\.desktop$|/share/doc/solidcraft/THIRD-PARTY-LICENSES\.txt$'
|
||||
@@ -388,11 +369,12 @@ jobs:
|
||||
|
||||
# The two halves of the universal binary build in parallel on their own runners (each alone
|
||||
# took about half of the old single job's 45 minutes); `macos` below joins and packages them.
|
||||
# Blacksmith's macOS runners are Apple silicon: the x86_64 half is cross-compiled.
|
||||
macos-build:
|
||||
name: macOS build ${{ matrix.arch }}
|
||||
needs: version
|
||||
if: needs.version.outputs.build == 'true'
|
||||
runs-on: macos-15
|
||||
runs-on: blacksmith-12vcpu-macos-15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -426,7 +408,7 @@ jobs:
|
||||
name: macOS universal
|
||||
needs: [version, macos-build]
|
||||
if: needs.version.outputs.build == 'true'
|
||||
runs-on: macos-15
|
||||
runs-on: blacksmith-6vcpu-macos-15
|
||||
env:
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
@@ -466,10 +448,11 @@ jobs:
|
||||
|
||||
windows:
|
||||
name: Windows ${{ matrix.arch }}
|
||||
needs: [version, icons]
|
||||
needs: version
|
||||
if: needs.version.outputs.build == 'true'
|
||||
# Must stay a GitHub-hosted runner, like every job before it: SignPath's open-source policy
|
||||
# only signs builds whose jobs all ran on GitHub-hosted runners.
|
||||
# Must stay a GitHub-hosted runner, like the `version` job before it: SignPath's open-source
|
||||
# policy only signs requests whose jobs leading up to them all ran on GitHub-hosted runners.
|
||||
# The installer icon is the committed assets/icons/app/windows/exports/solidcraft.ico.
|
||||
runs-on: windows-latest
|
||||
# actions: read lets the SignPath action read this job's details and fetch the uploaded
|
||||
# artifact with GITHUB_TOKEN (the workflow default above grants contents: read only).
|
||||
@@ -498,10 +481,6 @@ jobs:
|
||||
SIGNPATH_PROJECT_SLUG: solidcraft
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
with:
|
||||
name: icons
|
||||
path: icons
|
||||
- uses: dtolnay/rust-toolchain@1.98.0
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
@@ -516,8 +495,6 @@ jobs:
|
||||
- name: Build and package (msi, portable zip)
|
||||
if: env.SIGN_WINDOWS != 'true'
|
||||
shell: pwsh
|
||||
env:
|
||||
SOLIDCRAFT_ICONS: ${{ github.workspace }}/icons
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
|
||||
|
||||
# ---- signed path ---------------------------------------------------------------------------
|
||||
@@ -531,8 +508,6 @@ jobs:
|
||||
- name: Build and stage the binaries for signing
|
||||
if: env.SIGN_WINDOWS == 'true'
|
||||
shell: pwsh
|
||||
env:
|
||||
SOLIDCRAFT_ICONS: ${{ github.workspace }}/icons
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }} -Phase stage
|
||||
- name: Upload the unsigned binaries
|
||||
id: unsigned-exes
|
||||
@@ -576,8 +551,6 @@ jobs:
|
||||
- name: Package the signed binaries (msi, portable zip)
|
||||
if: env.SIGN_WINDOWS == 'true'
|
||||
shell: pwsh
|
||||
env:
|
||||
SOLIDCRAFT_ICONS: ${{ github.workspace }}/icons
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }} -Phase package
|
||||
- name: Upload the unsigned MSI
|
||||
id: unsigned-msi
|
||||
@@ -627,7 +600,7 @@ jobs:
|
||||
name: Web (wasm)
|
||||
needs: version
|
||||
if: needs.version.outputs.build == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
env:
|
||||
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
TRUNK_VERSION: 0.21.14
|
||||
@@ -656,7 +629,7 @@ jobs:
|
||||
needs: [version, linux, flatpak, arch, snap, freebsd, macos, windows, web]
|
||||
# Not for the release gate (packages_only): it builds and checks the packages, nothing more.
|
||||
if: needs.version.outputs.build == 'true' && !inputs.packages_only
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
|
||||
@@ -480,7 +480,7 @@ fn output_checks(c: &mut Checks, want: &Value, out: &str, results: &[Value], rel
|
||||
|
||||
/// The word a `surface_types` check names a face's surface by, as the record holds it: an
|
||||
/// analytic kind, `ball_patch` (a corner's patch on a sphere), `bspline` (a B-spline or NURBS
|
||||
/// surface) or `revolution` (a revolved curve).
|
||||
/// surface), `revolution` (a revolved curve) or `blend` (a round by its definition, on its net).
|
||||
fn surface_word(s: &solidcore_record::brep::Surface) -> &'static str {
|
||||
use solidcore_record::brep::Surface as S;
|
||||
match s {
|
||||
@@ -492,12 +492,14 @@ fn surface_word(s: &solidcore_record::brep::Surface) -> &'static str {
|
||||
S::Torus { .. } => "torus",
|
||||
S::Spline { .. } | S::RationalSpline { .. } => "bspline",
|
||||
S::PlacedRevolution { .. } => "revolution",
|
||||
S::Blend { .. } => "blend",
|
||||
}
|
||||
}
|
||||
|
||||
/// A B-spline face's two directions as `[degree, spans]` (spans: the distinct knot intervals of
|
||||
/// the modelled range), the lower first, so a surface parameterised the other way round reads the
|
||||
/// same. `None` for a surface that is not a B-spline.
|
||||
/// same. `None` for a surface that is not a B-spline. A blend face is read on its net, its NURBS
|
||||
/// form.
|
||||
fn spline_shape(s: &solidcore_record::brep::Surface) -> Option<[[u64; 2]; 2]> {
|
||||
use solidcore_record::brep::Surface as S;
|
||||
let (u, v, nu, nv) = match s {
|
||||
@@ -815,7 +817,12 @@ fn check_case_in(
|
||||
let record = b.body.record();
|
||||
for f in &record.faces {
|
||||
kinds.insert(surface_word(&f.surface));
|
||||
spline_faces.extend(spline_shape(&f.surface));
|
||||
// A blend face's net is its NURBS form: the spline check reads its degrees there.
|
||||
let net = match &f.surface {
|
||||
solidcore_record::brep::Surface::Blend { .. } => f.nurbs.as_ref(),
|
||||
_ => None,
|
||||
};
|
||||
spline_faces.extend(spline_shape(net.unwrap_or(&f.surface)));
|
||||
}
|
||||
boundary += record.edges.iter().filter(|e| e.uses.len() == 1).count() as u64;
|
||||
}
|
||||
|
||||
@@ -45,4 +45,5 @@ web-sys = { version = "0.3", features = [
|
||||
"IdbFactory", "IdbDatabase", "IdbObjectStore", "IdbOpenDbRequest", "IdbRequest", "IdbTransaction", "IdbTransactionMode",
|
||||
"Headers", "ReadableStream", "ReadableStreamDefaultReader",
|
||||
"BeforeUnloadEvent", "Event", "EventTarget", "KeyboardEvent", "Response", "FormData", "RequestInit", "MediaQueryList", "Gamepad", "Performance",
|
||||
"PointerEvent", "Node", "VisibilityState",
|
||||
] }
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
<link data-trunk rel="copy-file" href="../../assets/fonts/craft-ui-sc/OFL.txt" data-target-path="fonts/craft-ui-sc" />
|
||||
<link data-trunk rel="copy-file" href="../../assets/fonts/craft-ui-tc/CraftUI-TC.otf" data-target-path="fonts" />
|
||||
<link data-trunk rel="copy-file" href="../../assets/fonts/craft-ui-tc/OFL.txt" data-target-path="fonts/craft-ui-tc" />
|
||||
<link data-trunk rel="copy-file" href="../../assets/fonts/craft-ui-kr/CraftUI-KR.otf" data-target-path="fonts" />
|
||||
<link data-trunk rel="copy-file" href="../../assets/fonts/craft-ui-kr/OFL.txt" data-target-path="fonts/craft-ui-kr" />
|
||||
<link data-trunk rel="css" href="../../assets/styles/web/loading.css" />
|
||||
<link data-trunk rel="copy-file" href="../../assets/styles/web/loading-logo.svg" />
|
||||
<!-- Installable as an app (manifest.webmanifest); the icons are the Linux exports of the app mark. -->
|
||||
|
||||
@@ -48,6 +48,7 @@ impl Api {
|
||||
let execute = api.clone();
|
||||
let commands = api.clone();
|
||||
let inspect = api.clone();
|
||||
let buttons = api.ctx.clone();
|
||||
for (name, function) in [
|
||||
(
|
||||
"request",
|
||||
@@ -69,6 +70,16 @@ impl Api {
|
||||
Closure::<dyn Fn(JsValue) -> js_sys::Promise>::new(move |value: JsValue| inspect.request("document.inspect", params(&value)))
|
||||
.into_js_value(),
|
||||
),
|
||||
// The mouse buttons egui holds now, by bit as in a pointer event's `buttons`
|
||||
// (1 primary, 2 secondary, 4 middle, 8 back, 16 forward): for checking that they
|
||||
// follow the browser's (`mouse_buttons`).
|
||||
(
|
||||
"buttons",
|
||||
Closure::<dyn Fn() -> u32>::new(move || {
|
||||
crate::mouse_buttons::egui_down(&buttons).iter().enumerate().filter(|(_, down)| **down).map(|(bit, _)| 1 << bit).sum()
|
||||
})
|
||||
.into_js_value(),
|
||||
),
|
||||
] {
|
||||
js_sys::Reflect::set(&object, &name.into(), &function)?;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ mod keep;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod linked;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod mouse_buttons;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod space_mouse;
|
||||
|
||||
/// The browser switches tabs with Ctrl+Tab and Ctrl+Shift+Tab, so the page leaves those keys to
|
||||
@@ -341,6 +343,7 @@ mod web {
|
||||
const JAPANESE_FONT_PATH: &str = "./fonts/BIZUDPGothic-Regular.ttf";
|
||||
const CHINESE_SC_FONT_PATH: &str = "./fonts/CraftUI-SC.otf";
|
||||
const CHINESE_TC_FONT_PATH: &str = "./fonts/CraftUI-TC.otf";
|
||||
const KOREAN_FONT_PATH: &str = "./fonts/CraftUI-KR.otf";
|
||||
|
||||
type FontLoadResult = Rc<std::cell::RefCell<Option<Result<(String, Vec<u8>), String>>>>;
|
||||
|
||||
@@ -369,12 +372,16 @@ mod web {
|
||||
chinese_bytes: Option<Vec<u8>>,
|
||||
/// Which Chinese face `chinese_bytes` is: `"zh"` or `"zh-TW"`.
|
||||
chinese_kind: Option<&'static str>,
|
||||
/// Kind requested by the in-flight fetch (`"ja"`, `"zh"`, or `"zh-TW"`).
|
||||
/// The fetched Korean font's bytes: kept so a language change can re-order the fallbacks.
|
||||
korean_bytes: Option<Vec<u8>>,
|
||||
/// Kind requested by the in-flight fetch (`"ja"`, `"zh"`, `"zh-TW"` or `"ko"`).
|
||||
font_fetch_kind: Option<&'static str>,
|
||||
/// The language the fonts were last set up for (a CJK UI leads with the CJK face).
|
||||
font_language: String,
|
||||
space_mouse: SpaceMouse,
|
||||
wheel_gestures: solidcraft_ui_egui::WebWheelGestures,
|
||||
/// The mouse buttons the browser holds, which egui's are kept to (`mouse_buttons`).
|
||||
mouse_buttons: Rc<std::cell::RefCell<crate::mouse_buttons::Tracker>>,
|
||||
/// The settings this tab wrote or loaded last, and those another tab wrote since (#8644).
|
||||
prefs_writer: crate::PrefsWriter,
|
||||
prefs_warning: Option<String>,
|
||||
@@ -1021,6 +1028,7 @@ mod web {
|
||||
Some("zh") => self.chinese_bytes.is_some() && self.chinese_kind == Some("zh"),
|
||||
Some("zh-TW") => self.chinese_bytes.is_some() && self.chinese_kind == Some("zh-TW"),
|
||||
Some("ja") => self.cjk_bytes.is_some(),
|
||||
Some("ko") => self.korean_bytes.is_some(),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
@@ -1042,6 +1050,9 @@ mod web {
|
||||
self.chinese_bytes = Some(bytes);
|
||||
self.chinese_kind = Some("zh-TW");
|
||||
}
|
||||
Some("ko") => {
|
||||
self.korean_bytes = Some(bytes);
|
||||
}
|
||||
_ => {
|
||||
self.cjk_bytes = Some(bytes);
|
||||
}
|
||||
@@ -1053,6 +1064,7 @@ mod web {
|
||||
let path = match kind {
|
||||
Some("zh") => CHINESE_SC_FONT_PATH,
|
||||
Some("zh-TW") => CHINESE_TC_FONT_PATH,
|
||||
Some("ko") => KOREAN_FONT_PATH,
|
||||
_ => JAPANESE_FONT_PATH,
|
||||
};
|
||||
log::warn!("failed to fetch script font from {path}: {err}");
|
||||
@@ -1080,13 +1092,20 @@ mod web {
|
||||
}
|
||||
|
||||
/// Set up the UI fonts for the live language, keeping fetched script fonts as fallbacks
|
||||
/// (Chinese leads a Chinese UI; see [`solidcraft_ui_egui::fonts::install_web_script_fonts`]).
|
||||
/// (Chinese leads a Chinese UI, Korean a Korean one; see
|
||||
/// [`solidcraft_ui_egui::fonts::install_web_script_fonts`]).
|
||||
fn apply_fonts(&mut self, ctx: &egui::Context) {
|
||||
let effective = solidcraft_ui_text::effective_language(&self.app.preferences.language);
|
||||
if self.cjk_bytes.is_none() && self.chinese_bytes.is_none() {
|
||||
if self.cjk_bytes.is_none() && self.chinese_bytes.is_none() && self.korean_bytes.is_none() {
|
||||
solidcraft_ui_egui::fonts::setup_fonts(ctx);
|
||||
} else {
|
||||
solidcraft_ui_egui::fonts::install_web_script_fonts(ctx, self.cjk_bytes.clone(), self.chinese_bytes.clone(), effective);
|
||||
solidcraft_ui_egui::fonts::install_web_script_fonts(
|
||||
ctx,
|
||||
self.cjk_bytes.clone(),
|
||||
self.chinese_bytes.clone(),
|
||||
self.korean_bytes.clone(),
|
||||
effective,
|
||||
);
|
||||
}
|
||||
self.font_language = effective.to_owned();
|
||||
}
|
||||
@@ -1103,6 +1122,7 @@ mod web {
|
||||
let path = match kind {
|
||||
"zh" => CHINESE_SC_FONT_PATH,
|
||||
"zh-TW" => CHINESE_TC_FONT_PATH,
|
||||
"ko" => KOREAN_FONT_PATH,
|
||||
_ => JAPANESE_FONT_PATH,
|
||||
};
|
||||
self.font_fetch_kind = Some(kind);
|
||||
@@ -1130,6 +1150,8 @@ mod web {
|
||||
DESIGN_SHOWN.with(|d| d.set(solidcraft_ui_egui::toolbar::design_shown(&self.app)));
|
||||
}
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
// First, so everything after sees the buttons the browser holds.
|
||||
crate::mouse_buttons::reconcile(&self.mouse_buttons, ctx, raw);
|
||||
let now = raw.time.unwrap_or_else(|| ctx.input(|i| i.time));
|
||||
if let Some(delay) = self.wheel_gestures.update(raw, now) {
|
||||
ctx.request_repaint_after(delay);
|
||||
@@ -1270,6 +1292,7 @@ mod web {
|
||||
{
|
||||
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
|
||||
}
|
||||
let mouse_canvas = canvas.clone();
|
||||
let runner = eframe::WebRunner::new();
|
||||
let result = runner
|
||||
.start(
|
||||
@@ -1354,6 +1377,7 @@ mod web {
|
||||
let rev = app.session.revision;
|
||||
app.home.show(rev);
|
||||
}
|
||||
let mouse_buttons = crate::mouse_buttons::install(&window, &mouse_canvas, &cc.egui_ctx);
|
||||
let api = Api::install(sender, cc.egui_ctx.clone()).map_err(|e| format!("SolidCraft browser API: {e:?}"))?;
|
||||
let font_language = app.preferences.language.clone();
|
||||
let linked = crate::linked::Download::start(launch.open.as_deref(), &cc.egui_ctx);
|
||||
@@ -1375,10 +1399,12 @@ mod web {
|
||||
cjk_bytes: None,
|
||||
chinese_bytes: None,
|
||||
chinese_kind: None,
|
||||
korean_bytes: None,
|
||||
font_fetch_kind: None,
|
||||
font_language,
|
||||
space_mouse: SpaceMouse::default(),
|
||||
wheel_gestures: solidcraft_ui_egui::WebWheelGestures::default(),
|
||||
mouse_buttons,
|
||||
prefs_writer: crate::PrefsWriter::default(),
|
||||
prefs_warning: None,
|
||||
other_tab_prefs,
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
//! Mouse buttons as the browser holds them, not as eframe's per-button events report them.
|
||||
//!
|
||||
//! eframe 0.37's web runner turns each `pointerdown` and `pointerup` into one egui button event
|
||||
//! from `event.button`. Pointer events don't work that way for a mouse: a second button pressed
|
||||
//! while one is held (a chord) fires `pointermove`, not `pointerdown`, and so does releasing one
|
||||
//! of two held buttons; only the last release fires `pointerup`. Press right, then left, let go
|
||||
//! of right, then left: eframe reports "right down", then "left up", and egui keeps the right
|
||||
//! button held for good. A release the page never hears of (outside the window, an alt-tab, a
|
||||
//! cancelled pointer) leaves a button held the same way.
|
||||
//!
|
||||
//! So every mouse or pen pointer event's `buttons` bitmask (what the browser holds right now)
|
||||
//! is noted here, and before each frame [`corrections`] adds the button events egui is missing
|
||||
//! for its state to match it. The canvas also captures the pointer on `pointerdown`, so the
|
||||
//! release still arrives when it happens outside the canvas or the window; `pointercancel`,
|
||||
//! `lostpointercapture`, the window losing focus and the page being hidden release everything.
|
||||
//! (A shim here, not a patch to eframe: its listeners stay as they are, this only adds what
|
||||
//! they miss.)
|
||||
|
||||
use egui::{Event, Modifiers, NUM_POINTER_BUTTONS, PointerButton, Pos2};
|
||||
|
||||
/// egui's buttons by their bit in the browser's `buttons` bitmask: bit 0 primary, 1 secondary,
|
||||
/// 2 auxiliary (middle), 3 back, 4 forward. egui numbers its buttons in the same order.
|
||||
const BUTTONS: [PointerButton; NUM_POINTER_BUTTONS] =
|
||||
[PointerButton::Primary, PointerButton::Secondary, PointerButton::Middle, PointerButton::Extra1, PointerButton::Extra2];
|
||||
|
||||
/// What the browser last said about the mouse buttons.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Observed {
|
||||
/// The `buttons` bitmask: the buttons held now.
|
||||
pub buttons: u16,
|
||||
/// The buttons went down on the canvas, so a press egui missed may be added. A drag that
|
||||
/// started elsewhere on the page and came over the canvas must not become a press in the app.
|
||||
pub on_canvas: bool,
|
||||
}
|
||||
|
||||
/// What happened to the pointer, as the page's listeners see it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Happened {
|
||||
/// `pointerdown`, `pointermove` or `pointerup` from a mouse or pen, with its `buttons`;
|
||||
/// `down_on_canvas` when it is a `pointerdown` aimed at the canvas.
|
||||
Pointer { buttons: u16, down_on_canvas: bool },
|
||||
/// `pointercancel`, `lostpointercapture`, the window losing focus or the page being hidden:
|
||||
/// whatever was held won't be heard released.
|
||||
Lost,
|
||||
}
|
||||
|
||||
/// The listeners' state: the latest observation, waiting for the next frame.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Tracker {
|
||||
/// The buttons held now went down on the canvas.
|
||||
on_canvas: bool,
|
||||
pending: Option<Observed>,
|
||||
}
|
||||
|
||||
impl Tracker {
|
||||
/// Note what happened. Returns true when the held buttons changed, so a frame should run.
|
||||
pub fn note(&mut self, happened: Happened) -> bool {
|
||||
let before = self.pending.map(|o| o.buttons);
|
||||
let buttons = match happened {
|
||||
Happened::Pointer { buttons, down_on_canvas } => {
|
||||
self.on_canvas |= down_on_canvas;
|
||||
buttons
|
||||
}
|
||||
Happened::Lost => 0,
|
||||
};
|
||||
let observed = Observed { buttons, on_canvas: self.on_canvas };
|
||||
if buttons == 0 {
|
||||
self.on_canvas = false;
|
||||
}
|
||||
self.pending = Some(observed);
|
||||
before != Some(buttons)
|
||||
}
|
||||
|
||||
/// The observation for this frame, once.
|
||||
pub fn take(&mut self) -> Option<Observed> {
|
||||
self.pending.take()
|
||||
}
|
||||
}
|
||||
|
||||
/// The button events to add after `events` (this frame's input so far) so that egui, whose
|
||||
/// buttons were `down` at the end of the last frame, holds what the browser holds. Releases are
|
||||
/// always added; presses only for buttons that went down on the canvas.
|
||||
pub fn corrections(down: [bool; NUM_POINTER_BUTTONS], events: &[Event], observed: Observed, pos: Pos2, modifiers: Modifiers) -> Vec<Event> {
|
||||
let mut down = down;
|
||||
let mut pos = pos;
|
||||
for event in events {
|
||||
match event {
|
||||
Event::PointerButton { button, pressed, pos: at, .. } => {
|
||||
down[*button as usize] = *pressed;
|
||||
pos = *at;
|
||||
}
|
||||
Event::PointerMoved(at) => pos = *at,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for (bit, button) in BUTTONS.into_iter().enumerate() {
|
||||
let held = observed.buttons & (1 << bit) != 0;
|
||||
let pressed = match (down[bit], held) {
|
||||
(true, false) => false,
|
||||
(false, true) if observed.on_canvas => true,
|
||||
_ => continue,
|
||||
};
|
||||
out.push(Event::PointerButton { pos, button, pressed, modifiers });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
/// egui's buttons held at the end of the last frame.
|
||||
pub fn egui_down(ctx: &egui::Context) -> [bool; NUM_POINTER_BUTTONS] {
|
||||
ctx.input(|i| BUTTONS.map(|b| i.pointer.button_down(b)))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
/// Add the button events this frame needs (see [`corrections`]).
|
||||
pub fn reconcile(tracker: &std::cell::RefCell<Tracker>, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
let Some(observed) = tracker.borrow_mut().take() else { return };
|
||||
let pos = ctx.input(|i| i.pointer.interact_pos().or(i.pointer.latest_pos())).unwrap_or_default();
|
||||
let modifiers = ctx.input(|i| i.modifiers);
|
||||
let fixes = corrections(egui_down(ctx), &raw.events, observed, pos, modifiers);
|
||||
if !fixes.is_empty() {
|
||||
log::debug!("mouse buttons: {fixes:?} to match the browser's {:#07b}", observed.buttons);
|
||||
}
|
||||
raw.events.extend(fixes);
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub use web::install;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web {
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::closure::Closure;
|
||||
|
||||
use super::{Happened, Tracker};
|
||||
|
||||
/// Listen on the window in the capture phase, so each observation is noted before eframe's
|
||||
/// own listeners (on the canvas and the document) run a frame from the same event.
|
||||
pub fn install(window: &web_sys::Window, canvas: &web_sys::HtmlCanvasElement, ctx: &egui::Context) -> Rc<RefCell<Tracker>> {
|
||||
let tracker = Rc::new(RefCell::new(Tracker::default()));
|
||||
let listen = |name: &str, on: Box<dyn FnMut(web_sys::Event)>| {
|
||||
let on = Closure::<dyn FnMut(web_sys::Event)>::new(on);
|
||||
let _ = window.add_event_listener_with_callback_and_bool(name, on.as_ref().unchecked_ref(), true);
|
||||
// The listeners live as long as the page.
|
||||
on.forget();
|
||||
};
|
||||
for name in ["pointerdown", "pointermove", "pointerup"] {
|
||||
let (tracker, canvas, ctx) = (tracker.clone(), canvas.clone(), ctx.clone());
|
||||
listen(
|
||||
name,
|
||||
Box::new(move |e: web_sys::Event| {
|
||||
let Some(e) = e.dyn_ref::<web_sys::PointerEvent>() else { return };
|
||||
// Touches reach egui through touch events; their buttons aren't tracked here.
|
||||
if e.pointer_type() == "touch" {
|
||||
return;
|
||||
}
|
||||
let mut down_on_canvas = false;
|
||||
if name == "pointerdown" && e.target().is_some_and(|t| canvas.is_same_node(t.dyn_ref::<web_sys::Node>())) {
|
||||
down_on_canvas = true;
|
||||
// The release then comes here wherever it happens, even outside the window.
|
||||
let _ = canvas.set_pointer_capture(e.pointer_id());
|
||||
}
|
||||
if tracker.borrow_mut().note(Happened::Pointer { buttons: e.buttons(), down_on_canvas }) {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
let lost = {
|
||||
let (tracker, ctx) = (tracker.clone(), ctx.clone());
|
||||
move || {
|
||||
if tracker.borrow_mut().note(Happened::Lost) {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
};
|
||||
for name in ["pointercancel", "lostpointercapture", "blur"] {
|
||||
let lost = lost.clone();
|
||||
listen(name, Box::new(move |_| lost()));
|
||||
}
|
||||
let document = window.document();
|
||||
listen(
|
||||
"visibilitychange",
|
||||
Box::new(move |_| {
|
||||
if document.as_ref().is_some_and(|d| d.visibility_state() == web_sys::VisibilityState::Hidden) {
|
||||
lost();
|
||||
}
|
||||
}),
|
||||
);
|
||||
tracker
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const NONE: [bool; NUM_POINTER_BUTTONS] = [false; NUM_POINTER_BUTTONS];
|
||||
const P: Pos2 = Pos2::new(10.0, 20.0);
|
||||
|
||||
fn press(button: PointerButton, pressed: bool) -> Event {
|
||||
Event::PointerButton { pos: P, button, pressed, modifiers: Modifiers::NONE }
|
||||
}
|
||||
|
||||
fn held(buttons: &[PointerButton]) -> [bool; NUM_POINTER_BUTTONS] {
|
||||
let mut down = NONE;
|
||||
for b in buttons {
|
||||
down[*b as usize] = true;
|
||||
}
|
||||
down
|
||||
}
|
||||
|
||||
/// Run one browser event through the tracker, then a frame through `corrections`, the way
|
||||
/// the page does; returns egui's buttons after the frame and the events added.
|
||||
fn frame(
|
||||
tracker: &mut Tracker,
|
||||
down: [bool; NUM_POINTER_BUTTONS],
|
||||
eframe: &[Event],
|
||||
happened: Happened,
|
||||
) -> ([bool; NUM_POINTER_BUTTONS], Vec<Event>) {
|
||||
tracker.note(happened);
|
||||
let mut events = eframe.to_vec();
|
||||
if let Some(observed) = tracker.take() {
|
||||
let fixes = corrections(down, &events, observed, P, Modifiers::NONE);
|
||||
events.extend(fixes.clone());
|
||||
let mut after = down;
|
||||
for e in &events {
|
||||
if let Event::PointerButton { button, pressed, .. } = e {
|
||||
after[*button as usize] = *pressed;
|
||||
}
|
||||
}
|
||||
return (after, fixes);
|
||||
}
|
||||
(down, Vec::new())
|
||||
}
|
||||
|
||||
fn pointer(buttons: u16, down_on_canvas: bool) -> Happened {
|
||||
Happened::Pointer { buttons, down_on_canvas }
|
||||
}
|
||||
|
||||
/// Right, then left, held together; right let go, then left. The browser fires pointerdown
|
||||
/// (right), pointermove (buttons 3), pointermove (buttons 1), pointerup (left), so eframe
|
||||
/// says only "right down" and "left up": egui kept right held for good.
|
||||
#[test]
|
||||
fn a_chord_ends_with_no_button_held() {
|
||||
let mut t = Tracker::default();
|
||||
let (down, fixes) = frame(&mut t, NONE, &[press(PointerButton::Secondary, true)], pointer(0b10, true));
|
||||
assert!(fixes.is_empty(), "eframe's own press matches the browser: {fixes:?}");
|
||||
let (down, fixes) = frame(&mut t, down, &[], pointer(0b11, false));
|
||||
assert_eq!(fixes, vec![press(PointerButton::Primary, true)], "the chord's second button is pressed");
|
||||
assert_eq!(down, held(&[PointerButton::Primary, PointerButton::Secondary]));
|
||||
let (down, fixes) = frame(&mut t, down, &[], pointer(0b01, false));
|
||||
assert_eq!(fixes, vec![press(PointerButton::Secondary, false)], "right is let go while left stays");
|
||||
let (down, fixes) = frame(&mut t, down, &[press(PointerButton::Primary, false)], pointer(0, false));
|
||||
assert!(fixes.is_empty(), "eframe's own release matches: {fixes:?}");
|
||||
assert_eq!(down, NONE, "nothing stays held");
|
||||
}
|
||||
|
||||
/// Both pressed at once and both let go at once: one pointerup with buttons 0 that names
|
||||
/// only one button. The other is released too.
|
||||
#[test]
|
||||
fn one_pointerup_for_two_buttons_releases_both() {
|
||||
let mut t = Tracker::default();
|
||||
let (down, _) = frame(&mut t, NONE, &[press(PointerButton::Primary, true)], pointer(0b01, true));
|
||||
let (down, _) = frame(&mut t, down, &[], pointer(0b11, false));
|
||||
assert_eq!(down, held(&[PointerButton::Primary, PointerButton::Secondary]));
|
||||
let (down, fixes) = frame(&mut t, down, &[press(PointerButton::Primary, false)], pointer(0, false));
|
||||
assert_eq!(fixes, vec![press(PointerButton::Secondary, false)]);
|
||||
assert_eq!(down, NONE);
|
||||
}
|
||||
|
||||
/// A drag let go outside the window: no pointerup was heard, and the next pointer event
|
||||
/// (back over the page) has no buttons held.
|
||||
#[test]
|
||||
fn a_release_outside_is_caught_by_the_next_event() {
|
||||
let mut t = Tracker::default();
|
||||
let (down, _) = frame(&mut t, NONE, &[press(PointerButton::Secondary, true)], pointer(0b10, true));
|
||||
let (down, fixes) = frame(&mut t, down, &[Event::PointerMoved(Pos2::new(5.0, 6.0))], pointer(0, false));
|
||||
assert_eq!(
|
||||
fixes,
|
||||
vec![Event::PointerButton { pos: Pos2::new(5.0, 6.0), button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }]
|
||||
);
|
||||
assert_eq!(down, NONE);
|
||||
}
|
||||
|
||||
/// The window loses focus (alt-tab) or the pointer is cancelled with buttons held.
|
||||
#[test]
|
||||
fn blur_and_cancel_release_every_button() {
|
||||
let mut t = Tracker::default();
|
||||
let (down, _) = frame(&mut t, NONE, &[press(PointerButton::Middle, true)], pointer(0b100, true));
|
||||
let (down, _) = frame(&mut t, down, &[], pointer(0b110, false));
|
||||
let (down, fixes) = frame(&mut t, down, &[], Happened::Lost);
|
||||
assert_eq!(fixes, vec![press(PointerButton::Secondary, false), press(PointerButton::Middle, false)]);
|
||||
assert_eq!(down, NONE);
|
||||
// Held from before this tracker heard anything (a stale state) is released too.
|
||||
let (down, _) = frame(&mut t, held(&[PointerButton::Extra1]), &[], Happened::Lost);
|
||||
assert_eq!(down, NONE);
|
||||
}
|
||||
|
||||
/// A drag that started elsewhere on the page and came over the canvas isn't a press in the app.
|
||||
#[test]
|
||||
fn a_drag_from_outside_the_canvas_presses_nothing() {
|
||||
let mut t = Tracker::default();
|
||||
let (down, fixes) = frame(&mut t, NONE, &[], pointer(0b01, false));
|
||||
assert!(fixes.is_empty() && down == NONE, "{fixes:?}");
|
||||
let (down, fixes) = frame(&mut t, down, &[], pointer(0, false));
|
||||
assert!(fixes.is_empty() && down == NONE);
|
||||
// The next press on the canvas counts again.
|
||||
let (down, _) = frame(&mut t, down, &[press(PointerButton::Primary, true)], pointer(0b01, true));
|
||||
let (down, fixes) = frame(&mut t, down, &[], pointer(0b11, false));
|
||||
assert_eq!(fixes, vec![press(PointerButton::Secondary, true)]);
|
||||
assert_eq!(down, held(&[PointerButton::Primary, PointerButton::Secondary]));
|
||||
}
|
||||
|
||||
/// A quick click inside one frame (down and up both from eframe) needs nothing, and no frame
|
||||
/// runs corrections without a new observation (touch input is left alone).
|
||||
#[test]
|
||||
fn matching_input_is_left_alone() {
|
||||
let mut t = Tracker::default();
|
||||
let (down, fixes) = frame(&mut t, NONE, &[press(PointerButton::Primary, true), press(PointerButton::Primary, false)], pointer(0, true));
|
||||
assert!(fixes.is_empty() && down == NONE, "{fixes:?}");
|
||||
assert_eq!(t.take(), None, "an observation is used once");
|
||||
let touch_held = held(&[PointerButton::Primary]);
|
||||
assert!(corrections(touch_held, &[], Observed { buttons: 1, on_canvas: false }, P, Modifiers::NONE).is_empty());
|
||||
}
|
||||
|
||||
/// The tracker asks for a frame only when the held buttons change.
|
||||
#[test]
|
||||
fn repaint_only_on_change() {
|
||||
let mut t = Tracker::default();
|
||||
assert!(t.note(pointer(0, false)), "the first observation");
|
||||
assert!(!t.note(pointer(0, false)), "hovering changes nothing");
|
||||
assert!(t.note(pointer(0b10, true)));
|
||||
assert!(!t.note(pointer(0b10, false)));
|
||||
assert!(t.note(Happened::Lost));
|
||||
}
|
||||
}
|
||||
@@ -1595,9 +1595,11 @@ fn window_system_override(preference: Option<&str>) -> Option<&'static str> {
|
||||
|
||||
/// Whether to run through XWayland rather than native Wayland. winit has no file drag-and-drop
|
||||
/// on Wayland (only on X11), so dropping files from the file manager showed a "no" cursor: use
|
||||
/// XWayland when it's there. Except on niri, whose XWayland (xwayland-satellite) breaks window
|
||||
/// management (maximize/restore), so it stays on native Wayland and gives up file drops.
|
||||
/// `SOLIDCRAFT_WAYLAND=1` keeps native Wayland, `SOLIDCRAFT_WAYLAND=0` forces XWayland.
|
||||
/// XWayland when it's there. Except on compositors where XWayland is a worse default: niri
|
||||
/// (xwayland-satellite breaks maximize/restore) and Hyprland (fractional scale with
|
||||
/// `force_zero_scaling` plus `GDK_SCALE` makes the UI ~2.5× oversized, #10480). Those stay on
|
||||
/// native Wayland and give up file drops. `SOLIDCRAFT_WAYLAND=1` keeps native Wayland,
|
||||
/// `SOLIDCRAFT_WAYLAND=0` forces XWayland.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn prefer_x11(display: bool, wayland_display: bool, desktop: Option<&str>, wayland_override: Option<&str>) -> bool {
|
||||
match wayland_override.map(str::trim) {
|
||||
@@ -1605,8 +1607,18 @@ fn prefer_x11(display: bool, wayland_display: bool, desktop: Option<&str>, wayla
|
||||
Some(_) => return false,
|
||||
None => {}
|
||||
}
|
||||
let niri = desktop.is_some_and(|d| d.split(':').any(|name| name.trim().eq_ignore_ascii_case("niri")));
|
||||
display && !(niri && wayland_display)
|
||||
display && !(native_wayland_desktop(desktop) && wayland_display)
|
||||
}
|
||||
|
||||
/// Desktops that keep Automatic on native Wayland when a Wayland socket is present.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
fn native_wayland_desktop(desktop: Option<&str>) -> bool {
|
||||
desktop.is_some_and(|d| {
|
||||
d.split(':').any(|name| {
|
||||
let name = name.trim();
|
||||
name.eq_ignore_ascii_case("niri") || name.eq_ignore_ascii_case("Hyprland")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
@@ -1655,20 +1667,24 @@ mod backend_tests {
|
||||
use super::prefer_x11;
|
||||
|
||||
#[test]
|
||||
fn xwayland_by_default_native_wayland_on_niri_and_the_override_wins() {
|
||||
fn xwayland_by_default_native_wayland_on_niri_hyprland_and_the_override_wins() {
|
||||
// XWayland whenever there's an X display, for file drag-and-drop.
|
||||
assert!(prefer_x11(true, true, Some("GNOME"), None));
|
||||
assert!(prefer_x11(true, false, None, None));
|
||||
assert!(!prefer_x11(false, true, Some("KDE"), None));
|
||||
// niri (alone or in a desktop list) under Wayland stays native.
|
||||
// niri / Hyprland (alone or in a desktop list) under Wayland stays native.
|
||||
assert!(!prefer_x11(true, true, Some("niri"), None));
|
||||
assert!(!prefer_x11(true, true, Some("niri:GNOME"), None));
|
||||
// niri without a Wayland socket (nested X session) still uses X11.
|
||||
assert!(!prefer_x11(true, true, Some("Hyprland"), None));
|
||||
assert!(!prefer_x11(true, true, Some("hyprland"), None));
|
||||
// Without a Wayland socket (nested X session) still uses X11.
|
||||
assert!(prefer_x11(true, false, Some("niri"), None));
|
||||
assert!(prefer_x11(true, false, Some("Hyprland"), None));
|
||||
// SOLIDCRAFT_WAYLAND: anything but 0 keeps Wayland, 0 forces XWayland (when present).
|
||||
assert!(!prefer_x11(true, true, Some("GNOME"), Some("1")));
|
||||
assert!(!prefer_x11(true, true, Some("GNOME"), Some("")));
|
||||
assert!(prefer_x11(true, true, Some("niri"), Some("0")));
|
||||
assert!(prefer_x11(true, true, Some("Hyprland"), Some("0")));
|
||||
assert!(!prefer_x11(false, true, Some("niri"), Some("0")));
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,96 @@
|
||||
Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'.
|
||||
Copyright 2014-2021 Google Inc. with Reserved Font Name 'Noto'.
|
||||
Subset for SolidCraft web UI (Hangul, kana and Latin glyphs); renamed Craft UI KR (no Reserved Font Name).
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License,
|
||||
Version 1.1.
|
||||
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font
|
||||
creation efforts of academic and linguistic communities, and to
|
||||
provide a free and open framework in which fonts may be shared and
|
||||
improved in partnership with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply to
|
||||
any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software
|
||||
components as distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to,
|
||||
deleting, or substituting -- in part or in whole -- any of the
|
||||
components of the Original Version, by changing formats or by porting
|
||||
the Font Software to a new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed,
|
||||
modify, redistribute, and sell modified and unmodified copies of the
|
||||
Font Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components, in
|
||||
Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the
|
||||
corresponding Copyright Holder. This restriction only applies to the
|
||||
primary font name as presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created using
|
||||
the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -2653,7 +2653,7 @@
|
||||
"prefs.design_tools.imported_files": "الملفات المستوردة",
|
||||
"prefs.design_tools.note": "ملاحظة",
|
||||
"prefs.design_tools.off": "إيقاف",
|
||||
"prefs.design_tools.placement_group": "مسند، نتوء، تعشيق مرن",
|
||||
"prefs.design_tools.placement_group": "الميزات الموضوعة",
|
||||
"prefs.design_tools.rows": "صفوف",
|
||||
"prefs.devices": "الفأرة ثلاثية الأبعاد",
|
||||
"prefs.dialog_buttons": "أزرار مربعات الحوار",
|
||||
@@ -6070,7 +6070,7 @@
|
||||
"ui.pitch": "الخطوة",
|
||||
"ui.placement": "الموضع",
|
||||
"ui.placement_handles": "مقابض الموضع",
|
||||
"ui.placement_handles_tip": "علامة لسحب الميزة عبر النموذج وحلقة لتدويرها.",
|
||||
"ui.placement_handles_tip": "علامة لسحب الميزة عبر النموذج، وحلقة لتدويرها حيث يمكن تدوير الميزة.",
|
||||
"ui.plane": "المستوى",
|
||||
"ui.plane_at_angle": "مستوى بزاوية",
|
||||
"ui.plugins": "الإضافات",
|
||||
|
||||
@@ -3384,7 +3384,7 @@
|
||||
"ui.pitch": "Pitch",
|
||||
"ui.placement": "Placement",
|
||||
"ui.placement_handles": "Placement handles",
|
||||
"ui.placement_handles_tip": "Handles on the preview of a Rest, Boss or Snap Fit: drag the marker to move it across the model, turn it by its ring",
|
||||
"ui.placement_handles_tip": "Маркер за влачене на елемента по модела и, където елементът може да се завърта, пръстен за завъртането му.",
|
||||
"ui.plane": "Plane",
|
||||
"ui.plane_at_angle": "Plane at Angle",
|
||||
"ui.plugins": "Plugins",
|
||||
|
||||
@@ -4907,7 +4907,7 @@
|
||||
"ui.pitch": "Korak",
|
||||
"ui.placement": "Postavljanje",
|
||||
"ui.placement_handles": "Ručice za postavljanje",
|
||||
"ui.placement_handles_tip": "Ručice na pregledu oslonca, ispusta ili škljocajućeg spoja: povucite oznaku da je pomjerite po modelu, okrenite je njenim prstenom",
|
||||
"ui.placement_handles_tip": "Oznaka za povlačenje operacije po modelu i, gdje se operacija može okretati, prsten za njeno okretanje.",
|
||||
"ui.plane": "Ravan",
|
||||
"ui.plane_at_angle": "Ravan pod uglom",
|
||||
"ui.plugins": "Proširenja",
|
||||
|
||||
@@ -2653,7 +2653,7 @@
|
||||
"prefs.design_tools.imported_files": "Fitxers importats",
|
||||
"prefs.design_tools.note": "Nota",
|
||||
"prefs.design_tools.off": "Desactivat",
|
||||
"prefs.design_tools.placement_group": "Repòs, Resalt, Encaix",
|
||||
"prefs.design_tools.placement_group": "Operacions col·locades",
|
||||
"prefs.design_tools.rows": "Files",
|
||||
"prefs.devices": "Ratolí 3D",
|
||||
"prefs.dialog_buttons": "Botons de diàleg",
|
||||
@@ -6070,7 +6070,7 @@
|
||||
"ui.pitch": "Inclinació",
|
||||
"ui.placement": "Col·locació",
|
||||
"ui.placement_handles": "Tiradors de col·locació",
|
||||
"ui.placement_handles_tip": "Un marcador per arrossegar l'operació pel model i un anell per girar-la.",
|
||||
"ui.placement_handles_tip": "Un marcador per arrossegar l'operació pel model i, on l'operació es pot girar, un anell per girar-la.",
|
||||
"ui.plane": "Pla",
|
||||
"ui.plane_at_angle": "Pla amb angle",
|
||||
"ui.plugins": "Complements",
|
||||
|
||||
@@ -2562,7 +2562,7 @@
|
||||
"prefs.design_tools.imported_files": "Importované soubory",
|
||||
"prefs.design_tools.note": "Poznámka",
|
||||
"prefs.design_tools.off": "Vypnuto",
|
||||
"prefs.design_tools.placement_group": "Opěra, Nálitek, Západka",
|
||||
"prefs.design_tools.placement_group": "Umisťované prvky",
|
||||
"prefs.design_tools.rows": "Řádky",
|
||||
"prefs.devices": "3D myš",
|
||||
"prefs.dialog_buttons": "Tlačítka dialogu",
|
||||
@@ -5824,7 +5824,7 @@
|
||||
"ui.pitch": "Stoupání",
|
||||
"ui.placement": "Umístění",
|
||||
"ui.placement_handles": "Úchyty umístění",
|
||||
"ui.placement_handles_tip": "Úchyty na náhledu opěry, nálitku nebo západky: tažením značky ji přesunete po modelu, prstencem ji otočíte",
|
||||
"ui.placement_handles_tip": "Značka pro přetažení prvku po modelu a tam, kde se prvek může otáčet, kroužek pro jeho otočení.",
|
||||
"ui.plane": "Rovina",
|
||||
"ui.plane_at_angle": "Rovina pod úhlem",
|
||||
"ui.plugins": "Moduly plug-in",
|
||||
|
||||
@@ -4916,7 +4916,7 @@
|
||||
"ui.pitch": "Stigning",
|
||||
"ui.placement": "Placering",
|
||||
"ui.placement_handles": "Placeringshåndtag",
|
||||
"ui.placement_handles_tip": "Håndtag på forhåndsvisningen af en Understøtning, Skruebøsning eller Snapforbindelse: Træk i markøren for at flytte den hen over modellen, og drej den ved dens ring",
|
||||
"ui.placement_handles_tip": "En markør til at trække featuren hen over modellen og, hvor featuren kan drejes, en ring til at dreje den.",
|
||||
"ui.plane": "Plan",
|
||||
"ui.plane_at_angle": "Plan i vinkel",
|
||||
"ui.plugins": "Plugins",
|
||||
|
||||
@@ -2424,7 +2424,7 @@
|
||||
"prefs.design_tools.imported_files": "Importierte Dateien",
|
||||
"prefs.design_tools.note": "Hinweis",
|
||||
"prefs.design_tools.off": "Aus",
|
||||
"prefs.design_tools.placement_group": "Auflage, Zapfen, Schnappverbindung",
|
||||
"prefs.design_tools.placement_group": "Platzierte Funktionen",
|
||||
"prefs.design_tools.rows": "Zeilen",
|
||||
"prefs.devices": "3D-Maus",
|
||||
"prefs.dialog_buttons": "Dialogschaltflächen",
|
||||
@@ -5758,7 +5758,7 @@
|
||||
"ui.pitch": "Steigung",
|
||||
"ui.placement": "Platzierung",
|
||||
"ui.placement_handles": "Platzierungsgriffe",
|
||||
"ui.placement_handles_tip": "Eine Markierung zum Verschieben des Features über das Modell und ein Ring zum Drehen.",
|
||||
"ui.placement_handles_tip": "Eine Markierung zum Verschieben des Features über das Modell und, wo sich das Feature drehen lässt, ein Ring zum Drehen.",
|
||||
"ui.plane": "Ebene",
|
||||
"ui.plane_at_angle": "Ebene im Winkel",
|
||||
"ui.plugins": "Plugins",
|
||||
|
||||
@@ -2653,7 +2653,7 @@
|
||||
"prefs.design_tools.imported_files": "Imported files",
|
||||
"prefs.design_tools.note": "Note",
|
||||
"prefs.design_tools.off": "Off",
|
||||
"prefs.design_tools.placement_group": "Rest, Boss, Snap Fit",
|
||||
"prefs.design_tools.placement_group": "Placed features",
|
||||
"prefs.design_tools.rows": "Rows",
|
||||
"prefs.devices": "3D mouse",
|
||||
"prefs.dialog_buttons": "Dialog buttons",
|
||||
@@ -6070,7 +6070,7 @@
|
||||
"ui.pitch": "Pitch",
|
||||
"ui.placement": "Placement",
|
||||
"ui.placement_handles": "Placement handles",
|
||||
"ui.placement_handles_tip": "A marker to drag the feature across the model and a ring to turn it.",
|
||||
"ui.placement_handles_tip": "A marker to drag the feature across the model and, where the feature can turn, a ring to turn it.",
|
||||
"ui.plane": "Plane",
|
||||
"ui.plane_at_angle": "Plane at Angle",
|
||||
"ui.plugins": "Plugins",
|
||||
|
||||
@@ -2653,7 +2653,7 @@
|
||||
"prefs.design_tools.imported_files": "Archivos importados",
|
||||
"prefs.design_tools.note": "Nota",
|
||||
"prefs.design_tools.off": "Desactivado",
|
||||
"prefs.design_tools.placement_group": "Apoyo, Saliente, Encaje",
|
||||
"prefs.design_tools.placement_group": "Operaciones colocadas",
|
||||
"prefs.design_tools.rows": "Filas",
|
||||
"prefs.devices": "Ratón 3D",
|
||||
"prefs.dialog_buttons": "Botones de diálogo",
|
||||
@@ -6070,7 +6070,7 @@
|
||||
"ui.pitch": "Inclinación",
|
||||
"ui.placement": "Colocación",
|
||||
"ui.placement_handles": "Tiradores de colocación",
|
||||
"ui.placement_handles_tip": "Tiradores en la vista previa de un Refuerzo, Saliente o Encaje a presión: arrastra el marcador para moverlo por el modelo, gíralo por su anillo.",
|
||||
"ui.placement_handles_tip": "Un marcador para arrastrar la operación por el modelo y, donde la operación puede girar, un anillo para girarla.",
|
||||
"ui.plane": "Plano",
|
||||
"ui.plane_at_angle": "Plano en ángulo",
|
||||
"ui.plugins": "Complementos",
|
||||
|
||||
@@ -2581,7 +2581,7 @@
|
||||
"prefs.design_tools.imported_files": "Tuodut tiedostot",
|
||||
"prefs.design_tools.note": "Huomautus",
|
||||
"prefs.design_tools.off": "Pois",
|
||||
"prefs.design_tools.placement_group": "Tuki, Tappi, Napsautusliitos",
|
||||
"prefs.design_tools.placement_group": "Sijoitettavat piirteet",
|
||||
"prefs.design_tools.rows": "Rivit",
|
||||
"prefs.devices": "3D-hiiri",
|
||||
"prefs.dialog_buttons": "Valintaikkunan painikkeet",
|
||||
@@ -5850,7 +5850,7 @@
|
||||
"ui.pitch": "Nousu",
|
||||
"ui.placement": "Sijoitus",
|
||||
"ui.placement_handles": "Sijoituskahvat",
|
||||
"ui.placement_handles_tip": "Kahvat tuen, tapin tai napsautusliitoksen esikatselussa: siirrä merkkiä mallin pinnalla vetämällä ja käännä sitä renkaasta",
|
||||
"ui.placement_handles_tip": "Merkki piirteen vetämiseen mallin yli ja, jos piirrettä voi kääntää, rengas sen kääntämiseen.",
|
||||
"ui.plane": "Taso",
|
||||
"ui.plane_at_angle": "Taso kulmassa",
|
||||
"ui.plugins": "Laajennukset",
|
||||
|
||||
@@ -2343,7 +2343,7 @@
|
||||
"prefs.design_tools.imported_files": "Fichiers importés",
|
||||
"prefs.design_tools.note": "Remarque",
|
||||
"prefs.design_tools.off": "Désactivé",
|
||||
"prefs.design_tools.placement_group": "Appui, Bossage, Clip",
|
||||
"prefs.design_tools.placement_group": "Fonctions placées",
|
||||
"prefs.design_tools.rows": "Lignes",
|
||||
"prefs.devices": "Souris 3D",
|
||||
"prefs.dialog_buttons": "Boutons OK",
|
||||
@@ -5287,7 +5287,7 @@
|
||||
"ui.pitch": "Pas",
|
||||
"ui.placement": "Placement",
|
||||
"ui.placement_handles": "Poignées de placement",
|
||||
"ui.placement_handles_tip": "Poignées sur l'aperçu d'un appui, d'un bossage ou d'un clip : faire glisser le repère pour le déplacer sur le modèle, le tourner par son anneau",
|
||||
"ui.placement_handles_tip": "Un repère pour faire glisser la fonction sur le modèle et, là où la fonction peut pivoter, un anneau pour la faire pivoter.",
|
||||
"ui.plane": "Plan",
|
||||
"ui.plane_at_angle": "Plan incliné",
|
||||
"ui.plugins": "Modules externes",
|
||||
|
||||
@@ -4915,7 +4915,7 @@
|
||||
"ui.pitch": "Paso",
|
||||
"ui.placement": "Colocación",
|
||||
"ui.placement_handles": "Asas de colocación",
|
||||
"ui.placement_handles_tip": "Asas na vista previa dun Apoio, un Saínte ou un Encaixe a presión: arrastra o marcador para movelo polo modelo, xírao polo seu anel",
|
||||
"ui.placement_handles_tip": "Un marcador para arrastrar a operación polo modelo e, onde a operación pode xirar, un anel para xirala.",
|
||||
"ui.plane": "Plano",
|
||||
"ui.plane_at_angle": "Plano en ángulo",
|
||||
"ui.plugins": "Complementos",
|
||||
|
||||
@@ -2653,7 +2653,7 @@
|
||||
"prefs.design_tools.imported_files": "קבצים מיובאים",
|
||||
"prefs.design_tools.note": "הערה",
|
||||
"prefs.design_tools.off": "כבוי",
|
||||
"prefs.design_tools.placement_group": "משען, בליטה, חיבור נצמד",
|
||||
"prefs.design_tools.placement_group": "פיצ'רים ממוקמים",
|
||||
"prefs.design_tools.rows": "שורות",
|
||||
"prefs.devices": "עכבר תלת־ממדי",
|
||||
"prefs.dialog_buttons": "לחצני תיבות דו־שיח",
|
||||
@@ -6070,7 +6070,7 @@
|
||||
"ui.pitch": "פסיעה",
|
||||
"ui.placement": "מיקום",
|
||||
"ui.placement_handles": "ידיות מיקום",
|
||||
"ui.placement_handles_tip": "סמן לגרירת התכונה על המודל וטבעת לסיבובה.",
|
||||
"ui.placement_handles_tip": "סמן לגרירת התכונה על המודל, וטבעת לסיבובה במקום שבו אפשר לסובב את התכונה.",
|
||||
"ui.plane": "מישור",
|
||||
"ui.plane_at_angle": "מישור בזווית",
|
||||
"ui.plugins": "תוספים",
|
||||
|
||||
@@ -4907,7 +4907,7 @@
|
||||
"ui.pitch": "Korak",
|
||||
"ui.placement": "Postavljanje",
|
||||
"ui.placement_handles": "Ručice za postavljanje",
|
||||
"ui.placement_handles_tip": "Ručice na pregledu oslonca, ispusta ili škljocajućeg spoja: povucite oznaku kako biste je pomaknuli po modelu, okrenite je njezinim prstenom",
|
||||
"ui.placement_handles_tip": "Oznaka za povlačenje značajke po modelu i, gdje se značajka može okretati, prsten za njezino okretanje.",
|
||||
"ui.plane": "Ravnina",
|
||||
"ui.plane_at_angle": "Ravnina pod kutom",
|
||||
"ui.plugins": "Proširenja",
|
||||
|
||||
@@ -4915,7 +4915,7 @@
|
||||
"ui.pitch": "Emelkedés",
|
||||
"ui.placement": "Elhelyezés",
|
||||
"ui.placement_handles": "Elhelyezési fogantyúk",
|
||||
"ui.placement_handles_tip": "Fogantyúk egy Támasz, Dudor vagy Pattintós illesztés előnézetén: húzza a jelölőt a modell fölött, a gyűrűjénél fogva forgassa el",
|
||||
"ui.placement_handles_tip": "Egy jelölő az elem modellen való húzásához és, ahol az elem elforgatható, egy gyűrű az elforgatásához.",
|
||||
"ui.plane": "Sík",
|
||||
"ui.plane_at_angle": "Szögben álló sík",
|
||||
"ui.plugins": "Bővítmények",
|
||||
|
||||
@@ -4915,7 +4915,7 @@
|
||||
"ui.pitch": "Pitch",
|
||||
"ui.placement": "Penempatan",
|
||||
"ui.placement_handles": "Handle penempatan",
|
||||
"ui.placement_handles_tip": "Handle pada pratinjau Rest, Boss, atau Snap Fit: seret penanda untuk memindahkannya di model, putar lewat cincinnya",
|
||||
"ui.placement_handles_tip": "Penanda untuk menyeret fitur di atas model dan, jika fitur dapat diputar, cincin untuk memutarnya.",
|
||||
"ui.plane": "Bidang",
|
||||
"ui.plane_at_angle": "Bidang Bersudut",
|
||||
"ui.plugins": "Plugin",
|
||||
|
||||
@@ -2588,7 +2588,7 @@
|
||||
"prefs.design_tools.imported_files": "File importati",
|
||||
"prefs.design_tools.note": "Avvisa",
|
||||
"prefs.design_tools.off": "Disattivato",
|
||||
"prefs.design_tools.placement_group": "Appoggio, Risalto, Aggancio a scatto",
|
||||
"prefs.design_tools.placement_group": "Lavorazioni posizionate",
|
||||
"prefs.design_tools.rows": "Righe",
|
||||
"prefs.devices": "Mouse 3D",
|
||||
"prefs.dialog_buttons": "Pulsanti OK",
|
||||
@@ -5873,7 +5873,7 @@
|
||||
"ui.pitch": "Passo",
|
||||
"ui.placement": "Posizionamento",
|
||||
"ui.placement_handles": "Maniglie di posizionamento",
|
||||
"ui.placement_handles_tip": "Maniglie sull'anteprima di un Appoggio, Risalto o Aggancio a scatto: trascina il marcatore per spostarlo sul modello, ruotalo dal suo anello",
|
||||
"ui.placement_handles_tip": "Un indicatore per trascinare la lavorazione sul modello e, dove la lavorazione può ruotare, un anello per ruotarla.",
|
||||
"ui.plane": "Piano",
|
||||
"ui.plane_at_angle": "Piano inclinato",
|
||||
"ui.plugins": "Plugin",
|
||||
|
||||
@@ -2653,7 +2653,7 @@
|
||||
"prefs.design_tools.imported_files": "インポートしたファイル",
|
||||
"prefs.design_tools.note": "通知",
|
||||
"prefs.design_tools.off": "オフ",
|
||||
"prefs.design_tools.placement_group": "レスト、ボス、スナップフィット",
|
||||
"prefs.design_tools.placement_group": "配置するフィーチャー",
|
||||
"prefs.design_tools.rows": "行数",
|
||||
"prefs.devices": "3D マウス",
|
||||
"prefs.dialog_buttons": "ダイアログのボタン",
|
||||
@@ -6070,7 +6070,7 @@
|
||||
"ui.pitch": "ピッチ",
|
||||
"ui.placement": "配置",
|
||||
"ui.placement_handles": "配置ハンドル",
|
||||
"ui.placement_handles_tip": "モデル上でフィーチャーをドラッグするマーカーと、回転させるリング。",
|
||||
"ui.placement_handles_tip": "モデル上でフィーチャーをドラッグするマーカーと、フィーチャーを回転できる場合は回転させるリング。",
|
||||
"ui.plane": "平面",
|
||||
"ui.plane_at_angle": "傾斜平面",
|
||||
"ui.plugins": "プラグイン",
|
||||
|
||||
@@ -5073,7 +5073,7 @@
|
||||
"ui.pitch": "ბიჯი",
|
||||
"ui.placement": "განთავსება",
|
||||
"ui.placement_handles": "განთავსების სახელურები",
|
||||
"ui.placement_handles_tip": "სახელურები საყრდენის, ბოსის ან ჩამკეტის გადახედვაზე: მარკერი გადაათრიეთ მოდელზე მის გადასაადგილებლად, ხოლო რგოლით შეაბრუნეთ",
|
||||
"ui.placement_handles_tip": "მარკერი ოპერაციის მოდელზე გადასატანად და, სადაც ოპერაციის მობრუნება შეიძლება, რგოლი მის მოსაბრუნებლად.",
|
||||
"ui.plane": "სიბრტყე",
|
||||
"ui.plane_at_angle": "სიბრტყე კუთხით",
|
||||
"ui.plugins": "პლაგინები",
|
||||
|
||||
@@ -1996,7 +1996,7 @@
|
||||
"prefs.design_tools.imported_files": "가져온 파일",
|
||||
"prefs.design_tools.note": "알림",
|
||||
"prefs.design_tools.off": "끄기",
|
||||
"prefs.design_tools.placement_group": "레스트, 보스, 스냅 핏",
|
||||
"prefs.design_tools.placement_group": "배치 피처",
|
||||
"prefs.design_tools.rows": "행",
|
||||
"prefs.devices": "3D 마우스",
|
||||
"prefs.dialog_buttons": "대화 상자 버튼",
|
||||
@@ -5195,7 +5195,7 @@
|
||||
"ui.pipe": "파이프",
|
||||
"ui.pitch": "피치",
|
||||
"ui.placement": "배치",
|
||||
"ui.placement_handles_tip": "모델 위로 피처를 드래그하는 마커와 회전하는 링입니다.",
|
||||
"ui.placement_handles_tip": "모델 위로 피처를 드래그하는 마커와, 피처를 회전할 수 있는 경우 회전하는 링입니다.",
|
||||
"ui.plane": "평면",
|
||||
"ui.plane_at_angle": "각도 평면",
|
||||
"ui.plugins": "플러그인",
|
||||
|
||||
@@ -2582,7 +2582,7 @@
|
||||
"prefs.design_tools.imported_files": "Importētie faili",
|
||||
"prefs.design_tools.note": "Piezīme",
|
||||
"prefs.design_tools.off": "Izslēgts",
|
||||
"prefs.design_tools.placement_group": "Atbalsts, izcilnis, aizspiedsavienojums",
|
||||
"prefs.design_tools.placement_group": "Izvietojamās operācijas",
|
||||
"prefs.design_tools.rows": "Rindas",
|
||||
"prefs.devices": "3D pele",
|
||||
"prefs.dialog_buttons": "Dialoglodziņu pogas",
|
||||
@@ -5903,7 +5903,7 @@
|
||||
"ui.pitch": "Solis",
|
||||
"ui.placement": "Izvietojums",
|
||||
"ui.placement_handles": "Izvietošanas turi",
|
||||
"ui.placement_handles_tip": "Marķieris operācijas vilkšanai pa modeli un gredzens tās pagriešanai.",
|
||||
"ui.placement_handles_tip": "Marķieris operācijas vilkšanai pa modeli un, kur operāciju var pagriezt, gredzens tās pagriešanai.",
|
||||
"ui.plane": "Plakne",
|
||||
"ui.plane_at_angle": "Plakne leņķī",
|
||||
"ui.plugins": "Spraudņi",
|
||||
|
||||
@@ -4915,7 +4915,7 @@
|
||||
"ui.pitch": "Pic",
|
||||
"ui.placement": "Penempatan",
|
||||
"ui.placement_handles": "Pemegang penempatan",
|
||||
"ui.placement_handles_tip": "Pemegang pada pratonton Penyokong, Bonggol atau Sambungan Sentap: seret penanda untuk menggerakkannya merentasi model, putarkannya melalui cincinnya",
|
||||
"ui.placement_handles_tip": "Penanda untuk menyeret ciri merentasi model dan, jika ciri boleh dipusingkan, gelang untuk memusingkannya.",
|
||||
"ui.plane": "Satah",
|
||||
"ui.plane_at_angle": "Satah Bersudut",
|
||||
"ui.plugins": "Pemalam",
|
||||
|
||||
@@ -4915,7 +4915,7 @@
|
||||
"ui.pitch": "Stigning",
|
||||
"ui.placement": "Plassering",
|
||||
"ui.placement_handles": "Plasseringshåndtak",
|
||||
"ui.placement_handles_tip": "Håndtak på forhåndsvisningen av et anlegg, skruefeste eller snap-fit: dra markøren for å flytte den over modellen, drei den med ringen",
|
||||
"ui.placement_handles_tip": "En markør for å dra funksjonen over modellen og, der funksjonen kan dreies, en ring for å dreie den.",
|
||||
"ui.plane": "Plan",
|
||||
"ui.plane_at_angle": "Plan i vinkel",
|
||||
"ui.plugins": "Programtillegg",
|
||||
|
||||
@@ -4929,7 +4929,7 @@
|
||||
"ui.pitch": "Steek",
|
||||
"ui.placement": "Plaatsing",
|
||||
"ui.placement_handles": "Plaatsingsgrepen",
|
||||
"ui.placement_handles_tip": "Grepen op het voorbeeld van een Rustvlak, Nok of Snapverbinding: sleep de markering om hem over het model te verplaatsen, draai hem aan de ring",
|
||||
"ui.placement_handles_tip": "Een markering om het kenmerk over het model te slepen en, waar het kenmerk kan draaien, een ring om het te draaien.",
|
||||
"ui.plane": "Vlak",
|
||||
"ui.plane_at_angle": "Vlak onder hoek",
|
||||
"ui.plugins": "Plug-ins",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"ar": {
|
||||
"prefs.keep_hover_while_navigating": "أبقِ التمييز أثناء تحرك العرض",
|
||||
"prefs.keep_hover_while_navigating_note": "سطح المؤشر يحتفظ بتمييزه أثناء تحرك العرض تحته (إيقاف: يختفي التمييز أثناء تحرك العرض كما كان سابقًا، #2130)"
|
||||
},
|
||||
"de": {
|
||||
"prefs.keep_hover_while_navigating": "Hervorhebung beim Bewegen der Ansicht beibehalten",
|
||||
"prefs.keep_hover_while_navigating_note": "Die Fläche unter dem Zeiger behält ihre Hervorhebung, während sich die Ansicht darunter bewegt (aus: die Hervorhebung erlischt beim Bewegen der Ansicht wie zuvor, #2130)"
|
||||
},
|
||||
"en": {
|
||||
"prefs.keep_hover_while_navigating": "Keep the highlight while the view moves",
|
||||
"prefs.keep_hover_while_navigating_note": "The face under the pointer keeps its highlight while the view moves under it (off: the highlight goes off while the view moves, as before, #2130)"
|
||||
},
|
||||
"he": {
|
||||
"prefs.keep_hover_while_navigating": "שמור על ההדגשה בזמן שהתצוגה זזה",
|
||||
"prefs.keep_hover_while_navigating_note": "הפאה שמתחת למצביע שומרת על ההדגשה בזמן שהתצוגה זזה מתחתיה (כבוי: ההדגשה נעלמת בזמן שהתצוגה זזה כמו קודם, #2130)"
|
||||
},
|
||||
"ja": {
|
||||
"prefs.keep_hover_while_navigating": "ビューが動く間もハイライトを維持",
|
||||
"prefs.keep_hover_while_navigating_note": "ポインターが指している面は、ビューがその下を動く間もハイライトを維持します(オフ:以前のように、ビューが動く間はハイライトが消えます #2130)"
|
||||
},
|
||||
"ko": {
|
||||
"prefs.keep_hover_while_navigating": "뷰가 움직일 때도 하이라이트 유지",
|
||||
"prefs.keep_hover_while_navigating_note": "포인터 아래의 면은 그 아래에서 뷰가 움직이는 동안에도 하이라이트를 유지합니다 (끄면: 이전처럼 뷰가 움직일 때 하이라이트가 사라집니다, #2130)"
|
||||
},
|
||||
"vi": {
|
||||
"prefs.keep_hover_while_navigating": "Giữ điểm sáng khi khung nhìn di chuyển",
|
||||
"prefs.keep_hover_while_navigating_note": "Mặt dưới con trỏ giữ điểm sáng khi khung nhìn di chuyển bên dưới nó (tắt: điểm sáng tắt khi khung nhìn di chuyển như trước đây #2130)"
|
||||
},
|
||||
"zh": {
|
||||
"prefs.keep_hover_while_navigating": "视图移动时保持高亮",
|
||||
"prefs.keep_hover_while_navigating_note": "指针下的面在视图于其下方移动时保持高亮(关闭:与以前一样,视图移动时高亮会消失 #2130)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"en": {
|
||||
"settings.names_from_file": "Name tabs and the Browser's top row after the design's file"
|
||||
},
|
||||
"de": {
|
||||
"settings.names_from_file": "Tabs und oberste Browserzeile nach der Datei des Designs benennen"
|
||||
},
|
||||
"ar": {
|
||||
"settings.names_from_file": "تسمية علامات التبويب والصف العلوي في المستعرض باسم ملف التصميم"
|
||||
},
|
||||
"he": {
|
||||
"settings.names_from_file": "לקרוא ללשוניות ולשורה העליונה בדפדפן בשם קובץ העיצוב"
|
||||
},
|
||||
"ja": {
|
||||
"settings.names_from_file": "タブとブラウザーの最上行にデザインのファイル名を表示"
|
||||
},
|
||||
"vi": {
|
||||
"settings.names_from_file": "Đặt tên thẻ và hàng trên cùng của Trình duyệt theo tệp của thiết kế"
|
||||
},
|
||||
"zh": {
|
||||
"settings.names_from_file": "以设计的文件名命名标签页和浏览器顶行"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"en": {
|
||||
"settings.snap_points": "Snap to center, middle and end points when picking a point"
|
||||
},
|
||||
"en-GB": {
|
||||
"settings.snap_points": "Snap to centre, middle and end points when picking a point"
|
||||
},
|
||||
"de": {
|
||||
"settings.snap_points": "Beim Auswählen eines Punkts an Mittel- und Endpunkten fangen"
|
||||
},
|
||||
"ja": {
|
||||
"settings.snap_points": "点を選択するときに中心点、中点、端点にスナップ"
|
||||
},
|
||||
"ko": {
|
||||
"settings.snap_points": "점을 선택할 때 중심점, 중간점, 끝점에 스냅"
|
||||
},
|
||||
"zh": {
|
||||
"settings.snap_points": "拾取点时捕捉中心点、中点和端点"
|
||||
},
|
||||
"ar": {
|
||||
"settings.snap_points": "الالتقاط إلى نقاط المركز والمنتصف والنهاية عند اختيار نقطة"
|
||||
},
|
||||
"he": {
|
||||
"settings.snap_points": "הצמדה לנקודות מרכז, אמצע וקצה בעת בחירת נקודה"
|
||||
},
|
||||
"vi": {
|
||||
"settings.snap_points": "Bắt dính vào điểm tâm, điểm giữa và điểm đầu mút khi chọn một điểm"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ar": {"cmd.sketch_spline_handles": "إضافة مقابض المماس", "cmd.sketch_spline_reset_handles": "إعادة تعيين مقابض المماس"},
|
||||
"de": {"cmd.sketch_spline_handles": "Tangentengriffe hinzufügen", "cmd.sketch_spline_reset_handles": "Tangentengriffe zurücksetzen"},
|
||||
"en": {"cmd.sketch_spline_handles": "Add Tangent Handles", "cmd.sketch_spline_reset_handles": "Reset Tangent Handles"},
|
||||
"he": {"cmd.sketch_spline_handles": "הוסף ידיות משיק", "cmd.sketch_spline_reset_handles": "אפס ידיות משיק"},
|
||||
"ja": {"cmd.sketch_spline_handles": "接線ハンドルを追加", "cmd.sketch_spline_reset_handles": "接線ハンドルをリセット"},
|
||||
"vi": {"cmd.sketch_spline_handles": "Thêm tay nắm tiếp tuyến", "cmd.sketch_spline_reset_handles": "Đặt lại tay nắm tiếp tuyến"},
|
||||
"zh": {"cmd.sketch_spline_handles": "添加切线控制柄", "cmd.sketch_spline_reset_handles": "重置切线控制柄"}
|
||||
}
|
||||
@@ -2653,7 +2653,7 @@
|
||||
"prefs.design_tools.imported_files": "Zaimportowane pliki",
|
||||
"prefs.design_tools.note": "Uwaga",
|
||||
"prefs.design_tools.off": "Wyłączone",
|
||||
"prefs.design_tools.placement_group": "Podparcie, Występ, Zatrzask",
|
||||
"prefs.design_tools.placement_group": "Umieszczane operacje",
|
||||
"prefs.design_tools.rows": "Wiersze",
|
||||
"prefs.devices": "Mysz 3D",
|
||||
"prefs.dialog_buttons": "Przyciski okien dialogowych",
|
||||
@@ -6070,7 +6070,7 @@
|
||||
"ui.pitch": "Skok",
|
||||
"ui.placement": "Umiejscowienie",
|
||||
"ui.placement_handles": "Uchwyty umieszczania",
|
||||
"ui.placement_handles_tip": "Uchwyty na podglądzie podparcia, występu lub zatrzasku: przeciągnij znacznik, aby przesunąć element po modelu, obróć go pierścieniem",
|
||||
"ui.placement_handles_tip": "Znacznik do przeciągania operacji po modelu i, tam gdzie operację można obracać, pierścień do jej obracania.",
|
||||
"ui.plane": "Płaszczyzna",
|
||||
"ui.plane_at_angle": "Płaszczyzna pod kątem",
|
||||
"ui.plugins": "Wtyczki",
|
||||
|
||||
@@ -2588,7 +2588,7 @@
|
||||
"prefs.design_tools.imported_files": "Arquivos importados",
|
||||
"prefs.design_tools.note": "Observação",
|
||||
"prefs.design_tools.off": "Desativado",
|
||||
"prefs.design_tools.placement_group": "Apoio, Ressalto, Encaixe por pressão",
|
||||
"prefs.design_tools.placement_group": "Recursos posicionados",
|
||||
"prefs.design_tools.rows": "Linhas",
|
||||
"prefs.devices": "Mouse 3D",
|
||||
"prefs.dialog_buttons": "Botões das caixas de diálogo",
|
||||
@@ -5927,7 +5927,7 @@
|
||||
"ui.pitch": "Passo",
|
||||
"ui.placement": "Posicionamento",
|
||||
"ui.placement_handles": "Alças de posicionamento",
|
||||
"ui.placement_handles_tip": "Um marcador para arrastar o recurso pelo modelo e um anel para girá-lo.",
|
||||
"ui.placement_handles_tip": "Um marcador para arrastar o recurso pelo modelo e, onde o recurso pode girar, um anel para girá-lo.",
|
||||
"ui.plane": "Plano",
|
||||
"ui.plane_at_angle": "Plano em ângulo",
|
||||
"ui.plugins": "Plug-ins",
|
||||
|
||||
@@ -4915,7 +4915,7 @@
|
||||
"ui.pitch": "Pas",
|
||||
"ui.placement": "Plasare",
|
||||
"ui.placement_handles": "Mânere de plasare",
|
||||
"ui.placement_handles_tip": "Mânere pe previzualizarea unui Reazem, Bosaj sau a unei Prinderi cu clichet: trageți marcajul pentru a-l muta pe model, rotiți-l cu inelul său",
|
||||
"ui.placement_handles_tip": "Un marcaj pentru a trage operația pe model și, acolo unde operația se poate roti, un inel pentru a o roti.",
|
||||
"ui.plane": "Plan",
|
||||
"ui.plane_at_angle": "Plan în unghi",
|
||||
"ui.plugins": "Pluginuri",
|
||||
|
||||
@@ -4870,7 +4870,7 @@
|
||||
"ui.pitch": "Шаг",
|
||||
"ui.placement": "Размещение",
|
||||
"ui.placement_handles": "Манипуляторы размещения",
|
||||
"ui.placement_handles_tip": "Манипуляторы на предпросмотре «Опоры», «Бобышки» или «Защёлки»: перетащите маркер, чтобы переместить его по модели, поверните за кольцо",
|
||||
"ui.placement_handles_tip": "Маркер для перетаскивания операции по модели и, где операцию можно поворачивать, кольцо для её поворота.",
|
||||
"ui.plane": "Плоскость",
|
||||
"ui.plane_at_angle": "Плоскость под углом",
|
||||
"ui.plugins": "Плагины",
|
||||
|
||||
@@ -4907,7 +4907,7 @@
|
||||
"ui.pitch": "Корак",
|
||||
"ui.placement": "Постављање",
|
||||
"ui.placement_handles": "Ручице за постављање",
|
||||
"ui.placement_handles_tip": "Ручице на прегледу ослонца, испуста или шкљоцајућег споја: превуците ознаку да је померите по моделу, окрените је њеним прстеном",
|
||||
"ui.placement_handles_tip": "Ознака за превлачење операције по моделу и, где се операција може окретати, прстен за њено окретање.",
|
||||
"ui.plane": "Раван",
|
||||
"ui.plane_at_angle": "Раван под углом",
|
||||
"ui.plugins": "Проширења",
|
||||
|
||||
@@ -4916,7 +4916,7 @@
|
||||
"ui.pitch": "Stigning",
|
||||
"ui.placement": "Placering",
|
||||
"ui.placement_handles": "Placeringshandtag",
|
||||
"ui.placement_handles_tip": "Handtag på förhandsvisningen av ett stöd, nav eller snäppfäste: dra markören för att flytta det över modellen, vrid det med dess ring",
|
||||
"ui.placement_handles_tip": "En markör för att dra funktionen över modellen och, där funktionen kan vridas, en ring för att vrida den.",
|
||||
"ui.plane": "Plan",
|
||||
"ui.plane_at_angle": "Plan i vinkel",
|
||||
"ui.plugins": "Insticksmoduler",
|
||||
|
||||
@@ -4916,7 +4916,7 @@
|
||||
"ui.pitch": "Adım",
|
||||
"ui.placement": "Yerleşim",
|
||||
"ui.placement_handles": "Yerleşim tutamaçları",
|
||||
"ui.placement_handles_tip": "Bir Dayama, Kabarıklık veya Kilit Geçme önizlemesindeki tutamaçlar: işaretçiyi model üzerinde hareket ettirmek için sürükleyin, halkasıyla döndürün",
|
||||
"ui.placement_handles_tip": "Özelliği model üzerinde sürüklemek için bir işaretçi ve, özellik döndürülebiliyorsa, döndürmek için bir halka.",
|
||||
"ui.plane": "Düzlem",
|
||||
"ui.plane_at_angle": "Açılı düzlem",
|
||||
"ui.plugins": "Eklentiler",
|
||||
|
||||
@@ -2623,7 +2623,7 @@
|
||||
"prefs.design_tools.imported_files": "Імпортовані файли",
|
||||
"prefs.design_tools.note": "Примітка",
|
||||
"prefs.design_tools.off": "Вимк.",
|
||||
"prefs.design_tools.placement_group": "Упор, Бобишка, Защіпкове з'єднання",
|
||||
"prefs.design_tools.placement_group": "Розміщувані операції",
|
||||
"prefs.design_tools.rows": "Рядки",
|
||||
"prefs.devices": "3D-миша",
|
||||
"prefs.dialog_buttons": "Кнопки діалогу",
|
||||
@@ -5989,7 +5989,7 @@
|
||||
"ui.pitch": "Крок",
|
||||
"ui.placement": "Розміщення",
|
||||
"ui.placement_handles": "Ручки розміщення",
|
||||
"ui.placement_handles_tip": "Маркер, щоб перетягнути елемент по моделі, і кільце, щоб його повернути.",
|
||||
"ui.placement_handles_tip": "Маркер, щоб перетягнути елемент по моделі, і, де елемент можна повернути, кільце, щоб його повернути.",
|
||||
"ui.plane": "Площина",
|
||||
"ui.plane_at_angle": "Площина під кутом",
|
||||
"ui.plugins": "Плагіни",
|
||||
|
||||
@@ -2653,7 +2653,7 @@
|
||||
"prefs.design_tools.imported_files": "Tệp đã nhập",
|
||||
"prefs.design_tools.note": "Thông báo",
|
||||
"prefs.design_tools.off": "Tắt",
|
||||
"prefs.design_tools.placement_group": "Gối đỡ, Trụ, Khớp gài",
|
||||
"prefs.design_tools.placement_group": "Các tính năng đặt vị trí",
|
||||
"prefs.design_tools.rows": "Hàng",
|
||||
"prefs.devices": "Chuột 3D",
|
||||
"prefs.dialog_buttons": "Nút hộp thoại",
|
||||
@@ -6070,7 +6070,7 @@
|
||||
"ui.pitch": "Bước xoắn",
|
||||
"ui.placement": "Vị trí đặt",
|
||||
"ui.placement_handles": "Tay nắm định vị",
|
||||
"ui.placement_handles_tip": "Dấu để kéo đặc trưng trên mô hình và vòng để xoay.",
|
||||
"ui.placement_handles_tip": "Dấu để kéo đặc trưng trên mô hình và, ở nơi đặc trưng có thể xoay, vòng để xoay.",
|
||||
"ui.plane": "Mặt phẳng",
|
||||
"ui.plane_at_angle": "Mặt phẳng theo góc",
|
||||
"ui.plugins": "Plugin",
|
||||
|
||||
@@ -2588,7 +2588,7 @@
|
||||
"prefs.design_tools.imported_files": "導入的檔案",
|
||||
"prefs.design_tools.note": "提示",
|
||||
"prefs.design_tools.off": "關閉",
|
||||
"prefs.design_tools.placement_group": "支座、凸台、卡扣",
|
||||
"prefs.design_tools.placement_group": "放置的特徵",
|
||||
"prefs.design_tools.rows": "行數",
|
||||
"prefs.devices": "3D 游標",
|
||||
"prefs.dialog_buttons": "對話框按鈕",
|
||||
@@ -5929,7 +5929,7 @@
|
||||
"ui.pitch": "螺距",
|
||||
"ui.placement": "放置",
|
||||
"ui.placement_handles": "放置手柄",
|
||||
"ui.placement_handles_tip": "用於在模型上拖動特徵的標記和用於旋轉的圓環。",
|
||||
"ui.placement_handles_tip": "用於在模型上拖動特徵的標記,以及在特徵可旋轉時用於旋轉的圓環。",
|
||||
"ui.plane": "平面",
|
||||
"ui.plane_at_angle": "角度平面",
|
||||
"ui.plugins": "插件",
|
||||
|
||||
@@ -2653,7 +2653,7 @@
|
||||
"prefs.design_tools.imported_files": "导入的文件",
|
||||
"prefs.design_tools.note": "提示",
|
||||
"prefs.design_tools.off": "关闭",
|
||||
"prefs.design_tools.placement_group": "支座、凸台、卡扣",
|
||||
"prefs.design_tools.placement_group": "放置的特征",
|
||||
"prefs.design_tools.rows": "行数",
|
||||
"prefs.devices": "3D 鼠标",
|
||||
"prefs.dialog_buttons": "对话框按钮",
|
||||
@@ -6070,7 +6070,7 @@
|
||||
"ui.pitch": "螺距",
|
||||
"ui.placement": "放置",
|
||||
"ui.placement_handles": "放置手柄",
|
||||
"ui.placement_handles_tip": "用于在模型上拖动特征的标记和用于旋转的圆环。",
|
||||
"ui.placement_handles_tip": "用于在模型上拖动特征的标记,以及在特征可旋转时用于旋转的圆环。",
|
||||
"ui.plane": "平面",
|
||||
"ui.plane_at_angle": "角度平面",
|
||||
"ui.plugins": "插件",
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
| File | Author | Source | Licence |
|
||||
|---|---|---|---|
|
||||
| assets/fonts/craft-ui-kr/CraftUI-KR.otf | Adobe / Google Inc. (Noto Sans KR); SolidCraft subset | https://github.com/notofonts/noto-cjk Sans/SubsetOTF/KR Regular, glyph-subset for the Hangul syllable block and renamed Craft UI KR (OFL reserved-name); licence text in assets/fonts/craft-ui-kr/OFL.txt; web UI Korean | SIL OFL 1.1 |
|
||||
| assets/fonts/craft-ui-kr/OFL.txt | Adobe / Google Inc. (Noto CJK); SolidCraft header | Accompanying OFL for CraftUI-KR.otf | SIL OFL 1.1 |
|
||||
@@ -0,0 +1 @@
|
||||
| `assets/locales/pending/names-from-file.json` | SolidCraft contributors | Original label for the names-from-file preference | MIT OR Apache-2.0 |
|
||||
@@ -0,0 +1 @@
|
||||
| `assets/locales/pending/snap-points.json` | SolidCraft contributors | Original snap point labels and messages | MIT OR Apache-2.0 |
|
||||
@@ -20,4 +20,5 @@ from an `App` value the app defines. No app types and no engine.
|
||||
| `auth::tokens_match`, `auth::remove_token_file`, `auth::TokenFile`, `auth::pid_path`, `auth::MIN_TOKEN_LEN` | Constant-time compare and token file housekeeping |
|
||||
| `Effect` | What a request still running does when it finishes: reads only, writes a file, or changes; `when_done(document)` says it |
|
||||
| `server::Hub`, `server::Protocol`, `server::Summary`, `server::Waits`, `server::new_token` | The loopback JSON-lines server behind the token (desktop only); the app says what its requests mean |
|
||||
| `client::Connection`, `client::Request`, `client::CallError`, `client::Waits` | The client (desktop only): the app's token on every request, one bounded round trip, a fresh connection after a lost one, the wait for a request still running (`control.pending`); "not running" told from "lost during the call" |
|
||||
| `ControlRequest`, `ControlResponse`, `DocumentWatch`, `DOCUMENT_WATCH` | A request as the window gets it; the document id and revision subscribed connections wait on |
|
||||
|
||||
@@ -0,0 +1,616 @@
|
||||
//! The control channel's client (docs/control-protocol.md): a connection to an app's loopback
|
||||
//! control port, with the app's token ([`App::client_token`]) on every request, one bounded
|
||||
//! round trip per request, a fresh connection after one is lost, and the wait for a request the
|
||||
//! app says is still running (#1618), through `control.pending`. Its errors tell an app that is
|
||||
//! not running from one that went away during a call ([`CallError`]). What a request means, and
|
||||
//! anything an app adds to its parameters, is the caller's.
|
||||
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpStream, ToSocketAddrs};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Effect;
|
||||
use crate::auth::App;
|
||||
|
||||
/// Longest reply line accepted from the app.
|
||||
pub const MAX_REPLY: u64 = 64 << 20;
|
||||
|
||||
/// How long a reply may take: kernel operations (booleans, fillets) on big parts can take a while.
|
||||
pub const READ_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
|
||||
/// How long the app may take to answer a request sent [`Request::once`]: a little longer than its
|
||||
/// control server waits for a long request (3600 s), so its "still running" reply arrives first.
|
||||
pub const ONCE_TIMEOUT: Duration = Duration::from_secs(3720);
|
||||
|
||||
/// How long connecting to the app may take.
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_millis(800);
|
||||
|
||||
/// How often a long wait is reported ([`Connection::with_progress`]).
|
||||
const PROGRESS_EVERY: Duration = Duration::from_secs(30);
|
||||
|
||||
/// How an error starts when a request is still running in the app after the wait: the request
|
||||
/// was not cancelled and its reply waits on the connection; [`Connection::pending`] gets it.
|
||||
pub const STILL_RUNNING: &str = "still running:";
|
||||
|
||||
/// How long to wait for a request the app says is still running.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Waits {
|
||||
/// How long a call goes on waiting, asking `control.pending`, after the app said its request
|
||||
/// is still running (the app says so after 60 s), before it says so itself.
|
||||
pub pending: Duration,
|
||||
/// How long a later call, or [`Connection::pending`], waits for a request still running from
|
||||
/// an earlier call.
|
||||
pub settle: Duration,
|
||||
/// How often `control.pending` is asked while waiting.
|
||||
pub poll: Duration,
|
||||
}
|
||||
|
||||
impl Default for Waits {
|
||||
fn default() -> Self {
|
||||
Waits { pending: Duration::from_secs(240), settle: Duration::from_secs(60), poll: Duration::from_millis(500) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a call has no result. Each holds the message for the caller (an agent reads it).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum CallError {
|
||||
/// Nothing listens at the address: the app is not running, or it quit before the call was
|
||||
/// sent. The message says how to start it.
|
||||
NotRunning(String),
|
||||
/// The app went away or did not answer during the call, after running it or not.
|
||||
Lost(String),
|
||||
/// The request is still running in the app after the wait (it starts with [`STILL_RUNNING`]),
|
||||
/// or an earlier one is and this one was not sent.
|
||||
StillRunning(String),
|
||||
/// The app answered with an error (a refused token says which token file it came from).
|
||||
Refused(String),
|
||||
/// The reply was not a control reply.
|
||||
BadReply(String),
|
||||
}
|
||||
|
||||
impl CallError {
|
||||
/// The message.
|
||||
pub fn message(&self) -> &str {
|
||||
match self {
|
||||
CallError::NotRunning(m) | CallError::Lost(m) | CallError::StillRunning(m) | CallError::Refused(m) | CallError::BadReply(m) => m,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CallError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.message())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for CallError {}
|
||||
|
||||
impl From<CallError> for String {
|
||||
fn from(e: CallError) -> String {
|
||||
e.message().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// A request: its method and parameters, how messages name it and what it does if it is still
|
||||
/// running when its call returns.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Request {
|
||||
pub method: String,
|
||||
pub params: Value,
|
||||
/// The request in messages ("`engine.execute` (solid.fillet)").
|
||||
pub what: String,
|
||||
/// What it still does when it finishes after its wait.
|
||||
pub effect: Effect,
|
||||
/// Never sent twice: on a fresh connection, waiting up to [`ONCE_TIMEOUT`], and once it is
|
||||
/// being written any error is final, as the app may have run it. Otherwise a request that
|
||||
/// finds its connection lost is sent once more on a fresh one (the app may have restarted).
|
||||
pub once: bool,
|
||||
}
|
||||
|
||||
impl Request {
|
||||
/// `method` with `params`, named "`method`" in messages, taken to change the document, sent
|
||||
/// again once on a fresh connection when its connection was lost.
|
||||
pub fn new(method: &str, params: Value) -> Self {
|
||||
Request { method: method.to_string(), params, what: format!("`{method}`"), effect: Effect::Changes, once: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// A control reply as a call's result: its `result`, or its `error`.
|
||||
pub fn outcome(v: &Value) -> Result<Value, String> {
|
||||
if v.get("ok").and_then(Value::as_bool) == Some(true) {
|
||||
Ok(v.get("result").cloned().unwrap_or(Value::Null))
|
||||
} else {
|
||||
Err(v.get("error").and_then(Value::as_str).unwrap_or("error").to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// A request the app said is still running. Its reply will come on this connection, through
|
||||
/// `control.pending`, so the connection is kept for it.
|
||||
struct Outstanding {
|
||||
what: String,
|
||||
/// What it does when it finishes; `None`: not known (a request this client did not send).
|
||||
effect: Option<Effect>,
|
||||
since: Instant,
|
||||
}
|
||||
|
||||
impl Outstanding {
|
||||
fn secs(&self) -> u64 {
|
||||
self.since.elapsed().as_secs()
|
||||
}
|
||||
}
|
||||
|
||||
/// What became of a request still running when its call returned, once a later call waited it
|
||||
/// out: kept for [`Connection::pending`].
|
||||
enum Settled {
|
||||
Finished { what: String, reply: Value },
|
||||
Lost { what: String },
|
||||
}
|
||||
|
||||
/// The end of a wait for an outstanding request.
|
||||
enum Waited {
|
||||
/// It finished: its own reply.
|
||||
Done(Value),
|
||||
/// The app holds nothing (any more) for this connection.
|
||||
Nothing,
|
||||
/// Still running when the wait ran out.
|
||||
Running,
|
||||
}
|
||||
|
||||
/// A connection to the app listening at an address. It connects when a call needs it, so a
|
||||
/// client started before the app reaches it once it runs.
|
||||
pub struct Connection {
|
||||
app: App,
|
||||
/// What the app calls its document in messages ("design").
|
||||
document: &'static str,
|
||||
addr: String,
|
||||
port: u16,
|
||||
conn: Option<(BufReader<TcpStream>, TcpStream)>,
|
||||
/// Counts the connections made, so a caller can tell a fresh one from the one it asked.
|
||||
serial: u64,
|
||||
next_id: u64,
|
||||
/// The token given, when one was. Otherwise each request looks the token up again, since
|
||||
/// every launch of the app makes a new one.
|
||||
explicit_token: Option<String>,
|
||||
/// A request still running in the app; its reply waits on `conn`.
|
||||
outstanding: Option<Outstanding>,
|
||||
/// An outstanding request's end, once a later call waited for it (for `pending`).
|
||||
settled: Option<Settled>,
|
||||
waits: Waits,
|
||||
/// Where a long wait is reported.
|
||||
progress: fn(&str),
|
||||
}
|
||||
|
||||
impl Connection {
|
||||
/// A connection to `app` at `addr` (`host:port`), with `token` when given, else the app's
|
||||
/// token found the usual way ([`App::client_token`]); not connected yet.
|
||||
pub fn new(app: App, addr: &str, token: Option<&str>) -> Self {
|
||||
let port = addr.rsplit(':').next().and_then(|p| p.parse::<u16>().ok()).unwrap_or(0);
|
||||
Self {
|
||||
app,
|
||||
document: "design",
|
||||
addr: addr.to_string(),
|
||||
port,
|
||||
conn: None,
|
||||
serial: 0,
|
||||
next_id: 1,
|
||||
explicit_token: token.map(str::to_string),
|
||||
outstanding: None,
|
||||
settled: None,
|
||||
waits: Waits::default(),
|
||||
progress: |_| {},
|
||||
}
|
||||
}
|
||||
|
||||
/// What the app calls its document in messages ("design", the default).
|
||||
#[must_use]
|
||||
pub fn with_document(mut self, document: &'static str) -> Self {
|
||||
self.document = document;
|
||||
self
|
||||
}
|
||||
|
||||
/// Report a long wait for a request still running to `progress`, every 30 s.
|
||||
#[must_use]
|
||||
pub fn with_progress(mut self, progress: fn(&str)) -> Self {
|
||||
self.progress = progress;
|
||||
self
|
||||
}
|
||||
|
||||
/// How long to wait for a request still running.
|
||||
pub fn set_waits(&mut self, waits: Waits) {
|
||||
self.waits = waits;
|
||||
}
|
||||
|
||||
/// The address it connects to.
|
||||
pub fn addr(&self) -> &str {
|
||||
&self.addr
|
||||
}
|
||||
|
||||
/// The port of the address (0 when it names none).
|
||||
pub fn port(&self) -> u16 {
|
||||
self.port
|
||||
}
|
||||
|
||||
/// The token given, if one was.
|
||||
pub fn explicit_token(&self) -> Option<&str> {
|
||||
self.explicit_token.as_deref()
|
||||
}
|
||||
|
||||
/// Whether a connection is open now.
|
||||
pub fn is_connected(&self) -> bool {
|
||||
self.conn.is_some()
|
||||
}
|
||||
|
||||
/// The number of connections made so far: the same number while the same connection stays
|
||||
/// open.
|
||||
pub fn serial(&self) -> u64 {
|
||||
self.serial
|
||||
}
|
||||
|
||||
/// Whether a request of this connection is still running in the app.
|
||||
pub fn has_outstanding(&self) -> bool {
|
||||
self.outstanding.is_some()
|
||||
}
|
||||
|
||||
/// Close the connection. A request still running loses its reply with it.
|
||||
pub fn disconnect(&mut self) {
|
||||
self.conn = None;
|
||||
if let Some(o) = self.outstanding.take() {
|
||||
self.settled = Some(Settled::Lost { what: o.what });
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect afresh. Failing to reach the app at all is `NotConnected`, so [`Connection::explain`]
|
||||
/// can tell it from a connection lost during a call.
|
||||
///
|
||||
/// # Errors
|
||||
/// `NotConnected` when nothing listens at the address (or it does not resolve).
|
||||
pub fn connect(&mut self) -> std::io::Result<()> {
|
||||
self.disconnect();
|
||||
let not_connected = |e: std::io::Error| std::io::Error::new(std::io::ErrorKind::NotConnected, e);
|
||||
let mut last = std::io::Error::new(std::io::ErrorKind::NotFound, format!("cannot resolve {}", self.addr));
|
||||
for sa in self.addr.to_socket_addrs().map_err(not_connected)? {
|
||||
match TcpStream::connect_timeout(&sa, CONNECT_TIMEOUT) {
|
||||
Ok(s) => {
|
||||
s.set_nodelay(true).ok();
|
||||
s.set_read_timeout(Some(READ_TIMEOUT)).ok();
|
||||
let read = s.try_clone()?;
|
||||
self.conn = Some((BufReader::new(read), s));
|
||||
self.serial += 1;
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) => last = e,
|
||||
}
|
||||
}
|
||||
Err(not_connected(last))
|
||||
}
|
||||
|
||||
/// See whether the app is there now; the error says why not. The connection doesn't stay
|
||||
/// open: the app closes one that shows no token within a few seconds, and the first call may
|
||||
/// be long away. That call connects again.
|
||||
///
|
||||
/// # Errors
|
||||
/// As [`Connection::connect`].
|
||||
pub fn try_connect(&mut self) -> std::io::Result<()> {
|
||||
self.connect()?;
|
||||
self.disconnect();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The error for a failed exchange: whether the app is not running (and how to start it) or
|
||||
/// went away during the call.
|
||||
pub fn explain(&self, e: &std::io::Error) -> CallError {
|
||||
let (addr, port, name, document) = (&self.addr, self.port, self.app.name, self.document);
|
||||
let start = format!("{} --control {port}", name.to_lowercase());
|
||||
if e.kind() == std::io::ErrorKind::NotConnected {
|
||||
CallError::NotRunning(format!(
|
||||
"no {name} app is listening at {addr} ({e}): it is not running, or it quit. Start it with `{start}` (or ask the user to); the \
|
||||
next call connects to it"
|
||||
))
|
||||
} else {
|
||||
CallError::Lost(format!(
|
||||
"the {name} app at {addr} did not answer ({e}): it may have quit or crashed during this call, after running it or not. Inspect \
|
||||
the {document} before sending a change again; if the app quit, start it with `{start}`"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// The request line for `method`: a fresh id and the app's token (looked up again for every
|
||||
/// request: an app restarted mid-session has a new one).
|
||||
pub fn request_line(&mut self, method: &str, params: Value) -> String {
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
let mut msg = json!({"id": id, "method": method, "params": params});
|
||||
if let Some(t) = self.app.client_token(self.port, self.explicit_token.as_deref()) {
|
||||
msg["token"] = json!(t.token);
|
||||
}
|
||||
msg.to_string()
|
||||
}
|
||||
|
||||
/// One request line out and one reply line back, at most [`MAX_REPLY`] long, connecting
|
||||
/// first when not connected. Any failure closes the connection (a timed-out read leaves a
|
||||
/// late reply on the stream).
|
||||
fn roundtrip(&mut self, line: &str) -> std::io::Result<String> {
|
||||
if self.conn.is_none() {
|
||||
self.connect()?;
|
||||
}
|
||||
let Some((reader, writer)) = self.conn.as_mut() else { return Err(std::io::Error::other("not connected")) };
|
||||
// One write: a request that loses its newline still reaches the app as a request.
|
||||
let sent = writer.write_all(format!("{line}\n").as_bytes()).and_then(|()| writer.flush());
|
||||
if let Err(e) = sent {
|
||||
self.disconnect();
|
||||
return Err(e);
|
||||
}
|
||||
let mut reply = String::new();
|
||||
let n = match reader.by_ref().take(MAX_REPLY).read_line(&mut reply) {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
self.disconnect();
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
if n == 0 || !reply.ends_with('\n') {
|
||||
self.disconnect();
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "connection closed"));
|
||||
}
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
fn set_read_timeout(&self, t: Duration) {
|
||||
if let Some((_, writer)) = &self.conn {
|
||||
writer.set_read_timeout(Some(t)).ok();
|
||||
}
|
||||
}
|
||||
|
||||
/// A reply line as JSON.
|
||||
fn parse(reply: &str) -> Result<Value, CallError> {
|
||||
serde_json::from_str(reply).map_err(|e| CallError::BadReply(format!("bad reply from the app: {e}")))
|
||||
}
|
||||
|
||||
/// A control reply as a call's result; a refused token says which token file it came from
|
||||
/// (and whether it is stale).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`CallError::Refused`] with the app's error.
|
||||
pub fn outcome(&self, v: &Value) -> Result<Value, CallError> {
|
||||
outcome(v).map_err(|error| {
|
||||
CallError::Refused(
|
||||
self.app.client_token(self.port, self.explicit_token.as_deref()).map_or_else(|| error.clone(), |t| t.explain_error(&error)),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// `method` with `params`, at once and once: not behind a request still running on this
|
||||
/// connection, no second try. The app's whole reply (`ok`, `result` or `error`, and anything
|
||||
/// it adds), for a request the app answers at once on any connection
|
||||
/// (`control.capabilities`, `control.running`).
|
||||
///
|
||||
/// # Errors
|
||||
/// As [`Connection::explain`]; [`CallError::BadReply`].
|
||||
pub fn ask(&mut self, method: &str, params: Value) -> Result<Value, CallError> {
|
||||
let line = self.request_line(method, params);
|
||||
let reply = self.roundtrip(&line).map_err(|e| self.explain(&e))?;
|
||||
Self::parse(&reply)
|
||||
}
|
||||
|
||||
/// `method` with `params`, waiting at most `timeout` for the reply and never behind a request
|
||||
/// still running: for a request the app answers within a frame, asked again later when the
|
||||
/// app is busy. One try.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`CallError::StillRunning`] when a request of this connection is still running; as
|
||||
/// [`Connection::ask`] and [`Connection::outcome`].
|
||||
pub fn quick(&mut self, method: &str, params: Value, timeout: Duration) -> Result<Value, CallError> {
|
||||
if self.outstanding.is_some() {
|
||||
return Err(CallError::StillRunning("a request is still running in the app".into()));
|
||||
}
|
||||
if self.conn.is_none() {
|
||||
self.connect().map_err(|e| self.explain(&e))?;
|
||||
}
|
||||
let line = self.request_line(method, params);
|
||||
self.set_read_timeout(timeout);
|
||||
let reply = self.roundtrip(&line);
|
||||
self.set_read_timeout(READ_TIMEOUT);
|
||||
let reply = reply.map_err(|e| self.explain(&e))?;
|
||||
let v = Self::parse(&reply)?;
|
||||
self.outcome(&v)
|
||||
}
|
||||
|
||||
/// Ask the app, on this connection only, about the request it still runs for it.
|
||||
fn ask_pending(&mut self) -> Result<Value, CallError> {
|
||||
if self.conn.is_none() {
|
||||
self.disconnect();
|
||||
return Err(CallError::Lost("the connection to the app was lost".into()));
|
||||
}
|
||||
let v = self.ask("control.pending", json!({}))?;
|
||||
outcome(&v).map_err(CallError::Refused).inspect_err(|_| self.disconnect())
|
||||
}
|
||||
|
||||
/// `control.cancel` for the request still running on this connection. Sent at once, without
|
||||
/// waiting for that request first, and never on a fresh connection, which has nothing to
|
||||
/// cancel. Once the app no longer holds it (removed, stopped or finished), later calls don't
|
||||
/// wait for it.
|
||||
///
|
||||
/// # Errors
|
||||
/// As [`Connection::ask`]; [`CallError::Refused`].
|
||||
pub fn cancel(&mut self, params: Value) -> Result<Value, CallError> {
|
||||
if self.conn.is_none() {
|
||||
return Ok(json!({"cancelled": null, "note": "nothing to cancel: no request of this client is running in the app"}));
|
||||
}
|
||||
let v = self.ask("control.cancel", params)?;
|
||||
let r = outcome(&v).map_err(CallError::Refused);
|
||||
if r.is_ok() {
|
||||
self.outstanding = None;
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
/// Wait up to `limit` for the outstanding request, asking `control.pending` now and then and
|
||||
/// reporting how long it takes. An error: the connection, and so the reply, was lost.
|
||||
fn wait_outstanding(&mut self, limit: Duration) -> Result<Waited, CallError> {
|
||||
let start = Instant::now();
|
||||
let mut told = start;
|
||||
loop {
|
||||
let Some(o) = &self.outstanding else { return Ok(Waited::Nothing) };
|
||||
let what = o.what.clone();
|
||||
let r = self.ask_pending()?;
|
||||
if r.get("pending").is_none_or(Value::is_null) {
|
||||
self.outstanding = None;
|
||||
return Ok(Waited::Nothing);
|
||||
}
|
||||
if r.get("done").and_then(Value::as_bool) == Some(true) {
|
||||
self.outstanding = None;
|
||||
return Ok(Waited::Done(r.get("reply").cloned().unwrap_or(Value::Null)));
|
||||
}
|
||||
let waited = start.elapsed();
|
||||
if waited >= limit {
|
||||
return Ok(Waited::Running);
|
||||
}
|
||||
if told.elapsed() >= PROGRESS_EVERY {
|
||||
let secs = self.outstanding.as_ref().map_or(0, Outstanding::secs);
|
||||
(self.progress)(&format!("{what} is still running in the app ({secs} s); waiting for it"));
|
||||
told = Instant::now();
|
||||
}
|
||||
std::thread::sleep(self.waits.poll.min(limit.saturating_sub(waited)));
|
||||
}
|
||||
}
|
||||
|
||||
/// The error for the outstanding request still running after its wait.
|
||||
fn still_running(&self, earlier: bool) -> CallError {
|
||||
let Some(o) = &self.outstanding else { return CallError::StillRunning(format!("{STILL_RUNNING} a request is still running in the app")) };
|
||||
let document = self.document;
|
||||
let effect =
|
||||
o.effect.map_or_else(|| format!("it was not cancelled and may still change the {document} when it finishes"), |e| e.when_done(document));
|
||||
let settle = self.waits.settle.as_secs();
|
||||
CallError::StillRunning(if earlier {
|
||||
format!(
|
||||
"{STILL_RUNNING} an earlier request, {}, is still running in the app ({} s; {effect}), so this call was not sent. Call the \
|
||||
`pending` tool to wait for its result, then send this call again",
|
||||
o.what,
|
||||
o.secs()
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{STILL_RUNNING} {} is still running in the app after {} s ({effect}); don't send it again. Call the `pending` tool to \
|
||||
wait for its result (up to {settle} s per call); other calls wait for it first",
|
||||
o.what,
|
||||
o.secs()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Before a new request: wait for one still running from an earlier call, keeping its end for
|
||||
/// `pending`. Still running after the wait: the new request is not sent.
|
||||
fn settle_earlier(&mut self) -> Result<(), CallError> {
|
||||
let Some(what) = self.outstanding.as_ref().map(|o| o.what.clone()) else { return Ok(()) };
|
||||
match self.wait_outstanding(self.waits.settle) {
|
||||
Ok(Waited::Done(reply)) => self.settled = Some(Settled::Finished { what, reply }),
|
||||
Ok(Waited::Nothing) => {}
|
||||
Ok(Waited::Running) => return Err(self.still_running(true)),
|
||||
// The connection was lost, and the earlier reply with it (`pending` says so): the new
|
||||
// request goes on a fresh connection.
|
||||
Err(_) => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `control.pending` for the client: the end of the request still running from an earlier
|
||||
/// call, waiting for it up to the settle wait: `{pending: what | null, done?, reply?, lost?,
|
||||
/// running_s?}`. Never sent on a fresh connection, which has nothing pending.
|
||||
pub fn pending(&mut self) -> Value {
|
||||
if let Some(s) = self.settled.take() {
|
||||
return match s {
|
||||
Settled::Finished { what, reply } => json!({"pending": what, "done": true, "reply": reply}),
|
||||
Settled::Lost { what } => json!({"pending": what, "lost": true}),
|
||||
};
|
||||
}
|
||||
let Some(what) = self.outstanding.as_ref().map(|o| o.what.clone()) else { return json!({"pending": null}) };
|
||||
match self.wait_outstanding(self.waits.settle) {
|
||||
Ok(Waited::Done(reply)) => json!({"pending": what, "done": true, "reply": reply}),
|
||||
Ok(Waited::Nothing) => json!({"pending": null}),
|
||||
Ok(Waited::Running) => json!({"pending": what, "done": false, "running_s": self.outstanding.as_ref().map_or(0, Outstanding::secs)}),
|
||||
Err(_) => {
|
||||
self.settled = None;
|
||||
json!({"pending": what, "lost": true})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send `request` and wait for its reply: the app's whole reply (`ok`, `result` or `error`,
|
||||
/// and anything it adds). First a request still running from an earlier call is waited for
|
||||
/// (except for `control.running`, which the app answers at once); a request the app says is
|
||||
/// still running is waited for through `control.pending`; a request refused unrun as busy
|
||||
/// behind one this client did not know of is sent once more after that one, when
|
||||
/// `retry_busy`.
|
||||
///
|
||||
/// # Errors
|
||||
/// As [`Connection::explain`]: a request whose connection is lost is sent once more on a fresh
|
||||
/// one (unless [`Request::once`]), and when nothing listens any more the app went away, maybe
|
||||
/// while running it ([`CallError::Lost`]); [`CallError::StillRunning`]; [`CallError::BadReply`].
|
||||
pub fn exchange(&mut self, request: Request, retry_busy: bool) -> Result<Value, CallError> {
|
||||
let Request { method, params, what, effect, once } = request;
|
||||
if method != "control.running" {
|
||||
self.settle_earlier()?;
|
||||
}
|
||||
let again = retry_busy.then(|| params.clone());
|
||||
let sent = Instant::now();
|
||||
let line = self.request_line(&method, params);
|
||||
let reply = if once {
|
||||
// A fresh connection (an idle one may have been closed by an app restart; connecting
|
||||
// again sends nothing).
|
||||
self.connect().or_else(|_| self.connect()).and_then(|()| {
|
||||
self.set_read_timeout(ONCE_TIMEOUT);
|
||||
let r = self.roundtrip(&line);
|
||||
self.set_read_timeout(READ_TIMEOUT);
|
||||
r
|
||||
})
|
||||
} else {
|
||||
self.roundtrip(&line).or_else(|lost| {
|
||||
self.roundtrip(&line).map_err(|e| if e.kind() == std::io::ErrorKind::NotConnected && lost.kind() != e.kind() { lost } else { e })
|
||||
})
|
||||
}
|
||||
.map_err(|e| self.explain(&e))?;
|
||||
let v = Self::parse(&reply)?;
|
||||
// Timed out in the app, still running: wait for its result on this connection.
|
||||
if v.get("running").and_then(Value::as_bool) == Some(true) {
|
||||
self.outstanding = Some(Outstanding { what: what.clone(), effect: Some(effect), since: sent });
|
||||
(self.progress)(&format!(
|
||||
"{what} is still running in the app after {} s; waiting up to {} s more for it",
|
||||
sent.elapsed().as_secs(),
|
||||
self.waits.pending.as_secs()
|
||||
));
|
||||
let document = self.document;
|
||||
return match self.wait_outstanding(self.waits.pending)? {
|
||||
Waited::Done(reply) => Ok(reply),
|
||||
Waited::Nothing => Err(CallError::Lost(format!(
|
||||
"{what} timed out in the app, which kept no result for it: inspect the {document} before sending it again"
|
||||
))),
|
||||
Waited::Running => Err(self.still_running(false)),
|
||||
};
|
||||
}
|
||||
// Refused unrun behind a request this client did not know of: wait for that one, then
|
||||
// send this one once more.
|
||||
if v.get("busy").and_then(Value::as_bool) == Some(true)
|
||||
&& let Some(params) = again
|
||||
{
|
||||
self.outstanding = Some(Outstanding { what: "an earlier request".into(), effect: None, since: Instant::now() });
|
||||
self.settle_earlier()?;
|
||||
return self.exchange(Request { method, params, what, effect, once }, false);
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// [`Connection::exchange`] (retrying a busy refusal once) and the reply's result.
|
||||
///
|
||||
/// # Errors
|
||||
/// As [`Connection::exchange`] and [`Connection::outcome`].
|
||||
pub fn call(&mut self, request: Request) -> Result<Value, CallError> {
|
||||
let v = self.exchange(request, true)?;
|
||||
self.outcome(&v)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "client_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,190 @@
|
||||
//! The client against the shared server ([`crate::server::Hub`]) on an ephemeral loopback port.
|
||||
|
||||
use super::*;
|
||||
use crate::ControlRequest;
|
||||
use crate::server::{Hub, Protocol, Stop, Summary, Waits as ServerWaits};
|
||||
use std::ffi::OsString;
|
||||
use std::net::TcpListener;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc::channel;
|
||||
|
||||
const TOKEN: &str = "0123456789abcdef0123456789abcdef";
|
||||
|
||||
fn no_dir() -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
fn no_env(_: &str) -> Option<OsString> {
|
||||
None
|
||||
}
|
||||
|
||||
fn utc(secs: u64) -> String {
|
||||
secs.to_string()
|
||||
}
|
||||
|
||||
/// An app with no token files and no environment: a client sends only the token it is given.
|
||||
const APP: App = App { name: "Probe", data_dir: no_dir, env: no_env, utc, default_port: 0 };
|
||||
|
||||
/// An app whose requests all may change its document.
|
||||
struct Probe;
|
||||
|
||||
impl Protocol for Probe {
|
||||
fn summary(&self, method: &str, _: &Value) -> Summary {
|
||||
Summary { what: format!("`{method}`"), effect: Effect::Changes, command: None, steps: None, long: false }
|
||||
}
|
||||
fn document(&self) -> &'static str {
|
||||
"design"
|
||||
}
|
||||
fn change_event(&self) -> &'static str {
|
||||
"design.changed"
|
||||
}
|
||||
fn revision_method(&self) -> &'static str {
|
||||
"document.revision"
|
||||
}
|
||||
fn docs(&self) -> &'static str {
|
||||
"docs/control-protocol.md"
|
||||
}
|
||||
fn capabilities(&self) -> Value {
|
||||
json!({})
|
||||
}
|
||||
}
|
||||
|
||||
/// The app on `port` (0: an ephemeral one) behind [`TOKEN`]: its window answers each request after
|
||||
/// `delay` with the request's method and parameters, and the server says a request is still
|
||||
/// running after `wait`.
|
||||
fn serve(port: u16, delay: Duration, wait: Duration) -> Stop {
|
||||
// A port just closed may take a moment to be free again on a loaded machine.
|
||||
let started = Instant::now();
|
||||
let mut bound = TcpListener::bind(("127.0.0.1", port));
|
||||
while bound.is_err() && started.elapsed() < Duration::from_secs(2) {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
bound = TcpListener::bind(("127.0.0.1", port));
|
||||
}
|
||||
let listener = bound.unwrap();
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
std::thread::spawn(move || {
|
||||
for req in rx {
|
||||
std::thread::sleep(delay);
|
||||
let _ = req.reply.send(json!({"ok": true, "result": {"method": req.method, "params": req.params}}));
|
||||
}
|
||||
});
|
||||
let waits = ServerWaits { short: wait, long: wait, token: Duration::from_secs(5) };
|
||||
let hub = Arc::new(Hub::new(Probe, || {}, 1 << 20).with_waits(waits));
|
||||
hub.listen_stoppable(listener, TOKEN.into(), tx, false).unwrap()
|
||||
}
|
||||
|
||||
fn addr(port: u16) -> String {
|
||||
format!("127.0.0.1:{port}")
|
||||
}
|
||||
|
||||
/// A port nothing listens on.
|
||||
fn free_port() -> u16 {
|
||||
TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_call_is_one_round_trip_with_the_token() {
|
||||
let app = serve(0, Duration::ZERO, Duration::from_secs(10));
|
||||
let mut c = Connection::new(APP, &addr(app.port()), Some(TOKEN));
|
||||
assert!(!c.is_connected());
|
||||
let r = c.call(Request::new("document.inspect", json!({"a": 1}))).unwrap();
|
||||
assert_eq!(r, json!({"method": "document.inspect", "params": {"a": 1}}));
|
||||
assert!(c.is_connected());
|
||||
let serial = c.serial();
|
||||
// the next call goes on the same connection
|
||||
c.call(Request::new("document.inspect", json!({}))).unwrap();
|
||||
assert_eq!(c.serial(), serial);
|
||||
// `try_connect` sees the app there and leaves no connection open
|
||||
let mut fresh = Connection::new(APP, &addr(app.port()), Some(TOKEN));
|
||||
fresh.try_connect().unwrap();
|
||||
assert!(!fresh.is_connected());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wrong_token_or_none_is_refused() {
|
||||
let app = serve(0, Duration::ZERO, Duration::from_secs(10));
|
||||
for token in [Some("fedcba9876543210fedcba9876543210"), None] {
|
||||
let mut c = Connection::new(APP, &addr(app.port()), token);
|
||||
match c.call(Request::new("document.inspect", json!({}))) {
|
||||
Err(CallError::Refused(e)) => assert!(e.starts_with("unauthorized"), "{token:?}: {e}"),
|
||||
other => panic!("{token:?}: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_app_not_running_says_how_to_start_it() {
|
||||
let port = free_port();
|
||||
let mut c = Connection::new(APP, &addr(port), Some(TOKEN));
|
||||
let e = c.call(Request::new("document.inspect", json!({}))).unwrap_err();
|
||||
let CallError::NotRunning(m) = &e else { panic!("{e:?}") };
|
||||
assert!(m.starts_with(&format!("no Probe app is listening at 127.0.0.1:{port}")), "{m}");
|
||||
assert!(m.contains(&format!("`probe --control {port}`")), "{m}");
|
||||
assert_eq!(c.try_connect().unwrap_err().kind(), std::io::ErrorKind::NotConnected);
|
||||
}
|
||||
|
||||
/// An app that goes away during a call is lost, not "not running"; the next call says it is not
|
||||
/// running, and once it runs again the next call reaches it.
|
||||
#[test]
|
||||
fn an_app_gone_during_a_call_is_lost_and_reached_again_once_back() {
|
||||
let app = serve(0, Duration::ZERO, Duration::from_secs(10));
|
||||
let port = app.port();
|
||||
let mut c = Connection::new(APP, &addr(port), Some(TOKEN));
|
||||
c.call(Request::new("document.inspect", json!({}))).unwrap();
|
||||
app.stop();
|
||||
let e = c.call(Request::new("document.inspect", json!({}))).unwrap_err();
|
||||
let CallError::Lost(m) = &e else { panic!("{e:?}") };
|
||||
assert!(m.contains("may have quit") && m.contains("Inspect the design") && m.contains(&format!("`probe --control {port}`")), "{m}");
|
||||
assert!(matches!(c.call(Request::new("document.inspect", json!({}))), Err(CallError::NotRunning(_))));
|
||||
let _back = serve(port, Duration::ZERO, Duration::from_secs(10));
|
||||
assert_eq!(c.call(Request::new("ui.view", json!({}))).unwrap()["method"], json!("ui.view"));
|
||||
}
|
||||
|
||||
/// A server that reads a request and closes the connection without a reply: the call is lost
|
||||
/// (after one more try on a fresh connection), and the reply is never taken for "not running".
|
||||
#[test]
|
||||
fn a_connection_closed_without_a_reply_is_lost() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let mut line = String::new();
|
||||
let _ = BufReader::new(stream).read_line(&mut line);
|
||||
}
|
||||
});
|
||||
let mut c = Connection::new(APP, &addr(port), Some(TOKEN));
|
||||
let e = c.call(Request::new("document.inspect", json!({}))).unwrap_err();
|
||||
assert!(matches!(e, CallError::Lost(_)), "{e:?}");
|
||||
assert!(!c.is_connected());
|
||||
}
|
||||
|
||||
/// A request the app says is still running is waited for through `control.pending`, on the same
|
||||
/// connection, and its own reply is the call's result.
|
||||
#[test]
|
||||
fn a_request_still_running_is_waited_for_through_pending() {
|
||||
let app = serve(0, Duration::from_millis(400), Duration::from_millis(50));
|
||||
let mut c = Connection::new(APP, &addr(app.port()), Some(TOKEN));
|
||||
c.set_waits(Waits { pending: Duration::from_secs(10), settle: Duration::from_secs(10), poll: Duration::from_millis(5) });
|
||||
let r = c.call(Request::new("solid.slow", json!({"n": 2}))).unwrap();
|
||||
assert_eq!(r, json!({"method": "solid.slow", "params": {"n": 2}}));
|
||||
assert!(!c.has_outstanding());
|
||||
assert_eq!(c.pending(), json!({"pending": null}));
|
||||
}
|
||||
|
||||
/// Past the wait, the call says the request is still running, and `pending` later has its end.
|
||||
#[test]
|
||||
fn a_request_still_running_past_the_wait_is_reported_and_its_end_kept() {
|
||||
let app = serve(0, Duration::from_millis(400), Duration::from_millis(20));
|
||||
let mut c = Connection::new(APP, &addr(app.port()), Some(TOKEN));
|
||||
c.set_waits(Waits { pending: Duration::from_millis(30), settle: Duration::from_secs(10), poll: Duration::from_millis(5) });
|
||||
let mut slow = Request::new("solid.slow", json!({}));
|
||||
slow.what = "`solid.slow` (now)".into();
|
||||
let e = c.call(slow).unwrap_err();
|
||||
let CallError::StillRunning(m) = &e else { panic!("{e:?}") };
|
||||
assert!(m.starts_with(STILL_RUNNING) && m.contains("`solid.slow` (now)") && m.contains("will still apply"), "{m}");
|
||||
assert!(c.has_outstanding());
|
||||
let end = c.pending();
|
||||
assert_eq!(end["done"], json!(true), "{end}");
|
||||
assert_eq!(end["reply"]["result"]["method"], json!("solid.slow"), "{end}");
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
//! The control channel any app may share (docs/control-protocol.md): the per-launch token and its
|
||||
//! files ([`auth`]), the loopback server that admits a connection by the token and hands each
|
||||
//! request to the window ([`server`]), the request the window answers ([`ControlRequest`]), the
|
||||
//! document watch subscribed connections wait on ([`DocumentWatch`]), and what a request still running
|
||||
//! does when it finishes ([`Effect`]). It needs no UI toolkit; the window's own requests over egui
|
||||
//! (`ui.screenshot`, `ui.resize`) are `solidcraft-control-egui`.
|
||||
//! request to the window ([`server`]), the client that talks to it ([`client`]), the request the
|
||||
//! window answers ([`ControlRequest`]), the document watch subscribed connections wait on
|
||||
//! ([`DocumentWatch`]), and what a request still running does when it finishes ([`Effect`]). It
|
||||
//! needs no UI toolkit; the window's own requests over egui (`ui.screenshot`, `ui.resize`) are
|
||||
//! `solidcraft-control-egui`.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod auth;
|
||||
mod cancel;
|
||||
pub mod client;
|
||||
mod effect;
|
||||
mod request;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
@@ -123,6 +123,7 @@ fn remap_geo(
|
||||
}
|
||||
GeoRef::Plane { plane } => remap_plane(plane, ids, bodies, depth + 1, occurrences, doc)?,
|
||||
GeoRef::Datum { datum } => remap_datum_path(datum, occurrences, doc)?,
|
||||
GeoRef::Snap { of, .. } => remap_geo(of, ids, bodies, depth + 1, occurrences, doc)?,
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
+118
-2
@@ -59,6 +59,27 @@ pub enum GeoRef {
|
||||
SketchPoint { sketch: u64, point: String },
|
||||
/// A sketch curve (by sketch feature id and curve id).
|
||||
SketchCurve { sketch: u64, curve: String },
|
||||
/// A snap point of an edge, face or sketch curve (#11228): its centre, its middle or a flat
|
||||
/// face's centre, found again on `of` as it is now. Where `of` has more than one (a
|
||||
/// cylinder's two end centres), `end` says which along the axis (0 the low end, 1 the
|
||||
/// high one, set when the reference is made), else the one nearest `at`, where it was picked.
|
||||
Snap {
|
||||
of: Box<GeoRef>,
|
||||
kind: SnapAt,
|
||||
at: Vec3,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
end: Option<usize>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Which snap point of an edge, face or sketch curve a [`GeoRef::Snap`] is.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SnapAt {
|
||||
/// The centre of a circle or arc, a sphere or torus, or one end of a cylinder or cone.
|
||||
Centre,
|
||||
/// The middle of an edge or sketch curve, along its length.
|
||||
Middle,
|
||||
}
|
||||
|
||||
/// How a construction axis is defined.
|
||||
@@ -293,9 +314,27 @@ fn face_of<'a>(st: &'a ModelState, at: Vec3, name: &Option<String>) -> Result<(&
|
||||
return Ok((b, fi, at));
|
||||
}
|
||||
}
|
||||
// A mesh body has no faces to pick: say so, rather than let the pick read as a miss (#11469).
|
||||
if let Some(m) = st.bodies.iter().find(|b| lies_on_mesh(b, at)) {
|
||||
return Err(bad(format!("no face at {at:?}: `{}` is a mesh body, which has no faces; use Convert Mesh to make it a solid first", m.name)));
|
||||
}
|
||||
Err(bad(format!("no face at {at:?}")))
|
||||
}
|
||||
|
||||
/// Does `at` lie on the surface of a mesh body, within the pick tolerance? Only mesh bodies:
|
||||
/// their triangles are the body itself.
|
||||
fn lies_on_mesh(b: &ModelBody, at: Vec3) -> bool {
|
||||
if !b.body.is_mesh() {
|
||||
return false;
|
||||
}
|
||||
let Some((positions, triangles)) = b.body.triangle_mesh() else { return false };
|
||||
let tol = crate::appearance::pick_tolerance(b);
|
||||
triangles.iter().any(|t| {
|
||||
let [a, bb, c] = t.map(|i| positions[i as usize]);
|
||||
crate::appearance::closest_on_triangle(at, a, bb, c).dist(at) <= tol
|
||||
})
|
||||
}
|
||||
|
||||
/// The edge picked, as a polyline.
|
||||
fn edge_points(st: &ModelState, at: Vec3, name: &Option<String>) -> Result<Vec<Vec3>> {
|
||||
edge_pick(st, at, name).map(|(_, _, pts)| pts)
|
||||
@@ -384,7 +423,7 @@ fn named_vertex(st: &ModelState, at: Vec3, names: &[String]) -> Option<Vec3> {
|
||||
if names.len() < 2 || names.len() > MAX_VERTEX_EDGES {
|
||||
return None;
|
||||
}
|
||||
let ends: Vec<[Vec3; 2]> = names
|
||||
let found: Vec<(*const ModelBody, usize, [Vec3; 2])> = names
|
||||
.iter()
|
||||
.map(|n| {
|
||||
// The edge with that name itself, never the one nearest the old pick, and its ends
|
||||
@@ -395,10 +434,16 @@ fn named_vertex(st: &ModelState, at: Vec3, names: &[String]) -> Option<Vec3> {
|
||||
let hit = crate::name_lookup::locate_edge(b, n, at).ok()?;
|
||||
let m = b.mesh();
|
||||
let pts = m.edges.get(hit.edge)?;
|
||||
Some([*pts.first()?, *pts.last()?])
|
||||
Some((std::ptr::from_ref(b), hit.edge, [*pts.first()?, *pts.last()?]))
|
||||
})
|
||||
})
|
||||
.collect::<Option<_>>()?;
|
||||
// Two names of a vertex name two edges: a name the point decides (where the body grew so the
|
||||
// vertex lies inside another edge) can find an edge another name found.
|
||||
if found.iter().enumerate().any(|(i, (b, e, _))| found.iter().take(i).any(|(c, f, _)| std::ptr::eq(*b, *c) && e == f)) {
|
||||
return None;
|
||||
}
|
||||
let ends: Vec<[Vec3; 2]> = found.into_iter().map(|(_, _, ends)| ends).collect();
|
||||
let (first, rest) = ends.split_first()?;
|
||||
let tol = |p: Vec3| 1e-6 * (1.0 + p.len());
|
||||
first
|
||||
@@ -487,6 +532,7 @@ pub(crate) fn point(ctx: &Ctx, g: &GeoRef) -> Result<Vec3> {
|
||||
}
|
||||
// A round edge or face: its centre.
|
||||
GeoRef::Edge { .. } | GeoRef::Face { .. } | GeoRef::SketchCurve { .. } => centre(ctx, g),
|
||||
GeoRef::Snap { of, kind, at, end } => snap_point(ctx.st, of, *kind, *at, *end).or_else(|_| snap_point_ctx(ctx, of, *kind, *at)),
|
||||
GeoRef::Datum { datum } => match resolve_datum_in(ctx.doc, datum, ctx.component)? {
|
||||
ConstructGeom::Point(p) => Ok(p),
|
||||
_ => Err(bad("the datum is not a point (pick the O of a component)")),
|
||||
@@ -495,6 +541,65 @@ pub(crate) fn point(ctx: &Ctx, g: &GeoRef) -> Result<Vec3> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The centres of the circular edges that end a cylinder or cone face, from its B-rep (low, high
|
||||
/// along its axis), if it is one.
|
||||
fn face_axis_ends(st: &ModelState, of: &GeoRef) -> Option<Vec<Vec3>> {
|
||||
let GeoRef::Face { at, name } = of else { return None };
|
||||
let (b, fi, _) = face_of(st, *at, name).ok()?;
|
||||
let (origin, axis) = match kernel::face_surface(&b.body, fi) {
|
||||
Some(FaceSurface::Cylinder { origin, axis, .. }) => (origin, axis),
|
||||
Some(FaceSurface::Cone { apex, axis, .. }) => (apex, axis),
|
||||
_ => return None,
|
||||
};
|
||||
let tol = 1e-6 * (1.0 + b.body.size());
|
||||
let mut ends: Vec<(f64, Vec3)> = kernel::face_edge_indices(&b.body, fi)
|
||||
.into_iter()
|
||||
.filter_map(|e| kernel::edge_circle(&b.body, e))
|
||||
.filter(|c| (c.center - origin).cross(axis).len() <= tol)
|
||||
.map(|c| ((c.center - origin).dot(axis), c.center))
|
||||
.collect();
|
||||
ends.sort_by(|p, q| p.0.total_cmp(&q.0));
|
||||
ends.dedup_by(|p, q| (p.0 - q.0).abs() <= tol);
|
||||
match ends.as_slice() {
|
||||
[] => None,
|
||||
[one] => Some(vec![one.1]),
|
||||
[lo, .., hi] => Some(vec![lo.1, hi.1]),
|
||||
}
|
||||
}
|
||||
|
||||
/// The one of `found` nearest `at`.
|
||||
fn nearest(found: Vec<Vec3>, at: Vec3) -> Option<(usize, Vec3)> {
|
||||
found.into_iter().enumerate().filter(|(_, p)| p.is_finite()).min_by(|(_, p), (_, q)| p.dist(at).total_cmp(&q.dist(at)))
|
||||
}
|
||||
|
||||
/// A snap point found from the model alone: a cylinder's or cone's end centre (the `end` one,
|
||||
/// else the one nearest `at`).
|
||||
fn snap_point(st: &ModelState, of: &GeoRef, kind: SnapAt, at: Vec3, end: Option<usize>) -> Result<Vec3> {
|
||||
match (kind, of) {
|
||||
(SnapAt::Centre, GeoRef::Face { .. }) => {
|
||||
let ends = face_axis_ends(st, of).ok_or_else(|| bad("the face is not a cylinder or cone"))?;
|
||||
match end {
|
||||
Some(i) => ends.get(i).copied(),
|
||||
None => nearest(ends, at).map(|(_, p)| p),
|
||||
}
|
||||
.ok_or_else(|| bad("the snap point is gone"))
|
||||
}
|
||||
_ => Err(bad("not a face's snap point")),
|
||||
}
|
||||
}
|
||||
|
||||
/// A snap point of an edge or sketch curve (its middle or centre), or a sphere's or torus's centre.
|
||||
fn snap_point_ctx(ctx: &Ctx, of: &GeoRef, kind: SnapAt, at: Vec3) -> Result<Vec3> {
|
||||
let found: Vec<Vec3> = match (kind, of) {
|
||||
(SnapAt::Middle, GeoRef::Edge { .. } | GeoRef::SketchCurve { .. }) => {
|
||||
vec![along(ctx, of, 0.5)?.0]
|
||||
}
|
||||
(SnapAt::Centre, GeoRef::Edge { .. } | GeoRef::SketchCurve { .. } | GeoRef::Face { .. }) => vec![centre(ctx, of)?],
|
||||
_ => return Err(bad("a snap point is on an edge, a face or a sketch curve")),
|
||||
};
|
||||
nearest(found, at).map(|(_, p)| p).ok_or_else(|| bad("the snap point is gone"))
|
||||
}
|
||||
|
||||
/// Resolve a planar point or a drawn 3D wire's `wire:index` node in the evaluated state's frame.
|
||||
/// Wire coordinates have already been placed; applying the sketch plane again would move them twice.
|
||||
pub fn sketch_point_position(st: &ModelState, sketch: u64, point: &str) -> Option<Vec3> {
|
||||
@@ -833,6 +938,7 @@ pub(crate) fn source_bodies(ctx: &Ctx, refs: &[&GeoRef]) -> Vec<String> {
|
||||
GeoRef::Vertex { .. } => {
|
||||
point(ctx, g).ok().and_then(|p| vertex_body(ctx.st, p)).map(|b| vec![b.definition_name().to_string()]).unwrap_or_default()
|
||||
}
|
||||
GeoRef::Snap { of, .. } => source_bodies(ctx, &[of]),
|
||||
GeoRef::Axis { name } | GeoRef::ConstructPoint { name } => {
|
||||
ctx.st.construct.iter().find(|c| c.name.as_str() == AsRef::<str>::as_ref(name)).map(|c| c.bodies.clone()).unwrap_or_default()
|
||||
}
|
||||
@@ -1041,6 +1147,10 @@ impl GeoRef {
|
||||
GeoRef::Point { p } => pt(p),
|
||||
GeoRef::Vertex { at, .. } | GeoRef::Edge { at, .. } | GeoRef::Face { at, .. } => pt(at),
|
||||
GeoRef::Plane { plane: p } => plane(p),
|
||||
GeoRef::Snap { of, at, .. } => {
|
||||
pt(at);
|
||||
of.map_points(pt, plane);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -1080,6 +1190,12 @@ impl GeoRef {
|
||||
}
|
||||
}
|
||||
GeoRef::Plane { plane } => plane.name_picks(st),
|
||||
GeoRef::Snap { of, kind: SnapAt::Centre, at, end: end @ None } => {
|
||||
// Which end of a cylinder or cone: it stays that end when the face grows.
|
||||
*end = face_axis_ends(st, of).and_then(|ends| nearest(ends, *at)).map(|(i, _)| i);
|
||||
of.name_picks(st);
|
||||
}
|
||||
GeoRef::Snap { of, .. } => of.name_picks(st),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1197,6 +1197,10 @@ pub enum FeatureKind {
|
||||
tool: Option<FaceAt>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
sketch: Option<SketchId>,
|
||||
/// With `sketch`: its curves joined end to end first, so a closed loop cuts along itself
|
||||
/// only. Off (each curve on its own, an open one extended past its ends) in older designs.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
loops: bool,
|
||||
},
|
||||
/// Scale bodies about a point (uniform, or per axis).
|
||||
Scale {
|
||||
@@ -1436,7 +1440,7 @@ pub enum FeatureKind {
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
flip: bool,
|
||||
},
|
||||
/// Sheet metal contour flange: a sheet bent along an open chain of sketch lines.
|
||||
/// Sheet metal contour flange: a sheet bent along an open chain of sketch lines and tangent arcs.
|
||||
SheetContour {
|
||||
sketch: SketchId,
|
||||
curves: Vec<String>,
|
||||
|
||||
+13
-7
@@ -9715,7 +9715,7 @@ fn eval_feature(
|
||||
}
|
||||
apply_traced(doc, st, f, made, *operation, &[])
|
||||
}
|
||||
FeatureKind::SplitFace { faces, body, plane, tool, sketch } => {
|
||||
FeatureKind::SplitFace { faces, body, plane, tool, sketch, loops } => {
|
||||
let i = body_at(st, body, &crate::plain(faces))?;
|
||||
let Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
|
||||
let mut picked: Vec<usize> = Vec::new();
|
||||
@@ -9735,12 +9735,18 @@ fn eval_feature(
|
||||
}
|
||||
(None, Some(sid)) => {
|
||||
let ss = st.sketch(sid.0).ok_or_else(|| DocError::Unknown(format!("sketch {sid}")))?;
|
||||
// Each curve's exact segments (lines and arcs split by their own plane and cylinder).
|
||||
let curves: Vec<Vec<solidcore_record::Seg2>> = (0..ss.sketch.curves.len())
|
||||
.filter(|ci| ss.sketch.curves.get(*ci).is_some_and(|c| !c.construction))
|
||||
.map(|ci| ss.sketch.segs(ci))
|
||||
.filter(|segs| !segs.is_empty())
|
||||
.collect();
|
||||
// Each curve's exact segments (lines and arcs split by their own plane and cylinder),
|
||||
// or with `loops` the curves joined end to end: a closed chain is not extended.
|
||||
let curves: Vec<Vec<solidcore_record::Seg2>> = if *loops {
|
||||
let ids: Vec<String> = ss.sketch.curves.iter().filter(|c| !c.construction).map(|c| c.id.clone()).collect();
|
||||
surface::curve_chains(ss, &ids)?
|
||||
} else {
|
||||
(0..ss.sketch.curves.len())
|
||||
.filter(|ci| ss.sketch.curves.get(*ci).is_some_and(|c| !c.construction))
|
||||
.map(|ci| ss.sketch.segs(ci))
|
||||
.filter(|segs| !segs.is_empty())
|
||||
.collect()
|
||||
};
|
||||
if curves.is_empty() {
|
||||
return Err(DocError::Invalid(format!("sketch `{}` has no curves to split with", ss.name)));
|
||||
}
|
||||
|
||||
@@ -295,6 +295,33 @@ fn chain_points(ss: &SolvedSketch, ids: &[String]) -> Result<Vec<Vec2>> {
|
||||
Ok(pts)
|
||||
}
|
||||
|
||||
/// The curves of a chain as connected segments in sketch coordinates, in order, with lines
|
||||
/// and arcs kept exact (see [`chain_points`] for the polyline form).
|
||||
fn chain_segs(ss: &SolvedSketch, ids: &[String]) -> Result<Vec<Seg2>> {
|
||||
let mut segs: Vec<Seg2> = Vec::new();
|
||||
for id in ids {
|
||||
let ci = ss.sketch.curve_index(id).ok_or_else(|| DocError::Unknown(format!("curve `{id}`")))?;
|
||||
segs.extend(ss.sketch.segs(ci));
|
||||
}
|
||||
let Some(first) = segs.first().copied() else { return Err(DocError::Invalid("no curves".into())) };
|
||||
// Orient the first segment toward the second, then chain.
|
||||
let mut chain = vec![first];
|
||||
if let Some(n) = segs.get(1)
|
||||
&& (first.start().dist(n.start()) < 1e-6 || first.start().dist(n.end()) < 1e-6)
|
||||
{
|
||||
chain[0] = first.reversed();
|
||||
}
|
||||
for s in segs.iter().skip(1) {
|
||||
let prev = chain.last().map(Seg2::end).unwrap_or_default();
|
||||
let s = if s.end().dist(prev) < s.start().dist(prev) { s.reversed() } else { *s };
|
||||
if s.start().dist(prev) > 1e-5 {
|
||||
return Err(DocError::Invalid("the contour curves are not connected end to end".into()));
|
||||
}
|
||||
chain.push(s);
|
||||
}
|
||||
Ok(chain)
|
||||
}
|
||||
|
||||
/// A rib wall: the band swept from the curves across the plane by `depth` (or to the body),
|
||||
/// `t` thick about the sketch plane.
|
||||
fn rib_tool(st: &ModelState, ss: &SolvedSketch, ids: &[String], t: f64, depth: Option<f64>, flip: bool) -> Result<Body> {
|
||||
@@ -587,13 +614,7 @@ pub(super) fn sheet_eval(
|
||||
}
|
||||
FeatureKind::SheetContour { sketch, curves, distance, rule, flip, reverse } => {
|
||||
let ss = st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch}")))?.clone();
|
||||
for id in curves {
|
||||
let ci = ss.sketch.curve_index(id).ok_or_else(|| DocError::Unknown(format!("curve `{id}`")))?;
|
||||
if !ss.sketch.segs(ci).iter().all(|s| matches!(s, Seg2::Line { .. })) {
|
||||
return Err(DocError::Invalid("not supported yet: arcs in a contour flange (use lines; the bends are made for you)".into()));
|
||||
}
|
||||
}
|
||||
let pts = chain_points(&ss, curves)?;
|
||||
let segs = chain_segs(&ss, curves)?;
|
||||
let w = val(vals, distance, Kind::Length)?;
|
||||
if !(w > 0.0) {
|
||||
return Err(DocError::Invalid("the contour flange distance must be positive".into()));
|
||||
@@ -601,10 +622,21 @@ pub(super) fn sheet_eval(
|
||||
let r = doc.sheet_rule(rule.as_deref());
|
||||
let rv = doc.rule_values(vals, &r)?;
|
||||
let plane = if *reverse { Plane { origin: ss.plane.origin, x: ss.plane.x, y: ss.plane.y * -1.0 } } else { ss.plane };
|
||||
let pts: Vec<Vec2> = if *reverse { pts.iter().map(|p| Vec2::new(p.x, -p.y)).collect() } else { pts };
|
||||
let segs: Vec<Seg2> = if *reverse {
|
||||
segs.iter()
|
||||
.map(|s| match *s {
|
||||
Seg2::Arc { center, radius, start, sweep } => {
|
||||
Seg2::Arc { center: Vec2::new(center.x, -center.y), radius, start: -start, sweep: -sweep }
|
||||
}
|
||||
other => other.map_controls(|p| Vec2::new(p.x, -p.y)),
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
segs
|
||||
};
|
||||
// Mirroring the sketch swaps the material side too.
|
||||
let name = new_name(st, f, 0);
|
||||
let sh = sheet::contour_flange(&name, &r.name, &rv, &plane, &pts, w, *flip != *reverse)?;
|
||||
let sh = sheet::contour_flange_path(&name, &r.name, &rv, &plane, &segs, w, *flip != *reverse)?;
|
||||
let solid = sh.solid()?;
|
||||
st.bodies.push(ModelBody::new(name, solid, f.id));
|
||||
st.sheets.push(sh);
|
||||
|
||||
@@ -824,7 +824,7 @@ const BLEND_STATIONS: usize = 41;
|
||||
/// The sketch curves `ids` (free-form ones exactly where they can be) grouped into chains of segments that meet end to end, each oriented
|
||||
/// to run one way. Curves are joined greedily at their free ends: where three meet, one of them
|
||||
/// starts a chain of its own.
|
||||
fn curve_chains(ss: &SolvedSketch, ids: &[String]) -> Result<Vec<Vec<Seg2>>> {
|
||||
pub(super) fn curve_chains(ss: &SolvedSketch, ids: &[String]) -> Result<Vec<Vec<Seg2>>> {
|
||||
if ids.len() > MAX_CHAIN_CURVES {
|
||||
return Err(DocError::Invalid(format!("at most {MAX_CHAIN_CURVES} curves")));
|
||||
}
|
||||
|
||||
@@ -631,11 +631,12 @@ pub fn is_literal(s: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Is the expression a bare number without a unit, like `20` or `-1.5`? A leading `=` only marks
|
||||
/// Is the expression a bare number without a unit, like `20`, `-1.5` or `2,5`? A leading `=` only marks
|
||||
/// a formula (#4681), so `=20` is the same bare number.
|
||||
pub fn is_bare_number(s: &str) -> bool {
|
||||
let number = s.trim().strip_prefix('=').unwrap_or(s);
|
||||
is_literal(s) && number.trim().trim_start_matches(['-', '+']).trim().parse::<f64>().is_ok()
|
||||
// One number token, as the lexer reads it, so a decimal comma (`2,5`) and a mixed number
|
||||
// (`2 1/8`) count like `2.5` and `2.125`.
|
||||
is_literal(s) && lex(s).is_ok_and(|toks| matches!(toks.as_slice(), [Tok::Num(_)] | [Tok::Op('-' | '+'), Tok::Num(_)]))
|
||||
}
|
||||
|
||||
/// Is `s` shaped like a parameter name: a letter or `_`, then letters, digits and `_`? The shape
|
||||
|
||||
@@ -263,6 +263,7 @@ impl From<solidcraft_format::FormatError> for DocError {
|
||||
fn from(e: solidcraft_format::FormatError) -> Self {
|
||||
match e {
|
||||
solidcraft_format::FormatError::Invalid(why) => DocError::Invalid(why),
|
||||
solidcraft_format::FormatError::Cancelled => DocError::Invalid("cancelled".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -693,6 +694,7 @@ pub(crate) fn decode(mut v: Value, version: u32, applied: Option<&[String]>) ->
|
||||
sketch.validate_patterns().map_err(|e| DocError::Invalid(format!("damaged design: {e}")))?;
|
||||
sketch.validate_periodic_splines().map_err(|e| DocError::Invalid(format!("damaged design: {e}")))?;
|
||||
sketch.validate_spline_knots().map_err(|e| DocError::Invalid(format!("damaged design: {e}")))?;
|
||||
sketch.validate_spline_handles().map_err(|e| DocError::Invalid(format!("damaged design: {e}")))?;
|
||||
}
|
||||
if let crate::FeatureKind::BodyTransfer { from, to, .. } = &feature.kind
|
||||
&& [from, to].into_iter().any(|id| !component_ids.contains(id))
|
||||
@@ -749,6 +751,23 @@ fn validate_sketch_sizes(value: &Value) -> Result<()> {
|
||||
{
|
||||
return Err(invalid());
|
||||
}
|
||||
if let Some(curves) = sketch.get("curves").and_then(Value::as_array) {
|
||||
let mut point_references = 0usize;
|
||||
for curve in curves {
|
||||
for key in ["pts", "handles"] {
|
||||
if let Some(points) = curve.get(key) {
|
||||
let Some(points) = points.as_array() else { return Err(invalid()) };
|
||||
point_references = point_references.saturating_add(points.len());
|
||||
if points.len() > solidcraft_sketch::MAX_SPLINE_POINTS || point_references > 1_000_000 {
|
||||
return Err(invalid());
|
||||
}
|
||||
}
|
||||
}
|
||||
if curve.get("spline_handle_of").and_then(Value::as_str).is_some_and(|id| id.len() > 512) {
|
||||
return Err(invalid());
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(patterns) = sketch.get("patterns").and_then(Value::as_array) {
|
||||
let mut points = 0usize;
|
||||
let mut curves = 0usize;
|
||||
@@ -1984,7 +2003,7 @@ mod tests {
|
||||
let first = pts[0];
|
||||
let neighbor = pts[1];
|
||||
let closing = pts[3];
|
||||
sketch.add_curve(CurveKind::Spline { pts, control: false, degree: 3, periodic: true, nurbs: None }, None).unwrap();
|
||||
sketch.add_curve(CurveKind::Spline { pts, control: false, degree: 3, periodic: true, nurbs: None, handles: Vec::new() }, None).unwrap();
|
||||
let mut document = Document::new("periodic file validation");
|
||||
document.add_feature(crate::FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch }, None).unwrap();
|
||||
assert_eq!(read(&document.to_json()).unwrap(), document);
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
//! A body's face may save `surface` as a `blend` {definition}: a round by its definition (a
|
||||
//! procedural blend's, or a round of an exact class's whose net has no analytic label), always
|
||||
//! with its `nurbs`, the net. An older design has these faces as plain NURBS faces and keeps them
|
||||
//! so. The last release does not read a model cache with this kind (a cache miss: the design is
|
||||
//! evaluated again) and warns about the newer format number.
|
||||
@@ -0,0 +1,6 @@
|
||||
//! A construction point, axis or plane can be built from a snap point (#11228): a reference of
|
||||
//! type `snap` saves the edge, face or sketch curve it is on (`of`), which point it is (`kind`:
|
||||
//! `centre` or `middle`) and where it was picked (`at`), so it follows that edge
|
||||
//! or face when the model changes. An older design has no such reference and opens as before.
|
||||
//! The last release refuses a design that has one (it asks for an update) instead of failing to
|
||||
//! read the reference.
|
||||
@@ -0,0 +1,34 @@
|
||||
//! A fit point spline whose tangent handles were dragged saves the handle points and their
|
||||
//! construction lines. A spline stores them only once one is dragged: older designs open
|
||||
//! unchanged, their splines without handles. The last release warns about the newer format
|
||||
//! number and opens the spline from its fit points.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::{Value, json};
|
||||
use solidcore_record::Vec2;
|
||||
use solidcraft_sketch::{CurveKind, Sketch};
|
||||
|
||||
#[test]
|
||||
fn older_fit_splines_open_without_handles() {
|
||||
let mut sketch = Sketch::new();
|
||||
let points = [Vec2::new(0.0, 0.0), Vec2::new(12.0, 5.0), Vec2::new(24.0, 1.0), Vec2::new(37.0, 9.0)]
|
||||
.into_iter()
|
||||
.map(|point| sketch.add_point(point, None).unwrap())
|
||||
.collect::<Vec<_>>();
|
||||
sketch
|
||||
.add_curve(CurveKind::Spline { pts: points, control: false, degree: 3, periodic: false, nurbs: None, handles: Vec::new() }, None)
|
||||
.unwrap();
|
||||
let mut doc = crate::Document::new("Spline handles");
|
||||
doc.add_feature(crate::FeatureKind::Sketch { plane: crate::PlaneRef::Origin { name: "XY".into() }, sketch: sketch.clone() }, None)
|
||||
.unwrap();
|
||||
let mut old: Value = serde_json::from_str(&doc.to_json()).unwrap();
|
||||
old["format"] = json!(crate::format::format_string(crate::format::FORMAT - 1));
|
||||
old.as_object_mut().unwrap().remove("format_pending");
|
||||
|
||||
let opened = crate::format::read(&old.to_string()).unwrap();
|
||||
let crate::FeatureKind::Sketch { sketch: opened, .. } = &opened.features[0].kind else { panic!("expected sketch feature") };
|
||||
assert_eq!(opened.curves, sketch.curves, "the splines open as they were saved");
|
||||
assert!(opened.curves.iter().all(|c| c.spline_handle_of.is_none()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
//! A Split Face by sketch curves may save `loops: true`: the sketch's curves are joined end to end
|
||||
//! before the split, so a closed loop of lines and arcs cuts the face along the loop only. An older
|
||||
//! design has none and splits by each curve on its own, an open one extended past its ends, as
|
||||
//! before. The last release warns about the newer format number and opens the design, splitting
|
||||
//! that feature curve by curve (its sides extended across the face).
|
||||
@@ -135,7 +135,12 @@ pub(super) fn edge_frame(doc: &Document, st: &ModelState, comp: u64, snap: &Snap
|
||||
frame(mid, z, Some(y.cross(z))).ok_or_else(bad)
|
||||
}
|
||||
Some(other) => {
|
||||
let j = crate::name_lookup::locate_edge(b, other, pick).map_err(|_| gone(&format!("the edge `{other}`")))?.edge;
|
||||
// The pick lies on edge `i`, not on this one: a name the pick would decide finds `i`.
|
||||
let j = crate::name_lookup::locate_edge(b, other, pick)
|
||||
.ok()
|
||||
.map(|hit| hit.edge)
|
||||
.filter(|j| *j != i)
|
||||
.ok_or_else(|| gone(&format!("the edge `{other}`")))?;
|
||||
let f = m.edges.get(j).ok_or_else(bad)?;
|
||||
let t = tol(b);
|
||||
let at = meeting(e, f, t).ok_or_else(|| DocError::Invalid(format!("joint origin: its edges no longer meet at a corner (`{other}`)")))?;
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
//! face, when only a face of the same stem is left, when the name is on several bodies, or when
|
||||
//! the name was saved before format 14 ([`LEGACY_MARK`]), the name finds nothing and the feature
|
||||
//! that uses it fails, asking for the reference to be picked again
|
||||
//! ([`crate::DocError::Reselect`]). A face whose name was worked out from geometry, found where
|
||||
//! ([`crate::DocError::Reselect`]). An edge name has one more way: when no edge's faces answer
|
||||
//! to it, and one of its faces is gone from a feature still on the body, the reference's point
|
||||
//! decides (see [`renamed_edge`]). A face whose name was worked out from geometry, found where
|
||||
//! the reference's point no longer lies, is reported: only geometry decided.
|
||||
//! Saved numbered Project links also have a compatibility check for a whole face that
|
||||
//! contains the saved pick and comes directly from its original construction feature.
|
||||
@@ -52,9 +54,11 @@ pub struct EdgeHit {
|
||||
/// The edge of a body a stored edge name finds: the edge with that name; else the one edge
|
||||
/// between the faces it names exactly (see [`exactly`]). A numbered name (`~k`) was one of
|
||||
/// several edges between its faces, told apart only by where they lay, so the one edge left
|
||||
/// between them is it only where the reference's point still lies on it. `Err` explains when
|
||||
/// there is none: no such edge, the named edge split into several, one of several edges whose
|
||||
/// faces now bound one edge elsewhere, or a name saved before format 14.
|
||||
/// between them is it only where the reference's point still lies on it. A name whose faces no
|
||||
/// edge's faces answer to at all, one of them gone from a feature that is still there, is the edge
|
||||
/// of the other face the reference's point lies on, clear of its ends (see [`renamed_edge`]).
|
||||
/// `Err` explains when there is none: no such edge, the named edge split into several, one of
|
||||
/// several edges whose faces now bound one edge elsewhere, or a name saved before format 14.
|
||||
pub fn locate_edge(b: &ModelBody, name: &str, near: Vec3) -> Result<EdgeHit, String> {
|
||||
if legacy_name(name).is_some() {
|
||||
return Err(format!("edge `{name}` was named by a build before format 14"));
|
||||
@@ -77,13 +81,22 @@ pub fn locate_edge(b: &ModelBody, name: &str, near: Vec3) -> Result<EdgeHit, Str
|
||||
}
|
||||
// By the faces it separates: the best way any edge matches.
|
||||
let wanted = strip_edge_number(name);
|
||||
let mut best: Option<(Match, Vec<usize>)> = None;
|
||||
let (mut best, mut loose): (Option<(Match, Vec<usize>)>, bool) = (None, false);
|
||||
for (i, fs) in m.edge_faces.iter().enumerate() {
|
||||
let [fa, fb] = fs.as_slice() else { continue };
|
||||
let Some(level) = edge_match(&faces, *fa as usize, *fb as usize, wanted).filter(|level| exactly(*level)) else { continue };
|
||||
keep_best(&mut best, level, i);
|
||||
let Some(level) = edge_match(&faces, *fa as usize, *fb as usize, wanted) else { continue };
|
||||
if exactly(level) {
|
||||
keep_best(&mut best, level, i);
|
||||
} else {
|
||||
loose = true;
|
||||
}
|
||||
}
|
||||
let (_, cands) = best.ok_or_else(|| format!("edge `{name}` no longer exists"))?;
|
||||
let Some((_, cands)) = best else {
|
||||
let picked = if loose { None } else { renamed_edge(b, &m, &faces, wanted, near) };
|
||||
return picked
|
||||
.map(|i| EdgeHit { edge: i, point: near, verified: false, on_point: true })
|
||||
.ok_or_else(|| format!("edge `{name}` no longer exists"));
|
||||
};
|
||||
// When the faces of several numbered edges bound one edge now, every one of their names
|
||||
// finds the faces of that edge: taking it for each would put them all on one edge (#13332).
|
||||
let numbered = wanted.len() != name.len();
|
||||
@@ -144,10 +157,82 @@ fn exactly(level: Match) -> bool {
|
||||
matches!(level, Match::Exact | Match::Piece)
|
||||
}
|
||||
|
||||
/// How far from an edge a picked point may lie and still be on it.
|
||||
fn pick_reach(b: &ModelBody) -> f64 {
|
||||
(b.body.size() * 2e-3).max(1e-3)
|
||||
}
|
||||
|
||||
/// Whether the picked point `near` lies on edge `i` of body `b` (whose mesh is `m`): then a
|
||||
/// reference keeps it.
|
||||
fn edge_near(b: &ModelBody, m: &Mesh, i: usize, near: Vec3) -> bool {
|
||||
m.edges.get(i).is_some_and(|e| e.windows(2).any(|w| near.dist_to_segment(w[0], w[1]) < (b.body.size() * 2e-3).max(1e-3)))
|
||||
m.edges.get(i).is_some_and(|e| e.windows(2).any(|w| near.dist_to_segment(w[0], w[1]) < pick_reach(b)))
|
||||
}
|
||||
|
||||
/// The edge of body `b` whose exact curve passes through the picked point `near`, by its index
|
||||
/// in the body's mesh `m` (the mesh's edges are the B-rep's, in its order: the edge's faces are
|
||||
/// checked too). A point at an end of the edge is on the edges that meet there as well, so it
|
||||
/// names none. `None` too for a mesh body, a point that is not finite, or a point a pick's reach
|
||||
/// from every edge but a periodic face's seam or a pole.
|
||||
fn edge_through(b: &ModelBody, m: &Mesh, near: Vec3) -> Option<usize> {
|
||||
let reach = pick_reach(b);
|
||||
let (d, i) = solidcraft_kernel::comb_edge_near(&b.body, near).ok().flatten()?;
|
||||
let record = b.body.record();
|
||||
let edge = record.edges.get(i)?;
|
||||
let sorted = |mut fs: Vec<u32>| {
|
||||
fs.sort_unstable();
|
||||
fs.dedup();
|
||||
fs
|
||||
};
|
||||
let same_faces = sorted(edge.uses.iter().map(|u| u.face).collect()) == sorted(m.edge_faces.get(i)?.clone());
|
||||
let clear_of_ends = edge
|
||||
.vertices
|
||||
.iter()
|
||||
.all(|v| record.vertices.get(*v as usize).is_some_and(|v| Vec3::new(v.point[0], v.point[1], v.point[2]).dist(near) > reach));
|
||||
(d <= reach && same_faces && clear_of_ends).then_some(i)
|
||||
}
|
||||
|
||||
/// The edge a stored edge name `wanted` (without its number) meant when no edge's faces answer to
|
||||
/// it at all: one of its two faces is still found exactly, the other finds no face although the
|
||||
/// feature that named it still made faces of body `b`, and the reference's point `near` lies on an
|
||||
/// edge of the face still found, clear of its ends (see [`edge_through`]). Then the point says
|
||||
/// which edge this is: that feature may have named the face differently once (a design saved when
|
||||
/// a round wall took the name of a later join's face), and the face may also be gone while the
|
||||
/// feature stays (a shorter tab); either way the edge the pick lies on is taken. Not where that
|
||||
/// edge's other face is one the same feature made: its face there was replaced (a sketch line
|
||||
/// swapped for another), and the reference asks again. A face whose feature made nothing on the
|
||||
/// body was removed with it, and so is the name. The point must be the
|
||||
/// reference's own pick: a caller that passes a point on another edge cannot take what this finds.
|
||||
fn renamed_edge(b: &ModelBody, m: &Mesh, faces: &Names, wanted: &str, near: Vec3) -> Option<usize> {
|
||||
let count = faces.faces.len();
|
||||
let found = |x: &str| {
|
||||
let mut hits = (0..count).filter(|f| crate::history_names::matches(faces, *f, x).is_some_and(exactly));
|
||||
hits.next().filter(|_| hits.next().is_none())
|
||||
};
|
||||
let lost = |y: &str| {
|
||||
crate::persistent_name::made_by(y)
|
||||
.filter(|id| faces.faces.iter().any(|f| crate::persistent_name::made_by(f) == Some(*id)))
|
||||
.filter(|_| (0..count).all(|f| crate::history_names::matches(faces, f, y).is_none()))
|
||||
};
|
||||
// Every way the name splits into two face names (a face name may hold `|` itself); they must
|
||||
// agree on the face still found.
|
||||
let mut kept: Vec<(usize, u64)> = Vec::new();
|
||||
for (i, _) in wanted.match_indices('|') {
|
||||
let (Some(x), Some(y)) = (wanted.get(..i), wanted.get(i + 1..)) else { continue };
|
||||
for (x, y) in [(x, y), (y, x)] {
|
||||
if let Some(hit) = found(x).filter(|f| kept.iter().all(|(k, _)| k != f)).zip(lost(y)) {
|
||||
kept.push(hit);
|
||||
}
|
||||
}
|
||||
}
|
||||
let &[(face, lost_by)] = kept.as_slice() else { return None };
|
||||
let edge = edge_through(b, m, near)?;
|
||||
let uses = &b.body.record().edges.get(edge)?.uses;
|
||||
// The edge's other face is not one of the lost face's feature: where that feature made the
|
||||
// face at the pick, its face was replaced there (a sketch line swapped for another), not renamed.
|
||||
let made_by = |f: usize| faces.faces.get(f).and_then(|name| crate::persistent_name::made_by(name));
|
||||
let bounds_face = uses.iter().any(|u| u.face as usize == face);
|
||||
let replaced = uses.iter().any(|u| u.face as usize != face && made_by(u.face as usize) == Some(lost_by));
|
||||
(bounds_face && !replaced).then_some(edge)
|
||||
}
|
||||
|
||||
/// The point of face `face` of a mesh nearest `p`, and how far it is.
|
||||
|
||||
@@ -1295,6 +1295,25 @@ mod design_length_tests {
|
||||
assert!((inches - 127.0).abs() < 1e-9, "{inches}");
|
||||
}
|
||||
|
||||
/// A bare number written with a decimal comma (`2,5`) or as a mixed number (`2 1/8`) means the
|
||||
/// design's units like `2.5` does, not millimetres.
|
||||
#[test]
|
||||
fn a_decimal_comma_or_mixed_number_gets_the_design_unit() {
|
||||
let mut doc = Document::new("Units");
|
||||
doc.units = "in".into();
|
||||
let length = crate::expr::Kind::Length;
|
||||
assert_eq!(doc.with_design_unit("2,5", length), "2,5 in");
|
||||
assert_eq!(doc.with_design_unit("-,5", length), "-,5 in");
|
||||
assert_eq!(doc.with_design_unit("=2 1/8", length), "=2 1/8 in");
|
||||
assert_eq!(doc.with_design_unit("w = 2,5", length), "w = 2,5 in");
|
||||
let inches = doc.eval(&doc.with_design_unit("2 1/8", length), length).unwrap();
|
||||
assert!((inches - 2.125 * 25.4).abs() < 1e-9, "{inches}");
|
||||
// Anything that is not one bare number is left as typed.
|
||||
for e in ["2,5 mm", "2 1/8\"", "(2,5)", "2,5 + 1", "1/2", "d3"] {
|
||||
assert_eq!(doc.with_design_unit(e, length), e, "{e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_written_value_reads_back_within_a_micrometre() {
|
||||
for units in ["mm", "cm", "m", "in", "ft"] {
|
||||
|
||||
@@ -1342,7 +1342,16 @@ fn project_sketch_curve(sk: &Sketch, from: &Plane, ci: usize, to: &Plane) -> Vec
|
||||
(Some(c), Some(m)) => vec![LinkGeom::Ellipse { c: map(c), major: map(m), minor: *r }],
|
||||
_ => Vec::new(),
|
||||
},
|
||||
Some(CurveKind::Spline { pts, control, degree, periodic, nurbs }) if parallel => {
|
||||
Some(CurveKind::Spline { pts, control: false, periodic, nurbs: None, handles, .. }) if parallel && !handles.is_empty() => {
|
||||
let p: Option<Vec<Vec2>> = pts.iter().map(|q| sk.point(*q).map(map)).collect();
|
||||
let tangent_handles: Option<Vec<Vec2>> = handles.iter().map(|q| sk.point(*q).map(map)).collect();
|
||||
let Some((p, tangent_handles)) = p.zip(tangent_handles) else { return Vec::new() };
|
||||
// One curve per span whether or not a handle has been moved, so turning a handle keeps
|
||||
// the projected layout and what is constrained to it (#6502).
|
||||
let Ok(pieces) = solidcraft_sketch::fit_beziers_with_handles(&p, &tangent_handles, *periodic) else { return Vec::new() };
|
||||
pieces.into_iter().map(|piece| LinkGeom::Spline { pts: piece.to_vec(), control: true, degree: 3, periodic: false, nurbs: None }).collect()
|
||||
}
|
||||
Some(CurveKind::Spline { pts, control, degree, periodic, nurbs, handles, .. }) if parallel && handles.is_empty() => {
|
||||
let p: Option<Vec<Vec2>> = pts.iter().map(|q| sk.point(*q).map(map)).collect();
|
||||
p.map(|pts| vec![LinkGeom::Spline { pts, control: *control, degree: *degree, periodic: *periodic, nurbs: nurbs.clone() }])
|
||||
.unwrap_or_default()
|
||||
|
||||
+192
-68
@@ -45,6 +45,11 @@ const TOUCH_AREA: f64 = 1e-6;
|
||||
const FOLD_TABS_REJOIN: &str = "not supported yet: folding tabs that join again beyond the bend";
|
||||
pub const FOLD_FIXED_OFF_SHEET: &str = "the fold's fixed point is not on the sheet";
|
||||
|
||||
/// A bend radius below this (mm), the sheet model's outline tolerance, is a sharp bend. Its inner
|
||||
/// face has collapsed to the bend's inner edge, so the folded solid has only the outer cylindrical
|
||||
/// face there, as a press brake makes it with a sharp punch: no inner cylinder.
|
||||
const SHARP_BEND: f64 = 1e-3;
|
||||
|
||||
/// The most bores one sheet keeps.
|
||||
const MAX_BORES: usize = 1000;
|
||||
|
||||
@@ -2310,6 +2315,13 @@ impl SheetBody {
|
||||
};
|
||||
let mut faces = Vec::new();
|
||||
for (ci, c) in cells.iter().enumerate() {
|
||||
// A sharp bend's inner surface has no area: it is the line where the base's and the
|
||||
// flange's inner faces meet. The outer surface and the zone's end walls close the
|
||||
// solid; the end walls lose their collapsed inner edge below.
|
||||
let inner_sharp = match c.piece {
|
||||
Piece::Zone(i) => self.flanges.get(i).filter(|f| f.radius <= SHARP_BEND).map(|f| if f.angle >= 0.0 { z1 } else { z0 }),
|
||||
Piece::Panel(_) => None,
|
||||
};
|
||||
// Top (z1, outward +z) and bottom (z0, outward −z, loops reversed).
|
||||
let loops = |z: f64, rev: bool| -> Result<Vec<Vec<EdgeSpec>>> {
|
||||
std::iter::once(&c.outer)
|
||||
@@ -2325,6 +2337,9 @@ impl SheetBody {
|
||||
};
|
||||
let centre = c.outer.iter().map(Seg2::start).fold(Vec2::default(), |a, b| a + b) * (1.0 / c.outer.len().max(1) as f64);
|
||||
for (z, rev, sign) in [(z1, false, 1.0), (z0, true, -1.0)] {
|
||||
if inner_sharp == Some(z) {
|
||||
continue;
|
||||
}
|
||||
let surface = match c.piece {
|
||||
Piece::Panel(_) => SurfSpec::Plane,
|
||||
Piece::Zone(i) => {
|
||||
@@ -2381,13 +2396,30 @@ impl SheetBody {
|
||||
(Piece::Zone(_), _) if matches!(bottom, EdgeSpec::Chain { .. }) => SurfSpec::Ruled { a: bottom.clone(), b: top_ab.clone() },
|
||||
_ => SurfSpec::Plane,
|
||||
};
|
||||
let top = rev_edge(top_ab);
|
||||
// A sharp bend's inner edge is a point on the zone's end wall: drop that
|
||||
// collapsed edge and let the wall end at the point (a three-edge loop). The inner
|
||||
// face is z1 when the bend turns up and z0 when it turns down.
|
||||
let collapsed = |e: &EdgeSpec| match e {
|
||||
EdgeSpec::Arc { a, b, .. } | EdgeSpec::Line { a, b } => a.dist(*b) < LINEAR_TOL,
|
||||
EdgeSpec::Chain { .. } => false,
|
||||
};
|
||||
let inner_bottom = inner_sharp == Some(z0) && collapsed(&bottom);
|
||||
let inner_top = inner_sharp == Some(z1) && collapsed(&top_ab);
|
||||
let mut edges = Vec::new();
|
||||
if !inner_bottom {
|
||||
edges.push(bottom);
|
||||
}
|
||||
edges.push(up_b);
|
||||
if !inner_top {
|
||||
edges.push(rev_edge(top_ab));
|
||||
}
|
||||
edges.push(down_a);
|
||||
let mid = e.point_at(0.5);
|
||||
let tdir = e.point_at(0.51) - e.point_at(0.49);
|
||||
let right = Vec2::new(tdir.y, -tdir.x);
|
||||
let pm = self.map3(c.piece, mid, (z0 + z1) / 2.0);
|
||||
let outward = self.map3(c.piece, mid + right * 1e-3, (z0 + z1) / 2.0) - pm;
|
||||
faces.push(FaceSpec { loops: vec![vec![bottom, up_b, top, down_a]], surface, probe: Some((pm, outward)) });
|
||||
faces.push(FaceSpec { loops: vec![edges], surface, probe: Some((pm, outward)) });
|
||||
}
|
||||
}
|
||||
Ok(faces)
|
||||
@@ -3018,7 +3050,10 @@ impl SheetBody {
|
||||
if self.flanges.len().saturating_mul(2).saturating_add(1) > MAX_FLAT_PIECES {
|
||||
return Err(flat_budget_error());
|
||||
}
|
||||
let mut polys: Vec<Vec<Vec2>> = vec![dedup(&flat_polyline(&self.base.outer.ccw())?).into_vec()];
|
||||
let mut polys: Vec<Vec<Vec2>> = Vec::new();
|
||||
if encloses_area(&self.base) {
|
||||
polys.push(dedup(&flat_polyline(&self.base.outer.ccw())?).into_vec());
|
||||
}
|
||||
for f in &self.flanges {
|
||||
polys.push(dedup(&flat_polyline(&self.zone(f).outer.ccw())?).into_vec());
|
||||
if f.leg > 1e-9 {
|
||||
@@ -3351,9 +3386,13 @@ impl SheetBody {
|
||||
Ok((size.x, size.y))
|
||||
}
|
||||
|
||||
/// Panels with their regions and world placements: (flange index or None, region).
|
||||
/// Panels with their regions and world placements: (flange index or None, region). A
|
||||
/// profile that starts with an arc has no base panel.
|
||||
fn panels(&self) -> Vec<(Option<usize>, Region2)> {
|
||||
let mut v = vec![(None, self.base.clone())];
|
||||
let mut v = Vec::new();
|
||||
if encloses_area(&self.base) {
|
||||
v.push((None, self.base.clone()));
|
||||
}
|
||||
for (i, f) in self.flanges.iter().enumerate() {
|
||||
if f.leg > 1e-9 {
|
||||
v.push((Some(i), self.panel(f)));
|
||||
@@ -6590,36 +6629,115 @@ pub fn contour_flange(body: &str, rule: &str, rv: &RuleValues, plane: &Plane, pt
|
||||
if pts.len() < 2 {
|
||||
return Err(DocError::Invalid("a contour flange needs an open chain of lines".into()));
|
||||
}
|
||||
let segs: Vec<Seg2> = pts.windows(2).map(|w| Seg2::Line { a: w[0], b: w[1] }).collect();
|
||||
contour_flange_path(body, rule, rv, plane, &segs, width, flip)
|
||||
}
|
||||
|
||||
/// A sheet from an open chain of lines and circular arcs (contour flange): `segs` in `plane`,
|
||||
/// the material on the right of the travel (or the left when `flip`), `width` along the plane
|
||||
/// normal. A line is a flat panel; an arc is a bend of its own radius. The arc is the profile
|
||||
/// surface: when the bend turns toward the material the sheet is outside that surface, so the
|
||||
/// bend's inner radius is the arc's less the thickness; when it turns away the arc is the inner
|
||||
/// surface and its radius is the bend's. A line-line corner is a sharp corner and gets a bend of
|
||||
/// the rule's radius, as a chain of lines always did.
|
||||
pub fn contour_flange_path(body: &str, rule: &str, rv: &RuleValues, plane: &Plane, segs: &[Seg2], width: f64, flip: bool) -> Result<SheetBody> {
|
||||
if segs.is_empty() {
|
||||
return Err(DocError::Invalid("a contour flange needs an open profile".into()));
|
||||
}
|
||||
if !(width.is_finite() && width > 0.0) {
|
||||
return Err(DocError::Invalid("the contour flange distance must be positive".into()));
|
||||
}
|
||||
let (t, r) = (rv.thickness, rv.bend_radius);
|
||||
let seg_dir = |i: usize| -> Result<(Vec2, f64)> {
|
||||
let (Some(a), Some(b)) = (pts.get(i), pts.get(i + 1)) else { return Err(DocError::Invalid("chain".into())) };
|
||||
let l = a.dist(*b);
|
||||
Ok(((*b - *a).normalized().ok_or_else(|| DocError::Invalid("zero-length line in the contour".into()))?, l))
|
||||
};
|
||||
// Material side in the sketch: right of travel, or left when flipped.
|
||||
let side = if flip { -1.0 } else { 1.0 };
|
||||
// Material side in the sketch: right of travel.
|
||||
let (d0, _) = seg_dir(0)?;
|
||||
let first = segs.first().copied().ok_or_else(|| DocError::Invalid("a contour flange needs an open profile".into()))?;
|
||||
let start = first.start();
|
||||
let d0 = first.start_tangent();
|
||||
let l0 = d0.len();
|
||||
if !(l0.is_finite() && l0 > 1e-12) {
|
||||
return Err(DocError::Invalid("the contour starts with a curve of no direction".into()));
|
||||
}
|
||||
let d0 = d0 / l0;
|
||||
let m0 = Vec2::new(d0.y, -d0.x) * side;
|
||||
let start = pts.first().copied().unwrap_or_default();
|
||||
let (x, z) = (plane.dir_to_world(d0), plane.dir_to_world(m0));
|
||||
let frame = Plane::new(plane.to_world(start), x, z.cross(x)).ok_or_else(|| DocError::Invalid("contour frame".into()))?;
|
||||
// Width along the sketch normal: +y or −y of the frame.
|
||||
let ysign = if frame.y.dot(plane.normal()) >= 0.0 { 1.0 } else { -1.0 };
|
||||
let n = pts.len() - 1;
|
||||
// Turn at each corner: angle and whether it bends toward the material (up).
|
||||
let mut corners = Vec::new();
|
||||
for i in 0..n.saturating_sub(1) {
|
||||
let (da, _) = seg_dir(i)?;
|
||||
let (db, _) = seg_dir(i + 1)?;
|
||||
let cross = da.cross(db) * side;
|
||||
let ang = da.dot(db).clamp(-1.0, 1.0).acos();
|
||||
if ang < 1e-9 {
|
||||
return Err(DocError::Invalid("straight corners in a contour flange".into()));
|
||||
}
|
||||
// Right-hand material: turning right (negative cross) bends toward it.
|
||||
corners.push((ang, cross < 0.0));
|
||||
}
|
||||
let (y0, y1) = if ysign > 0.0 { (0.0, width) } else { (-width, 0.0) };
|
||||
let setback = |ang: f64, up: bool| if up { (r + t) * (ang / 2.0).tan() } else { r * (ang / 2.0).tan() };
|
||||
// The junction after each segment: a sharp line-line corner (its unsigned turn and whether
|
||||
// it bends toward the material), or none where a line meets an arc tangentially.
|
||||
let mut sharp: Vec<Option<(f64, bool)>> = Vec::with_capacity(segs.len().saturating_sub(1));
|
||||
for i in 0..segs.len().saturating_sub(1) {
|
||||
let (Some(a), Some(b)) = (segs.get(i), segs.get(i + 1)) else { continue };
|
||||
if a.end().dist(b.start()) > 1e-5 {
|
||||
return Err(DocError::Invalid("the contour curves are not connected end to end".into()));
|
||||
}
|
||||
if matches!(a, Seg2::Line { .. }) && matches!(b, Seg2::Line { .. }) {
|
||||
let (Some(da), Some(db)) = (a.end_tangent().normalized(), b.start_tangent().normalized()) else {
|
||||
return Err(DocError::Invalid("zero-length line in the contour".into()));
|
||||
};
|
||||
let ang = da.dot(db).clamp(-1.0, 1.0).acos();
|
||||
if ang < 1e-9 {
|
||||
return Err(DocError::Invalid("straight corners in a contour flange".into()));
|
||||
}
|
||||
sharp.push(Some((ang, da.cross(db) * side < 0.0)));
|
||||
} else {
|
||||
let (Some(ta), Some(tb)) = (a.end_tangent().normalized(), b.start_tangent().normalized()) else {
|
||||
return Err(DocError::Invalid("zero-length curve in the contour".into()));
|
||||
};
|
||||
if ta.dot(tb) < 1.0 - 1e-6 {
|
||||
return Err(DocError::Invalid(
|
||||
"the contour arcs must be tangent to the curves beside them; a sharp corner beside an arc is not a bend of that arc's radius"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
sharp.push(None);
|
||||
}
|
||||
}
|
||||
// Panels and bends in path order: every line is a panel, every arc a bend, every sharp
|
||||
// line-line corner a bend of the rule's radius. A panel loses the tangent setback at each
|
||||
// sharp corner it ends on.
|
||||
enum Piece {
|
||||
Panel { len: f64 },
|
||||
Bend { angle: f64, radius: f64 },
|
||||
}
|
||||
let mut pieces: Vec<Piece> = Vec::with_capacity(segs.len().saturating_mul(2));
|
||||
for (i, s) in segs.iter().enumerate() {
|
||||
match *s {
|
||||
Seg2::Line { a, b } => {
|
||||
let l = a.dist(b);
|
||||
if !(l.is_finite() && l > 1e-12) {
|
||||
return Err(DocError::Invalid("zero-length line in the contour".into()));
|
||||
}
|
||||
let before = if i == 0 { 0.0 } else { sharp.get(i - 1).copied().flatten().map(|(ang, up)| setback(ang, up)).unwrap_or(0.0) };
|
||||
let after = sharp.get(i).copied().flatten().map(|(ang, up)| setback(ang, up)).unwrap_or(0.0);
|
||||
let len = l - before - after;
|
||||
if !len.is_finite() || len < -1e-9 {
|
||||
return Err(DocError::Invalid("a contour line is shorter than its bends".into()));
|
||||
}
|
||||
pieces.push(Piece::Panel { len: len.max(0.0) });
|
||||
}
|
||||
Seg2::Arc { center: _, radius, start: _, sweep } => {
|
||||
if !(radius.is_finite() && radius > 0.0) {
|
||||
return Err(DocError::Invalid("a contour arc has no radius".into()));
|
||||
}
|
||||
if !(sweep.is_finite() && sweep.abs() > 1e-9 && sweep.abs() < std::f64::consts::TAU - 1e-9) {
|
||||
return Err(DocError::Invalid("a contour arc must be shorter than a full turn".into()));
|
||||
}
|
||||
let angle = -sweep * side;
|
||||
let up = angle >= 0.0;
|
||||
let inner = if up { radius - t } else { radius };
|
||||
if !(inner.is_finite() && inner > 1e-9) {
|
||||
return Err(DocError::Invalid("a contour arc's radius is no larger than the sheet is thick".into()));
|
||||
}
|
||||
pieces.push(Piece::Bend { angle, radius: inner });
|
||||
}
|
||||
_ => return Err(DocError::Invalid("not supported yet: a contour flange profile is lines and circular arcs".into())),
|
||||
}
|
||||
if let Some((ang, up)) = sharp.get(i).copied().flatten() {
|
||||
pieces.push(Piece::Bend { angle: if up { ang } else { -ang }, radius: r });
|
||||
}
|
||||
}
|
||||
let mut sheet = SheetBody {
|
||||
body: body.into(),
|
||||
rule: rule.into(),
|
||||
@@ -6642,48 +6760,54 @@ pub fn contour_flange(body: &str, rule: &str, rv: &RuleValues, plane: &Plane, pt
|
||||
base_curves: Vec::new(),
|
||||
altered_by: None,
|
||||
};
|
||||
let (y0, y1) = if ysign > 0.0 { (0.0, width) } else { (-width, 0.0) };
|
||||
// First segment: the base panel.
|
||||
let (_, l0) = seg_dir(0)?;
|
||||
let end0 = l0 - corners.first().map(|(a, u)| setback(*a, *u)).unwrap_or(0.0);
|
||||
if end0 <= 0.0 {
|
||||
return Err(DocError::Invalid("a contour line is shorter than its bends".into()));
|
||||
}
|
||||
sheet.base = rect(Vec2::new(0.0, y0), Vec2::new(end0, y0), Vec2::new(end0, y1), Vec2::new(0.0, y1));
|
||||
let mut parent: Option<usize> = None;
|
||||
let mut at = end0;
|
||||
for (i, (ang, up)) in corners.iter().enumerate() {
|
||||
let (_, l) = seg_dir(i + 1)?;
|
||||
let before = setback(*ang, *up);
|
||||
let after = corners.get(i + 1).map(|(a, u)| setback(*a, *u)).unwrap_or(0.0);
|
||||
let leg = l - before - after;
|
||||
if leg < -1e-9 {
|
||||
// The first panel is the base; a profile that starts with an arc has no base panel.
|
||||
let first_is_panel = matches!(pieces.first(), Some(Piece::Panel { .. }));
|
||||
let mut at = 0.0;
|
||||
if let Some(Piece::Panel { len }) = pieces.first() {
|
||||
if *len <= 1e-9 {
|
||||
return Err(DocError::Invalid("a contour line is shorter than its bends".into()));
|
||||
}
|
||||
// The material flips sides relative to the panel when the bend goes down.
|
||||
let f = SheetFlange {
|
||||
parent,
|
||||
p: Vec2::new(at, y0),
|
||||
d: Vec2::new(0.0, 1.0),
|
||||
len: y1 - y0,
|
||||
n: Vec2::new(1.0, 0.0),
|
||||
angle: if *up { *ang } else { -*ang },
|
||||
radius: r,
|
||||
leg: leg.max(0.0),
|
||||
ext: (0.0, 0.0),
|
||||
slope: (0.0, 0.0),
|
||||
tear: (false, false),
|
||||
tear_curved: (false, false),
|
||||
tear_ease: (0.0, 0.0),
|
||||
shape: None,
|
||||
exact: None,
|
||||
zone_shape: None,
|
||||
tag: String::new(),
|
||||
};
|
||||
let ba = sheet.allowance(&f);
|
||||
sheet.flanges.push(f);
|
||||
parent = Some(sheet.flanges.len() - 1);
|
||||
at += ba + leg.max(0.0);
|
||||
sheet.base = rect(Vec2::new(0.0, y0), Vec2::new(*len, y0), Vec2::new(*len, y1), Vec2::new(0.0, y1));
|
||||
at = *len;
|
||||
}
|
||||
let mut parent: Option<usize> = None;
|
||||
let mut pending: Option<usize> = None;
|
||||
for piece in pieces.iter().skip(usize::from(first_is_panel)) {
|
||||
match piece {
|
||||
Piece::Panel { len } => {
|
||||
let Some(i) = pending.take() else {
|
||||
return Err(DocError::Invalid("a contour panel has no bend before it".into()));
|
||||
};
|
||||
let Some(f) = sheet.flanges.get_mut(i) else { return Err(DocError::Invalid("contour bend".into())) };
|
||||
f.leg = *len;
|
||||
at += *len;
|
||||
}
|
||||
Piece::Bend { angle, radius } => {
|
||||
let f = SheetFlange {
|
||||
parent,
|
||||
p: Vec2::new(at, y0),
|
||||
d: Vec2::new(0.0, 1.0),
|
||||
len: y1 - y0,
|
||||
n: Vec2::new(1.0, 0.0),
|
||||
angle: *angle,
|
||||
radius: *radius,
|
||||
leg: 0.0,
|
||||
ext: (0.0, 0.0),
|
||||
slope: (0.0, 0.0),
|
||||
tear: (false, false),
|
||||
tear_curved: (false, false),
|
||||
tear_ease: (0.0, 0.0),
|
||||
shape: None,
|
||||
exact: None,
|
||||
zone_shape: None,
|
||||
tag: String::new(),
|
||||
};
|
||||
at += sheet.allowance(&f);
|
||||
sheet.flanges.push(f);
|
||||
parent = Some(sheet.flanges.len() - 1);
|
||||
pending = parent;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(sheet)
|
||||
}
|
||||
|
||||
+11
-1
@@ -1925,7 +1925,17 @@ fn an_extruded_ellipse_or_spline_is_one_wall_named_by_its_curve() {
|
||||
[(0., 0.), (10., -3.), (20., 4.), (30., 0.)].iter().map(|&(x, y)| sk.add_point(Vec2::new(x, y), None).unwrap()).collect();
|
||||
let (a, b) = (sk.add_point(Vec2::new(30., 20.), None).unwrap(), sk.add_point(Vec2::new(0., 20.), None).unwrap());
|
||||
let curve = sk
|
||||
.add_curve(solidcraft_sketch::CurveKind::Spline { pts: pts.clone(), control: false, degree: 3, periodic: false, nurbs: None }, None)
|
||||
.add_curve(
|
||||
solidcraft_sketch::CurveKind::Spline {
|
||||
pts: pts.clone(),
|
||||
control: false,
|
||||
degree: 3,
|
||||
periodic: false,
|
||||
nurbs: None,
|
||||
handles: Vec::new(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
sk.add_line_pts(pts[3], a, None).unwrap();
|
||||
sk.add_line_pts(a, b, None).unwrap();
|
||||
|
||||
@@ -122,6 +122,27 @@ pub(crate) const ISO_COARSE: &[(f64, f64)] = &[
|
||||
(56.0, 5.5),
|
||||
];
|
||||
|
||||
/// The other ISO 261 coarse sizes (second and third choice), with their ISO 262 coarse pitches:
|
||||
/// known by name (M33 is M33x3.5) and offered in the Thread window, but not chosen for a face
|
||||
/// automatically, which still picks from [`ISO_COARSE`] (#4153, #11901).
|
||||
const ISO_COARSE_MORE: &[(f64, f64)] = &[
|
||||
(1.0, 0.25),
|
||||
(1.1, 0.25),
|
||||
(1.2, 0.25),
|
||||
(1.4, 0.3),
|
||||
(1.8, 0.35),
|
||||
(2.2, 0.45),
|
||||
(3.5, 0.6),
|
||||
(4.5, 0.75),
|
||||
(7.0, 1.0),
|
||||
(33.0, 3.5),
|
||||
(39.0, 4.0),
|
||||
(45.0, 4.5),
|
||||
(52.0, 5.0),
|
||||
(60.0, 5.5),
|
||||
(64.0, 6.0),
|
||||
];
|
||||
|
||||
/// The ISO 2902 diameter-pitch series' middle pitch per diameter, which ISO 2904 (trapezoidal)
|
||||
/// and DIN 513-2 (buttress, from 10 mm) share: (nominal diameter, pitch).
|
||||
const ISO_2902_MEDIUM: &[(f64, f64)] = &[
|
||||
@@ -343,11 +364,15 @@ impl ThreadForm {
|
||||
}
|
||||
}
|
||||
|
||||
/// The standard sizes, (nominal diameter, pitch) in mm; none for ASME buttress (its pitch is
|
||||
/// always given) and the square thread.
|
||||
/// The standard sizes, (nominal diameter, pitch) in mm, by diameter; none for ASME buttress
|
||||
/// (its pitch is always given) and the square thread.
|
||||
pub fn sizes(self) -> Vec<(f64, f64)> {
|
||||
match self {
|
||||
ThreadForm::Metric => ISO_COARSE.to_vec(),
|
||||
ThreadForm::Metric => {
|
||||
let mut all: Vec<(f64, f64)> = ISO_COARSE.iter().chain(ISO_COARSE_MORE).copied().collect();
|
||||
all.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
all
|
||||
}
|
||||
ThreadForm::Trapezoidal => ISO_2902_MEDIUM.to_vec(),
|
||||
ThreadForm::Buttress => ISO_2902_MEDIUM.iter().copied().filter(|(d, _)| *d >= 10.0).collect(),
|
||||
ThreadForm::Acme => ACME_SIZES.iter().map(|(_, d, n)| (d * INCH, INCH / n)).collect(),
|
||||
@@ -553,6 +578,16 @@ impl ThreadForm {
|
||||
}
|
||||
}
|
||||
|
||||
/// The sizes [`fit`](Self::fit) chooses from: [`sizes`](Self::sizes), less the metric sizes
|
||||
/// that are only known by name ([`ISO_COARSE_MORE`]), so a face is sized as it was before
|
||||
/// they were added.
|
||||
fn fit_sizes(self) -> Vec<(f64, f64)> {
|
||||
match self {
|
||||
ThreadForm::Metric => ISO_COARSE.to_vec(),
|
||||
_ => self.sizes(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The standard size a cylinder of this diameter takes: a shaft is the major diameter, a hole
|
||||
/// its nominal diameter, else the drill ([`fit_with`](Self::fit_with)). None when no size is within 15% (or the form has no size table).
|
||||
pub fn fit(self, diameter: f64, internal: bool) -> Option<(f64, f64)> {
|
||||
@@ -564,13 +599,14 @@ impl ThreadForm {
|
||||
/// as a size's nominal diameter (within 1% and 0.03 mm) is that size, a 12 mm hole is M12,
|
||||
/// and only a hole near no nominal diameter is matched by tap drill.
|
||||
pub fn fit_with(self, diameter: f64, internal: bool, old_rule: bool) -> Option<(f64, f64)> {
|
||||
let sizes = self.fit_sizes();
|
||||
if internal && !old_rule {
|
||||
let near = |n: f64| (n - diameter).abs() <= 0.01 * diameter + 0.03;
|
||||
if let Some((n, p)) = self.sizes().into_iter().find(|(n, _)| near(*n)) {
|
||||
if let Some((n, p)) = sizes.iter().copied().find(|(n, _)| near(*n)) {
|
||||
return Some((n, p));
|
||||
}
|
||||
}
|
||||
self.sizes()
|
||||
sizes
|
||||
.into_iter()
|
||||
.map(|(n, p)| (n, p, if internal { self.drill(n, p) } else { n }))
|
||||
.min_by(|a, b| (a.2 - diameter).abs().total_cmp(&(b.2 - diameter).abs()))
|
||||
|
||||
@@ -490,6 +490,7 @@ impl GeoRef {
|
||||
match self {
|
||||
Self::Axis { name } | Self::ConstructPoint { name } => f(Ref::FeatureName(name)),
|
||||
Self::Plane { plane } => plane.walk_feature_refs(f, depth + 1),
|
||||
Self::Snap { of, .. } => of.walk_feature_refs(f, depth + 1),
|
||||
Self::Point { .. }
|
||||
| Self::Datum { .. }
|
||||
| Self::Origin
|
||||
@@ -509,6 +510,7 @@ impl GeoRef {
|
||||
match self {
|
||||
Self::Axis { name } | Self::ConstructPoint { name } => f(Ref::FeatureName(name)),
|
||||
Self::Plane { plane } => plane.walk_feature_refs_mut(f, depth + 1),
|
||||
Self::Snap { of, .. } => of.walk_feature_refs_mut(f, depth + 1),
|
||||
Self::Point { .. }
|
||||
| Self::Datum { .. }
|
||||
| Self::Origin
|
||||
|
||||
@@ -1237,6 +1237,44 @@ fn counted_body_names_stay_when_the_timeline_changes() {
|
||||
assert_eq!(bodies(&s), named(&[("Body2", "B"), ("Body3", "C"), ("Body4", "E")]));
|
||||
}
|
||||
|
||||
/// A design saved before body names were pinned, and saved rolled back, is pinned as it opens
|
||||
/// for the features after the marker too (#14008): a body made at the marker does not take the
|
||||
/// number of a later body, so the later bodies keep the names they were saved with and a combine
|
||||
/// that names them still joins the same bodies.
|
||||
#[test]
|
||||
fn counted_body_names_after_the_marker_stay_in_a_design_saved_before_pinning() {
|
||||
let mut s = Session::default();
|
||||
for (name, x) in [("A", 0), ("B", 20), ("C", 40), ("D", 50)] {
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0], "name": name}));
|
||||
}
|
||||
run(&mut s, "solid.combine", json!({"target": "Body3", "tools": ["Body4"], "operation": "join", "name": "Join"}));
|
||||
// As a file written before names were pinned: no stored names, and rolled back to after A.
|
||||
let mut doc = (*s.doc).clone();
|
||||
for f in &mut doc.features {
|
||||
f.body_names.clear();
|
||||
}
|
||||
doc.reserved_body_names.clear();
|
||||
doc.marker = Some(1);
|
||||
let mut s = Session::opened_at(solidcraft_doc::Document::from_json(&doc.to_json()).unwrap(), None, None);
|
||||
assert_eq!(s.doc.find_feature("C").map(|f| f.body_names.clone()), Some(vec!["Body3".to_string()]));
|
||||
assert!(["Body1", "Body2", "Body3", "Body4"].iter().all(|n| s.doc.reserved_body_names.contains(*n)));
|
||||
// Opening changed nothing the user can see: the design is still rolled back, and not dirty.
|
||||
assert_eq!(s.doc.marker, Some(1));
|
||||
assert_eq!(s.model.state().bodies.len(), 1);
|
||||
assert!(!s.is_dirty());
|
||||
assert!(s.undo.is_empty());
|
||||
|
||||
run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [0, 40, 0], "name": "N"}));
|
||||
run(&mut s, "timeline.roll_to", json!({"position": "end"}));
|
||||
let st = s.model.state();
|
||||
let names: Vec<(String, String)> =
|
||||
st.bodies.iter().map(|b| (b.name.clone(), s.doc.feature(b.feature).map(|f| f.name.clone()).unwrap_or_default())).collect();
|
||||
let want: Vec<(String, String)> =
|
||||
[("Body1", "A"), ("Body5", "N"), ("Body2", "B"), ("Body3", "C")].iter().map(|(b, f)| (b.to_string(), f.to_string())).collect();
|
||||
assert_eq!(names, want, "C and D are joined into Body3, N takes a new number");
|
||||
assert!(s.doc.features.iter().all(|f| s.model.result(f.id).is_some_and(|r| r.error.is_none())));
|
||||
}
|
||||
|
||||
/// Pinning reads each feature's bodies once and keeps them with the state it read them from;
|
||||
/// the names it pins and reserves, and the fingerprints it leaves, are those of reading every
|
||||
/// feature's bodies again: random boxes (named and counted), moves, suppressions, deletions,
|
||||
|
||||
@@ -191,7 +191,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.contract(&SPLIT_FACE_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params(
|
||||
"faces: [[x,y,z] points on the faces to split, or face names from model.faces]; body?; and the splitting tool: plane: XY|XZ|YZ|plane name|{origin, normal} — or tool: {body, point} (a face of a body, its surface extended) — or sketch: id or name (its curves, along the sketch's normal)",
|
||||
"faces: [[x,y,z] points on the faces to split, or face names from model.faces]; body?; and the splitting tool: plane: XY|XZ|YZ|plane name|{origin, normal} — or tool: {body, point} (a face of a body, its surface extended) — or sketch: id or name (its curves, along the sketch's normal; each curve on its own, an open one extended past its ends) and loops?: bool (default false: true joins the curves end to end first, so a closed loop cuts along itself only)",
|
||||
),
|
||||
CommandSpec::new("solid.split_body", "Split Body", crate::checked!("solid.split_body", split_body))
|
||||
.at("SOLID", "MODIFY")
|
||||
@@ -1642,7 +1642,7 @@ struct RowHit {
|
||||
const NAMED_BODIES: usize = 8;
|
||||
|
||||
/// A body's size, its edges' polylines and which of them are seams.
|
||||
type EdgeShape = (f64, Vec<Vec<Vec3>>, Vec<bool>);
|
||||
type EdgeShape = (f64, Vec<Vec<Vec3>>, Vec<bool>, Option<solidcraft_geom::Mesh>);
|
||||
|
||||
/// How far a polyline is from `q`.
|
||||
fn polyline_dist(points: &[Vec3], q: Vec3) -> f64 {
|
||||
@@ -1722,10 +1722,11 @@ fn edge_points(s: &Session, p: &Value, cmd: &str) -> Result<EdgePicks> {
|
||||
// meeting) can't be blended either: nobody means it by a pick.
|
||||
Ok(m) => Some((
|
||||
size,
|
||||
m.edges,
|
||||
Vec::new(),
|
||||
m.seams.iter().zip(&m.smooth_edges).map(|(s, t)| *s || *t).chain(m.seams.iter().skip(m.smooth_edges.len()).copied()).collect(),
|
||||
Some(m),
|
||||
)),
|
||||
Err(_) => b.body.edges(tol).ok().map(|edges| (size, edges.into_iter().map(|e| e.points).collect(), Vec::new())),
|
||||
Err(_) => b.body.edges(tol).ok().map(|edges| (size, edges.into_iter().map(|e| e.points).collect(), Vec::new(), None)),
|
||||
};
|
||||
if let Some(shape) = shape {
|
||||
shapes.insert(i, shape);
|
||||
@@ -1790,14 +1791,19 @@ fn edge_points(s: &Session, p: &Value, cmd: &str) -> Result<EdgePicks> {
|
||||
let mut hits: Vec<RowHit> = Vec::new();
|
||||
for (k, q) in culled {
|
||||
let Some(r) = rows.get(k) else { continue };
|
||||
let (Some((size, edges, seams)), Some(b)) = (shapes.get(&r.body), st.bodies.get(r.body)) else { continue };
|
||||
let (Some((size, listed, seams, mesh)), Some(b)) = (shapes.get(&r.body), st.bodies.get(r.body)) else { continue };
|
||||
// The display mesh's polylines, else the body's own edge list.
|
||||
let edges = mesh.as_ref().map_or(listed, |m| &m.edges);
|
||||
let chord = (size * 2e-3).max(1e-3);
|
||||
let mut near = Vec::new();
|
||||
let mut candidates = Vec::new();
|
||||
for (i, points) in edges.iter().enumerate() {
|
||||
let d = polyline_dist(points, q);
|
||||
if d <= edge_reach(*size) {
|
||||
let seam = seams.get(i).copied().unwrap_or(false);
|
||||
// Where two faces meet tangentially (a flat wall running into a round one)
|
||||
// there is nothing to blend, so nobody means the edge by the pick (#12215).
|
||||
// Decided only for the edges near the pick: it reads the faces' normals.
|
||||
let seam = seams.get(i).copied().unwrap_or(false) || mesh.as_ref().is_some_and(|m| m.tangent_join(i, TANGENT_JOIN));
|
||||
near.push((d, seam, leaving(points, q)));
|
||||
candidates.push((i, d, seam));
|
||||
}
|
||||
@@ -1826,7 +1832,8 @@ fn edge_points(s: &Session, p: &Value, cmd: &str) -> Result<EdgePicks> {
|
||||
for r in &rows {
|
||||
let Some(q) = q_of(r) else { continue };
|
||||
shape_of(r.body, &mut shapes);
|
||||
let Some((_, edges, _)) = shapes.get(&r.body) else { continue };
|
||||
let Some((_, listed, _, mesh)) = shapes.get(&r.body) else { continue };
|
||||
let edges = mesh.as_ref().map_or(listed, |m| &m.edges);
|
||||
nearest = edges.iter().map(|points| polyline_dist(points, q)).fold(nearest, f64::min);
|
||||
}
|
||||
let far = if nearest.is_finite() { format!("the nearest edge is {nearest:.3} mm away") } else { "there is no edge".to_string() };
|
||||
@@ -1843,7 +1850,7 @@ fn edge_points(s: &Session, p: &Value, cmd: &str) -> Result<EdgePicks> {
|
||||
let mut groups: Vec<&RowHit> = Vec::new();
|
||||
for h in hits.iter().filter(|h| h.d <= 2.0 * d_min + 1e-3) {
|
||||
let Some(r) = rows.get(h.row) else { continue };
|
||||
let size = shapes.get(&r.body).map_or(1.0, |(size, _, _)| *size);
|
||||
let size = shapes.get(&r.body).map_or(1.0, |(size, _, _, _)| *size);
|
||||
let same = |g: &&RowHit| {
|
||||
rows.get(g.row).is_some_and(|o| o.body == r.body) && ((g.edge.is_some() && g.edge == h.edge) || g.q.dist(h.q) <= 1e-6 * size.max(1.0))
|
||||
};
|
||||
@@ -1919,7 +1926,8 @@ fn edge_points(s: &Session, p: &Value, cmd: &str) -> Result<EdgePicks> {
|
||||
continue;
|
||||
}
|
||||
shape_of(other.body, &mut shapes);
|
||||
let Some((size, edges, seams)) = shapes.get(&other.body) else { continue };
|
||||
let Some((size, listed, seams, mesh)) = shapes.get(&other.body) else { continue };
|
||||
let edges = mesh.as_ref().map_or(listed, |m| &m.edges);
|
||||
// The exact curve is no nearer than the polyline less its chord tolerance.
|
||||
let chord = (size * 2e-3).max(1e-3);
|
||||
let ghost = edges.iter().enumerate().filter(|(k, _)| !seams.get(*k).copied().unwrap_or(false)).any(|(k, points)| {
|
||||
@@ -1991,6 +1999,9 @@ fn edge_foot(body: &solidcraft_kernel::Body, index: usize, q: Vec3) -> Option<(f
|
||||
d.is_finite().then_some((d, foot))
|
||||
}
|
||||
|
||||
/// Faces meeting at less than this angle (radians, 2°) meet tangentially, as a tangent chain reads them.
|
||||
const TANGENT_JOIN: f64 = 2.0 * std::f64::consts::PI / 180.0;
|
||||
|
||||
/// The direction an edge's polyline leaves `pt` in, when `pt` is one of its ends; `None` when
|
||||
/// the edge runs on through `pt`.
|
||||
fn leaving(points: &[Vec3], pt: Vec3) -> Option<Vec3> {
|
||||
@@ -2496,19 +2507,24 @@ fn move_body_feature(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// A copy keeps the look and the material of the body it came from (#6423): the copy is the same
|
||||
/// part in another place, so it looks the same.
|
||||
/// part in another place, so it looks the same. A feature makes the same number of copies of each
|
||||
/// source (a Move copy one, a Pattern its count less one), the sources' copies together and in
|
||||
/// the sources' order.
|
||||
fn copy_keeps_appearance(s: &mut Session, reply: &Value, sources: &[String], cmd: &str) -> Result<()> {
|
||||
let id = reply.get("feature").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "the copy has no feature id"))?;
|
||||
let copies: Vec<String> = s.model.state().bodies.iter().filter(|b| b.feature == id).map(|b| b.name.clone()).collect();
|
||||
if copies.len() != sources.len() {
|
||||
return Err(bad(cmd, "the copy did not make one body per body it copied"));
|
||||
}
|
||||
for (source, copy) in sources.iter().zip(copies.iter()) {
|
||||
if let Some(look) = s.doc.appearances.bodies.get(source).cloned() {
|
||||
s.doc_mut().appearances.bodies.insert(copy.clone(), look);
|
||||
}
|
||||
if let Some(material) = s.doc.materials.get(source).cloned() {
|
||||
s.doc_mut().materials.insert(copy.clone(), material);
|
||||
let each = copies.len().checked_div(sources.len()).filter(|each| *each > 0 && each * sources.len() == copies.len());
|
||||
let Some(each) = each else {
|
||||
return Err(bad(cmd, "the copy did not make the same number of bodies for each body it copied"));
|
||||
};
|
||||
for (source, copies) in sources.iter().zip(copies.chunks(each)) {
|
||||
for copy in copies {
|
||||
if let Some(look) = s.doc.appearances.bodies.get(source).cloned() {
|
||||
s.doc_mut().appearances.bodies.insert(copy.clone(), look);
|
||||
}
|
||||
if let Some(material) = s.doc.materials.get(source).cloned() {
|
||||
s.doc_mut().materials.insert(copy.clone(), material);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -2586,7 +2602,15 @@ fn finish_pattern(
|
||||
) -> Result<Value> {
|
||||
let comps = string_list(UNKNOWN_COMMAND, p, "components")?;
|
||||
if comps.is_empty() {
|
||||
return add_feature(s, p, FeatureKind::Pattern { features, pattern, bodies: bodies.into_iter().map(BodyName).collect() });
|
||||
let copied = bodies.clone();
|
||||
let reply = add_feature(s, p, FeatureKind::Pattern { features, pattern, bodies: bodies.into_iter().map(BodyName).collect() })?;
|
||||
// The pattern is added either way: when it made no copies (the feature failed to
|
||||
// evaluate) or an uneven number of them, there is nothing to tell the copies apart by
|
||||
// and they keep no look, as before.
|
||||
if !copied.is_empty() {
|
||||
let _ = copy_keeps_appearance(s, &reply, &copied, "pattern");
|
||||
}
|
||||
return Ok(reply);
|
||||
}
|
||||
let cmd = "pattern (components)";
|
||||
let mats = solidcraft_doc::pattern_matrices(&s.doc, &s.model.state(), &pattern)?;
|
||||
@@ -2946,6 +2970,7 @@ fn split_face(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
plane: xy(),
|
||||
tool: Some(solidcraft_doc::FaceAt { body: BodyName(tb), point: Position(point) }),
|
||||
sketch: None,
|
||||
loops: false,
|
||||
}
|
||||
} else if p.get("sketch").is_some_and(|v| !v.is_null()) {
|
||||
let sketch = sketch_id(s, p.get("sketch"), cmd, "sketch")?;
|
||||
@@ -2955,6 +2980,7 @@ fn split_face(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
plane: xy(),
|
||||
tool: None,
|
||||
sketch: Some(SketchId(sketch)),
|
||||
loops: bool_(p, "loops").unwrap_or(false),
|
||||
}
|
||||
} else {
|
||||
let plane = plane_param(s, p.get("plane"), cmd)?;
|
||||
@@ -2964,6 +2990,7 @@ fn split_face(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
plane,
|
||||
tool: None,
|
||||
sketch: None,
|
||||
loops: false,
|
||||
}
|
||||
};
|
||||
add_face_feature(&picked, s, p, kind)
|
||||
|
||||
@@ -2603,6 +2603,64 @@ fn an_imported_assembly_with_a_repeated_part_moves_as_one() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #13382: a design saved by a version whose STEP reader numbered the file's bodies otherwise
|
||||
/// opens with each body where it was and with its own shape. Here the older reader left the
|
||||
/// file's first body out, so every stored name and tree index after it is one lower. The names
|
||||
/// went by index: each body took the next one's name, and that name put it in the next part's
|
||||
/// component.
|
||||
#[test]
|
||||
fn an_import_saved_by_an_older_step_reader_opens_with_its_bodies_in_place() {
|
||||
use solidcraft_doc::FeatureKind;
|
||||
use solidcraft_doc::kernel::StepNode;
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-13382-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("parts.step").to_string_lossy().to_string();
|
||||
let mut src = Session::default();
|
||||
// Three parts of different sizes, each in its own component and placed apart.
|
||||
for (k, (name, size)) in [("Small", [10, 10, 10]), ("Long", [40, 6, 6]), ("Tall", [8, 8, 30])].into_iter().enumerate() {
|
||||
let made = run(&mut src, "component.create", json!({"name": name}));
|
||||
run(&mut src, "solid.box", json!({"length": size[0], "width": size[1], "height": size[2], "body_name": name}));
|
||||
run(&mut src, "component.activate", json!({"component": 0}));
|
||||
// The first part is grounded where it is made.
|
||||
if k > 0 {
|
||||
run(&mut src, "occurrence.move", json!({"occurrence": made["occurrence"], "translate": [100 * k, 0, 0], "space": "world"}));
|
||||
}
|
||||
}
|
||||
run(&mut src, "file.export", json!({"path": path}));
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "file.insert_step", json!({"path": path}));
|
||||
let names: Vec<String> = s.world_state().unwrap().bodies.iter().map(|b| b.name.clone()).collect();
|
||||
assert_eq!(names.len(), 3, "{names:?}");
|
||||
let before: Vec<_> = names.iter().map(|n| placed_bounds(&s, n)).collect();
|
||||
|
||||
// The design as the older reader saved it: no body 0, the others numbered from 0.
|
||||
let mut doc = (*s.doc).clone();
|
||||
let f = doc.features.iter_mut().find(|f| matches!(f.kind, FeatureKind::Import { .. })).unwrap();
|
||||
let first = f.body_names.remove(0);
|
||||
fn renumber(nodes: &mut [StepNode]) {
|
||||
for n in nodes {
|
||||
n.bodies = n.bodies.iter().filter(|b| **b != 0).map(|b| b - 1).collect();
|
||||
renumber(&mut n.children);
|
||||
}
|
||||
}
|
||||
if let FeatureKind::Import { components, skip, .. } = &mut f.kind {
|
||||
renumber(components);
|
||||
assert!(skip.is_empty(), "no part is placed twice: {skip:?}");
|
||||
}
|
||||
|
||||
let opened = Session::opened(doc, None);
|
||||
let now: Vec<String> = opened.world_state().unwrap().bodies.iter().map(|b| b.name.clone()).collect();
|
||||
assert_eq!(now.len(), 3, "the body the older reader left out is built too: {now:?}");
|
||||
for (n, b) in names.iter().zip(&before) {
|
||||
if *n == first {
|
||||
continue;
|
||||
}
|
||||
let got = placed_bounds(&opened, n);
|
||||
assert!(shifted(*b, got, [0.0; 3]), "{n} opened at {got:?}, was at {b:?}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A 20 × 30 × 8 plate made in its own component and stood up by its placement (turned
|
||||
/// `angle` degrees about X), the way an imported part set upright is: in the world it lies
|
||||
/// flat at `x0`..`x0 + 20`, y 0..30, z 0..8. The design is active again afterwards.
|
||||
@@ -2714,6 +2772,44 @@ fn an_emboss_that_works_has_no_warning() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A 40 x 10 stadium (two lines, two tangent arcs) extruded 10 mm. The top rim runs on smoothly
|
||||
/// through the points where a line meets an arc, so picking one of those points names the rim,
|
||||
/// not an ambiguous corner (#12215).
|
||||
fn stadium_block() -> Session {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY"}));
|
||||
run(&mut s, "sketch.line", json!({"points":[[5.0,0.0],[35.0,0.0]],"closed":false}));
|
||||
run(&mut s, "sketch.arc.center_point", json!({"center":[35.0,5.0],"start":[35.0,0.0],"end":[35.0,10.0]}));
|
||||
run(&mut s, "sketch.line", json!({"points":[[35.0,10.0],[5.0,10.0]],"closed":false}));
|
||||
run(&mut s, "sketch.arc.center_point", json!({"center":[5.0,5.0],"start":[5.0,10.0],"end":[5.0,0.0]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance":10.0}));
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chamfer_picked_where_a_line_meets_a_tangent_arc_takes_the_whole_rim() {
|
||||
let mut s = stadium_block();
|
||||
let before = volume(&mut s);
|
||||
run(&mut s, "solid.chamfer", json!({"edges": [[35.0, 0.0, 10.0]], "distance": 0.2}));
|
||||
let rim = 2.0 * 30.0 + 2.0 * std::f64::consts::PI * 5.0;
|
||||
let removed = before - volume(&mut s);
|
||||
let want = 0.5 * 0.2 * 0.2 * rim;
|
||||
assert!((removed - want).abs() < 0.02 * want, "removed {removed} vs {want}");
|
||||
}
|
||||
|
||||
/// A box corner is not a tangent join: a pick there still says several edges meet (#5173).
|
||||
#[test]
|
||||
fn chamfer_picked_at_a_box_corner_is_still_refused() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane":"XY"}));
|
||||
run(&mut s, "sketch.rectangle.two_point", json!({"p0":[0.0,0.0],"p1":[20.0,10.0]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
run(&mut s, "solid.extrude", json!({"distance":10.0}));
|
||||
let err = s.execute("solid.chamfer", &json!({"edges": [[20.0, 0.0, 10.0]], "distance": 0.2})).unwrap_err().to_string();
|
||||
assert!(err.contains("edges meet"), "{err}");
|
||||
}
|
||||
|
||||
/// #9969: a cut whose tool lies clear of the body reported "operation complete" and changed
|
||||
/// nothing, with no warning. It now says it removed no material; a cut that works says nothing.
|
||||
#[test]
|
||||
|
||||
+139
-22
@@ -31,7 +31,6 @@ use crate::schema::insert_mesh::{InsertMeshParams, MeshInserted};
|
||||
use crate::schema::insert_step::{InsertStepParams, StepInserted};
|
||||
use crate::schema::new_design::{NewDesignParams, NewDesignResult};
|
||||
use crate::schema::save::{SaveAsParams, SaveParams, SaveResult};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use crate::schema::save_mesh::{SaveMeshResult, SlicerDelivery};
|
||||
use crate::schema::slicers::{SlicerInfo, SlicersParams, SlicersResult};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
@@ -102,7 +101,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.noundo()
|
||||
.contract(&EXPORT_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::file::FileExportResult>)
|
||||
.params("path; format?: stl|stla|obj|step|3mf|3mf-tagged|glb|urdf|json (default from extension; 3mf-tagged: 3MF whose triangles carry their B-rep face, at full precision, for meshing and analysis tools; docs/formats/3mf-face-tags.md; obj: the colours of the bodies go in a material library (`.mtl`) written next to the mesh file; glb: binary glTF in metres with Y up, each body placed and named, its colours, metallic and roughness as materials; a transparent body or face, or a body a render setup gives an image texture or optics, is refused; urdf: the whole assembly as a robot, with STL meshes written next to it; docs/urdf.md); bodies?: [names] (JSON and URDF export the complete design); urdf only: mesh_prefix? (before each mesh file name, e.g. package://robot/meshes/), effort? (default 1000), velocity? (default 10); quality?: standard|fine|finest (STL, OBJ, 3MF and GLB: how closely the mesh follows curved faces, a chord tolerance of 2e-4, 5e-5 or 1e-5 of each body's size; default standard); objects?: body|component (3MF only: one object per body, the default, or one object for each placed component with two or more bodies, with those bodies as its parts, so a slicer loads them as one object with parts)"),
|
||||
.params("path; format?: stl|stla|obj|step|3mf|3mf-tagged|glb|urdf|json (default from extension; 3mf-tagged: 3MF whose triangles carry their B-rep face, at full precision, for meshing and analysis tools; docs/formats/3mf-face-tags.md; obj: the colours of the bodies go in a material library (`.mtl`) written next to the mesh file; glb: binary glTF in metres with Y up, each body placed and named, its colours, metallic and roughness as materials; a transparent body or face, or a body a render setup gives an image texture or optics, is refused; urdf: the whole assembly as a robot, with STL meshes written next to it; docs/urdf.md); bodies?: [names] (JSON and URDF export the complete design); urdf only: mesh_prefix? (before each mesh file name, e.g. package://robot/meshes/), effort? (default 1000), velocity? (default 10); quality?: standard|fine|finest (STL, OBJ, 3MF and GLB: how closely the mesh follows curved faces, a chord tolerance of 2e-4, 5e-5 or 1e-5 of each body's size; default standard); objects?: body|component (3MF only: one object per body, the default, or one object for each placed component with two or more bodies, with those bodies as its parts, so a slicer loads them as one object with parts); lay_on_print_faces?: bool (STL and 3MF only, default false: each body with a print face (body.print_face) is written laid on it, as a slicer export lays it → print_faces: the bodies laid, warnings: each body that has a print face but was written as it lies)"),
|
||||
// One command per format, so each File › Export item can have a key of its own; started
|
||||
// from a key or command search, the app opens the save picker (the mesh dialog for STL and
|
||||
// 3MF). Each takes `file.export`'s parameters for its format, without `format`.
|
||||
@@ -117,13 +116,13 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.noundo()
|
||||
.contract(&EXPORT_STL_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::file::ExportResult>)
|
||||
.params("path; bodies?: [names] (default: every body); quality?: standard|fine|finest (default standard) → {path, bytes, format}: binary STL, whatever the path's extension"),
|
||||
.params("path; bodies?: [names] (default: every body); quality?: standard|fine|finest (default standard); lay_on_print_faces?: bool (default false: as file.export) → {path, bytes, format, print_faces?, warnings?}: binary STL, whatever the path's extension"),
|
||||
CommandSpec::new("file.export_3mf", "Export 3MF", crate::checked!("file.export_3mf", export_3mf))
|
||||
.icon("mesh_export")
|
||||
.noundo()
|
||||
.contract(&EXPORT_3MF_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::file::ExportResult>)
|
||||
.params("path; bodies?: [names] (default: every body); quality?: standard|fine|finest (default standard); objects?: body|component (as file.export) → {path, bytes, format}: 3MF, whatever the path's extension"),
|
||||
.params("path; bodies?: [names] (default: every body); quality?: standard|fine|finest (default standard); objects?: body|component (as file.export); lay_on_print_faces?: bool (default false: as file.export) → {path, bytes, format, print_faces?, warnings?}: 3MF, whatever the path's extension"),
|
||||
CommandSpec::new("file.export_obj", "Export OBJ", crate::checked!("file.export_obj", export_obj_cmd))
|
||||
.icon("export")
|
||||
.noundo()
|
||||
@@ -142,7 +141,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.noundo()
|
||||
.contract(&SAVE_MESH_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<crate::schema::save_mesh::SaveMeshResult>)
|
||||
.params("path; format?: stl|3mf|3mf-tagged (default from the extension, else stl; 3mf-tagged: docs/formats/3mf-face-tags.md); bodies?: [names] (default: all); ascii?: bool (STL); slicer?: id or name from file.slicers, the program path it lists, or a program the user added under Custom… (send the saved file there, through the same steps as file.send_to_slicer: a slicer that has to be started first is waited for; may be deprecated later in favour of file.send_to_slicer or a Send to App workflow, kept for scripts); single_instance?: bool (default true; see file.send_to_slicer); quality?: standard|fine|finest (STL, OBJ and 3MF: how closely the mesh follows curved faces, a chord tolerance of 2e-4, 5e-5 or 1e-5 of each body's size; default standard); objects?: body|component (3MF only: one object per body, the default, or one object for each placed component with two or more bodies, with those bodies as its parts, so a slicer loads them as one object with parts)"),
|
||||
.params("path; format?: stl|3mf|3mf-tagged (default from the extension, else stl; 3mf-tagged: docs/formats/3mf-face-tags.md); bodies?: [names] (default: all); ascii?: bool (STL); slicer?: id or name from file.slicers, the program path it lists, or a program the user added under Custom… (send the saved file there, through the same steps as file.send_to_slicer: a slicer that has to be started first is waited for; may be deprecated later in favour of file.send_to_slicer or a Send to App workflow, kept for scripts); single_instance?: bool (default true; see file.send_to_slicer); quality?: standard|fine|finest (STL, OBJ and 3MF: how closely the mesh follows curved faces, a chord tolerance of 2e-4, 5e-5 or 1e-5 of each body's size; default standard); objects?: body|component (3MF only: one object per body, the default, or one object for each placed component with two or more bodies, with those bodies as its parts, so a slicer loads them as one object with parts); lay_on_print_faces?: bool (default false: each body with a print face (body.print_face) is saved laid on it; a file sent to `slicer` is laid on them whatever this says)"),
|
||||
CommandSpec::new("file.send_to_slicer", "Send to Slicer", send_to_slicer)
|
||||
.enabled(starts_programs)
|
||||
.at("INSERT", "FILES")
|
||||
@@ -179,7 +178,7 @@ const EXPORT_CONTRACT: crate::schema::TypedContract<crate::schema::file::ExportP
|
||||
const SAVE_CONTRACT: TypedContract<SaveParams> = TypedContract::new();
|
||||
const SAVE_AS_CONTRACT: TypedContract<SaveAsParams> = TypedContract::new();
|
||||
const EXPORT_STEP_CONTRACT: crate::schema::TypedContract<crate::schema::file::ExportStepParams> = crate::schema::TypedContract::new();
|
||||
const EXPORT_STL_CONTRACT: crate::schema::TypedContract<crate::schema::file::ExportMeshParams> = crate::schema::TypedContract::new();
|
||||
const EXPORT_STL_CONTRACT: crate::schema::TypedContract<crate::schema::file::ExportStlParams> = crate::schema::TypedContract::new();
|
||||
const EXPORT_3MF_CONTRACT: crate::schema::TypedContract<crate::schema::file::Export3mfParams> = crate::schema::TypedContract::new();
|
||||
const EXPORT_OBJ_CONTRACT: crate::schema::TypedContract<crate::schema::file::ExportMeshParams> = crate::schema::TypedContract::new();
|
||||
const SLICERS_CONTRACT: TypedContract<SlicersParams> = TypedContract::new();
|
||||
@@ -905,7 +904,12 @@ fn write_bytes(
|
||||
"json" => solidcraft_io::write_design(&s.design_at(Some(path))),
|
||||
_ => return Err(bad("file.save", "format must be container or json")),
|
||||
};
|
||||
// Atomic, keeping the version being replaced as `<file>.bak`.
|
||||
// Cancelled while the design was being prepared (a watched save): nothing is written.
|
||||
if solidcraft_kernel::watch::current().is_some_and(|w| w.cancelled()) {
|
||||
return Err(EngineError::Other(format!("{path}: save cancelled")));
|
||||
}
|
||||
// Atomic, keeping the version being replaced as `<file>.bak`; a watched save reports how far
|
||||
// the write is and stops on Cancel until the file is replaced.
|
||||
solidcraft_io::write_atomic(std::path::Path::new(path), &bytes, true).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
s.path = Some(path.to_string());
|
||||
s.disk = solidcraft_io::design_lock::stamp(path);
|
||||
@@ -964,11 +968,59 @@ impl Session {
|
||||
// first undo after opening evaluated the whole timeline again (82-93 s in a
|
||||
// 5,484-feature design). The pinned names are the ones the file's bodies have, so
|
||||
// opening is no change: the pinned design is the saved one.
|
||||
let unpinned = session.doc.reserved_body_names.is_empty() && session.doc.features.iter().all(|f| f.body_names.is_empty());
|
||||
let before = session.doc.clone();
|
||||
session.pin_body_names();
|
||||
if unpinned || !std::sync::Arc::ptr_eq(&before, &session.doc) {
|
||||
session.pin_rolled_back_body_names();
|
||||
}
|
||||
session.saved = session.doc.clone();
|
||||
session
|
||||
}
|
||||
|
||||
/// Pin the names of the bodies the features after the timeline marker make, in a design
|
||||
/// saved rolled back before names were pinned (#14008). Those features were never evaluated,
|
||||
/// so [`Session::pin_body_names`] left them unpinned: a body then made at the marker took the
|
||||
/// number of a later body, every later counted body shifted, and a combine that names them
|
||||
/// failed or joined another body. The names are the ones the whole timeline gives, which is
|
||||
/// what the design called those bodies when it was saved. The design stays rolled back.
|
||||
///
|
||||
/// The whole timeline is evaluated on a scratch copy, and only the names it pins come back:
|
||||
/// refreshing this session there would also work out what the design reads from the later
|
||||
/// geometry (a gear's ratio from a face after the marker, joint placements) and keep it.
|
||||
fn pin_rolled_back_body_names(&mut self) {
|
||||
if !self.doc.marker.is_some_and(|m| m < self.doc.features.len()) {
|
||||
return;
|
||||
}
|
||||
let mut whole = self.scratch();
|
||||
whole.doc_mut().marker = None;
|
||||
whole.model.evaluate_shared(&whole.doc);
|
||||
whole.pin_body_names();
|
||||
let pins: Vec<(u64, Vec<String>)> = whole
|
||||
.doc
|
||||
.features
|
||||
.iter()
|
||||
.filter(|f| self.doc.feature(f.id).is_some_and(|mine| mine.body_names != f.body_names))
|
||||
.map(|f| (f.id, f.body_names.clone()))
|
||||
.collect();
|
||||
let reserve: Vec<String> = whole.doc.reserved_body_names.difference(&self.doc.reserved_body_names).cloned().collect();
|
||||
if pins.is_empty() && reserve.is_empty() {
|
||||
return;
|
||||
}
|
||||
// As `pin_body_names` stores names: the bodies keep the names they have, so no result
|
||||
// changes and none is evaluated again.
|
||||
let renamed: Vec<u64> = pins.iter().map(|(id, _)| *id).collect();
|
||||
let keep = self.model.pinned_prefix(&self.doc, &renamed);
|
||||
let doc = self.doc_mut();
|
||||
for (id, names) in pins {
|
||||
if let Some(f) = doc.feature_mut(id) {
|
||||
f.body_names = names;
|
||||
}
|
||||
}
|
||||
doc.reserved_body_names.extend(reserve);
|
||||
self.model.refingerprint_shared(&self.doc, keep);
|
||||
}
|
||||
|
||||
/// Prepare only the file's copy with relative paths; saving never rewrites live history.
|
||||
pub(crate) fn design_at(&self, path: Option<&str>) -> std::borrow::Cow<'_, Document> {
|
||||
let mut design = std::borrow::Cow::Borrowed(&*self.doc);
|
||||
@@ -1223,8 +1275,7 @@ fn export_to(
|
||||
)?
|
||||
};
|
||||
solidcraft_io::vfs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let reply =
|
||||
ExportResult { path: path.to_string(), bytes: bytes.len(), format: format!("{format:?}"), files: Vec::new(), param_warnings: Vec::new() };
|
||||
let reply = ExportResult { path: path.to_string(), bytes: bytes.len(), format: format!("{format:?}"), ..ExportResult::default() };
|
||||
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
@@ -1233,8 +1284,60 @@ fn export_thumbnail(s: &Session, format: Format, bodies: &[String]) -> Option<Ve
|
||||
matches!(format, Format::ThreeMf | Format::ThreeMfTagged).then(|| thumbnail::exported(s, bodies)).flatten()
|
||||
}
|
||||
|
||||
/// [`export_to`] for a mesh file a slicer is sent (STL or 3MF): each body laid on its print face.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
/// `lay_on_print_faces` as the mesh exports read it: true writes each body with a print face laid
|
||||
/// on it, as a slicer export does. Only STL and 3MF take it; `false` is accepted for any format.
|
||||
fn lay_on_print_faces(p: &Value, cmd: &str, format: Format) -> Result<bool> {
|
||||
match p.get("lay_on_print_faces") {
|
||||
None | Some(Value::Null) | Some(Value::Bool(false)) => Ok(false),
|
||||
Some(Value::Bool(true)) if matches!(format, Format::StlBinary | Format::StlAscii | Format::ThreeMf | Format::ThreeMfTagged) => Ok(true),
|
||||
Some(Value::Bool(true)) => Err(bad(cmd, "`lay_on_print_faces` is for STL and 3MF: other formats are not printed")),
|
||||
Some(_) => Err(bad(cmd, "`lay_on_print_faces` must be true or false")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Write the bodies as a mesh file (STL or 3MF), each laid on its print face: the file's size, and
|
||||
/// which bodies were laid and which were written as they lie.
|
||||
fn write_laid(
|
||||
s: &Session,
|
||||
path: &str,
|
||||
format: Format,
|
||||
bodies: &[String],
|
||||
parts: &[(String, Vec<String>)],
|
||||
quality: solidcraft_io::MeshQuality,
|
||||
) -> Result<(usize, print_face::Laid)> {
|
||||
let name = std::path::Path::new(path).file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| s.doc.name.clone());
|
||||
let mut state = s.doc.painted(&*s.world_state()?);
|
||||
let laid = print_face::lay_on_print_faces(s, &mut state, bodies, parts);
|
||||
let thumbnail = export_thumbnail(s, format, bodies);
|
||||
let bytes = solidcraft_io::export_with_parts(&state, bodies, parts, format, &name, &s.doc.metadata, quality, thumbnail.as_deref())?;
|
||||
solidcraft_io::vfs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
Ok((bytes.len(), laid))
|
||||
}
|
||||
|
||||
/// [`export_to`] for `file.export`, `file.export_stl` and `file.export_3mf` with
|
||||
/// `lay_on_print_faces`: each body laid on its print face.
|
||||
fn export_laid(
|
||||
s: &Session,
|
||||
path: &str,
|
||||
format: Format,
|
||||
bodies: &[String],
|
||||
parts: &[(String, Vec<String>)],
|
||||
quality: solidcraft_io::MeshQuality,
|
||||
) -> Result<Value> {
|
||||
let (bytes, laid) = write_laid(s, path, format, bodies, parts, quality)?;
|
||||
let reply = ExportResult {
|
||||
path: path.to_string(),
|
||||
bytes,
|
||||
format: format!("{format:?}"),
|
||||
print_faces: laid.bodies,
|
||||
warnings: laid.warnings,
|
||||
..ExportResult::default()
|
||||
};
|
||||
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
/// [`export_to`] for a mesh file a slicer is sent, or `file.save_mesh` with `lay_on_print_faces`
|
||||
/// (STL or 3MF): each body laid on its print face.
|
||||
fn export_laid_to(
|
||||
s: &Session,
|
||||
path: &str,
|
||||
@@ -1243,15 +1346,10 @@ fn export_laid_to(
|
||||
parts: &[(String, Vec<String>)],
|
||||
quality: solidcraft_io::MeshQuality,
|
||||
) -> Result<Value> {
|
||||
let name = std::path::Path::new(path).file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| s.doc.name.clone());
|
||||
let mut state = s.doc.painted(&*s.world_state()?);
|
||||
let laid = print_face::lay_on_print_faces(s, &mut state, bodies, parts);
|
||||
let thumbnail = export_thumbnail(s, format, bodies);
|
||||
let bytes = solidcraft_io::export_with_parts(&state, bodies, parts, format, &name, &s.doc.metadata, quality, thumbnail.as_deref())?;
|
||||
solidcraft_io::vfs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let (bytes, laid) = write_laid(s, path, format, bodies, parts, quality)?;
|
||||
let reply = SaveMeshResult {
|
||||
path: path.to_string(),
|
||||
bytes: bytes.len(),
|
||||
bytes,
|
||||
format: format!("{format:?}"),
|
||||
print_faces: laid.bodies,
|
||||
warnings: laid.warnings,
|
||||
@@ -1273,7 +1371,11 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let quality = mesh_quality(p, cmd)?;
|
||||
let parts = mesh_parts(s, p, cmd, format)?;
|
||||
export_to(s, cmd, path, format, &string_list(cmd, p, "bodies")?, &parts, quality)
|
||||
let bodies = string_list(cmd, p, "bodies")?;
|
||||
if lay_on_print_faces(p, cmd, format)? {
|
||||
return export_laid(s, path, format, &bodies, &parts, quality);
|
||||
}
|
||||
export_to(s, cmd, path, format, &bodies, &parts, quality)
|
||||
}
|
||||
|
||||
fn export_step(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1285,14 +1387,22 @@ fn export_step(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn export_stl(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "file.export_stl";
|
||||
let path = path_arg(p, cmd)?;
|
||||
export_to(s, cmd, path, Format::StlBinary, &string_list(cmd, p, "bodies")?, &[], mesh_quality(p, cmd)?)
|
||||
let (bodies, quality) = (string_list(cmd, p, "bodies")?, mesh_quality(p, cmd)?);
|
||||
if lay_on_print_faces(p, cmd, Format::StlBinary)? {
|
||||
return export_laid(s, path, Format::StlBinary, &bodies, &[], quality);
|
||||
}
|
||||
export_to(s, cmd, path, Format::StlBinary, &bodies, &[], quality)
|
||||
}
|
||||
|
||||
fn export_3mf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "file.export_3mf";
|
||||
let path = path_arg(p, cmd)?;
|
||||
let parts = mesh_parts(s, p, cmd, Format::ThreeMf)?;
|
||||
export_to(s, cmd, path, Format::ThreeMf, &string_list(cmd, p, "bodies")?, &parts, mesh_quality(p, cmd)?)
|
||||
let (bodies, quality) = (string_list(cmd, p, "bodies")?, mesh_quality(p, cmd)?);
|
||||
if lay_on_print_faces(p, cmd, Format::ThreeMf)? {
|
||||
return export_laid(s, path, Format::ThreeMf, &bodies, &parts, quality);
|
||||
}
|
||||
export_to(s, cmd, path, Format::ThreeMf, &bodies, &parts, quality)
|
||||
}
|
||||
|
||||
fn export_obj_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1433,6 +1543,9 @@ fn save_mesh(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// a possibility, not a plan.
|
||||
let quality = mesh_quality(p, cmd)?;
|
||||
let parts = mesh_parts(s, p, cmd, format)?;
|
||||
// Read before `slicer`, so a wrong value is refused there too; a file sent to a slicer is laid
|
||||
// on its print faces whatever this says.
|
||||
let lay = lay_on_print_faces(p, cmd, format)?;
|
||||
if let Some(spec) = str_(p, "slicer") {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
@@ -1456,7 +1569,11 @@ fn save_mesh(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
},
|
||||
);
|
||||
}
|
||||
export_to(s, cmd, path, format, &string_list(cmd, p, "bodies")?, &parts, quality)
|
||||
let bodies = string_list(cmd, p, "bodies")?;
|
||||
if lay {
|
||||
return export_laid_to(s, path, format, &bodies, &parts, quality);
|
||||
}
|
||||
export_to(s, cmd, path, format, &bodies, &parts, quality)
|
||||
}
|
||||
|
||||
/// The browser build can't start a program.
|
||||
|
||||
@@ -186,6 +186,107 @@ fn node_key(imp: &StepImport, node: &StepNode, depth: usize) -> u64 {
|
||||
h.finish()
|
||||
}
|
||||
|
||||
/// For each body `now` gives, the index `stored` gave the same body, or `None` for a body
|
||||
/// `stored` doesn't have. A body is the same one when it has the same place in both trees: the
|
||||
/// same product at the same position among its siblings, and the same position among that
|
||||
/// product's bodies. `None` when the trees disagree about their products (a name or a number of
|
||||
/// children), or a body index is out of range or used twice: then nothing can be matched.
|
||||
fn matched_bodies(stored: &[StepNode], now: &[StepNode], count: usize) -> Option<Vec<Option<usize>>> {
|
||||
let mut matched = vec![None; count];
|
||||
let mut taken = std::collections::HashSet::new();
|
||||
let mut pending: Vec<(&[StepNode], &[StepNode])> = vec![(stored, now)];
|
||||
while let Some((was, is)) = pending.pop() {
|
||||
if was.len() != is.len() {
|
||||
return None;
|
||||
}
|
||||
for (a, b) in was.iter().zip(is) {
|
||||
if a.name != b.name {
|
||||
return None;
|
||||
}
|
||||
for (old, new) in a.bodies.iter().zip(&b.bodies) {
|
||||
let slot = matched.get_mut(*new)?;
|
||||
if slot.is_some() || !taken.insert(*old) {
|
||||
return None;
|
||||
}
|
||||
*slot = Some(*old);
|
||||
}
|
||||
pending.push((&a.children, &b.children));
|
||||
}
|
||||
}
|
||||
Some(matched)
|
||||
}
|
||||
|
||||
/// An Import feature written against `imp`'s numbering of the file's bodies (#13382). The
|
||||
/// feature keeps its body names, skipped bodies and linked face names by body index, as the
|
||||
/// STEP reader that inserted the file numbered them. A later reader can number the same file's
|
||||
/// bodies differently, for example when it reads a body an earlier one left out. Each name
|
||||
/// then went to the wrong body, which its name put in another component: bodies opened moved
|
||||
/// and with another body's shape. The stored tree says which body each index was, so each
|
||||
/// stored value moves to the index `imp` gives that body. A body only `imp` has gets the
|
||||
/// reader's name, and is skipped when a new insert would skip it. Returns whether anything
|
||||
/// changed; the feature is left as it is when the trees can't be matched.
|
||||
pub(crate) fn realign_import(f: &mut solidcraft_doc::Feature, imp: &StepImport) -> bool {
|
||||
let FeatureKind::Import { components, skip, .. } = &f.kind else { return false };
|
||||
if components.is_empty() || *components == imp.tree {
|
||||
return false;
|
||||
}
|
||||
let count = imp.bodies.len();
|
||||
let Some(matched) = matched_bodies(components, &imp.tree, count) else { return false };
|
||||
let stored_count = {
|
||||
let mut all = Vec::new();
|
||||
for n in components {
|
||||
collect_bodies(n, &mut all, 0);
|
||||
}
|
||||
all.len()
|
||||
};
|
||||
if stored_count == count && matched.iter().enumerate().all(|(k, m)| *m == Some(k)) {
|
||||
// The same numbering: only placements or names that don't move bodies differ.
|
||||
return false;
|
||||
}
|
||||
let old_skip: std::collections::HashSet<u64> = skip.iter().copied().collect();
|
||||
let new_skip: std::collections::HashSet<u64> = Plan::of(imp).skip.into_iter().collect();
|
||||
let skip: Vec<u64> = (0..count as u64)
|
||||
.filter(|k| match matched.get(*k as usize).copied().flatten() {
|
||||
Some(old) => old_skip.contains(&(old as u64)),
|
||||
None => new_skip.contains(k),
|
||||
})
|
||||
.collect();
|
||||
f.body_names = by_new_index(&matched, &f.body_names);
|
||||
if !f.link_faces.is_empty() {
|
||||
f.link_faces = by_new_index(&matched, &f.link_faces);
|
||||
}
|
||||
if let FeatureKind::Import { components, skip: stored, .. } = &mut f.kind {
|
||||
components.clone_from(&imp.tree);
|
||||
*stored = skip;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// `stored`, kept by the old body indices, as a list by the new ones (empty for a new body).
|
||||
fn by_new_index<T: Clone + Default>(matched: &[Option<usize>], stored: &[T]) -> Vec<T> {
|
||||
matched.iter().map(|m| m.and_then(|old| stored.get(old)).cloned().unwrap_or_default()).collect()
|
||||
}
|
||||
|
||||
/// [`realign_import`] for every Import feature of `doc` that is not suppressed, read with the
|
||||
/// reader that will build it. The reads are returned so the caller can hold them while it
|
||||
/// evaluates `doc`: the evaluation then finds each file read already instead of reading it again.
|
||||
pub(crate) fn realign_imports(doc: &mut solidcraft_doc::Document) -> Vec<std::sync::Arc<StepImport>> {
|
||||
let mut reads = Vec::new();
|
||||
for f in doc.features.iter_mut().filter(|f| !f.suppressed) {
|
||||
let FeatureKind::Import { step, components, .. } = &f.kind else { continue };
|
||||
if step.is_empty() || components.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// A file that doesn't read fails the feature when it is evaluated, which reports it.
|
||||
let Ok(imp) = solidcraft_doc::kernel::step_import_shared(step) else { continue };
|
||||
if realign_import(f, &imp) {
|
||||
log::info!("{}: the stored body names follow this version's numbering of the file's bodies", f.name);
|
||||
}
|
||||
reads.push(imp);
|
||||
}
|
||||
reads
|
||||
}
|
||||
|
||||
/// The import's component, as the command reports it. The reply types in `schema/insert_mesh.rs`
|
||||
/// and `schema/insert_step.rs` are what the commands answer with these fields.
|
||||
pub(crate) struct Placed {
|
||||
|
||||
@@ -18,7 +18,7 @@ use crate::schema::inspect::{
|
||||
FaceCylinder, KernelCounts, ListedMaterial, MaterialAssignParams, MaterialAssignResult, MaterialListParams, MaterialListResult, MeasureParams,
|
||||
MeasureResult, MeasureTotal, Measured, MeasuredBodies, MeasuredBody, ModelBodyFeatures, ModelEdge, ModelEdges, ModelFace, ModelFaces,
|
||||
ModelThread, ModelThreads, ModelThreadsParams, SketchConstraint, SketchCurve, SketchInspectParams, SketchInspected, SketchPoint, SketchProfile,
|
||||
SketchWire, TimelineEntry, TimelineSummary, WireArcInfo,
|
||||
SketchWire, SplineTangentHandle, TimelineEntry, TimelineSummary, WireArcInfo,
|
||||
};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Session};
|
||||
@@ -360,12 +360,27 @@ fn sketch_json(s: &Session, world: &solidcraft_doc::ModelState, id: u64) -> Opti
|
||||
CurveKind::Ellipse { c: cc, m, r } => {
|
||||
CurveShape::Ellipse { center: pid(cc), major: pid(m), center_at: at(cc), major_at: at(m), minor_radius: r }
|
||||
}
|
||||
CurveKind::Spline { ref pts, control, degree, periodic, ref nurbs } => CurveShape::Spline {
|
||||
CurveKind::Spline { ref pts, control, degree, periodic, ref nurbs, ref handles } => CurveShape::Spline {
|
||||
points: pts.iter().map(|q| pid(*q)).collect(),
|
||||
points_at: pts.iter().map(|q| at(*q)).collect(),
|
||||
control,
|
||||
degree,
|
||||
periodic,
|
||||
tangent_handles: handles
|
||||
.iter()
|
||||
.map(|&point| SplineTangentHandle {
|
||||
point: pid(point),
|
||||
at: at(point),
|
||||
curve: sk
|
||||
.curves
|
||||
.iter()
|
||||
.find(|candidate| {
|
||||
candidate.spline_handle_of.as_deref() == Some(c.id.as_str())
|
||||
&& matches!(candidate.kind, CurveKind::Line { a, b } if a == point || b == point)
|
||||
})
|
||||
.map(|candidate| candidate.id.clone()),
|
||||
})
|
||||
.collect(),
|
||||
// A control spline with its own knots (a projected free-form edge) says them.
|
||||
knots: nurbs.as_ref().map(|n| n.knots.clone()),
|
||||
weights: nurbs.as_ref().filter(|n| !n.weights.is_empty()).map(|n| n.weights.clone()),
|
||||
|
||||
@@ -143,3 +143,33 @@ fn an_edge_origin_on_a_body_placed_by_an_offset_lands_on_that_edge() {
|
||||
let (o, z) = resolve(corner);
|
||||
assert!(near(o, at(Vec3::new(0.0, 0.0, 5.0))) && near(z, up), "{o:?} {z:?}");
|
||||
}
|
||||
|
||||
/// A corner's other edge is found by its own name. Where no edge answers to that name any more,
|
||||
/// the corner is gone: the pick lies on the edge the corner was picked from, never on the other
|
||||
/// edge, so it cannot say which edge that name meant.
|
||||
#[test]
|
||||
fn a_corner_whose_other_edge_is_gone_asks_again() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 5, "corner": [0, 0, 0], "body_name": "Plate"}));
|
||||
let boss =
|
||||
run(&mut s, "solid.cylinder", json!({"radius": 3, "height": 5, "base": [20, 20, 5], "operation": "join", "targets": ["Plate"]}))["feature"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let st = s.model.state();
|
||||
let snap = solidcraft_doc::joints::edge_snap(
|
||||
&s.doc,
|
||||
&st,
|
||||
0,
|
||||
Vec3::new(20.0, 0.0, 5.0),
|
||||
Some(Vec3::new(20.0, 10.0, 5.0)),
|
||||
Some(Vec3::new(1.0, 0.0, 5.0)),
|
||||
)
|
||||
.unwrap_or_else(|e| panic!("{e}"));
|
||||
let solidcraft_doc::joints::Snap::Edge { pick, edge, face: Some(face), corner: Some(_), axis } = snap else { panic!("{snap:?}") };
|
||||
// The top face, and a face of the boss (a feature with faces on the plate) that no face
|
||||
// answers to: the edge the pick lies on would take that name's place.
|
||||
let corner = Some(format!("{face}|F{boss}:side:l99"));
|
||||
let stale = solidcraft_doc::joints::Snap::Edge { pick, edge, face: Some(face), corner, axis };
|
||||
let origin = solidcraft_doc::joints::JointOrigin::new(0, stale);
|
||||
assert!(solidcraft_doc::joints::resolve_origin(&s.doc, &st, &origin).is_err());
|
||||
}
|
||||
|
||||
@@ -429,3 +429,34 @@ fn an_editor_accepts_the_ratios_rolling_the_marker_works_out() {
|
||||
assert_eq!(s.doc.marker, None);
|
||||
assert!(close_to(ratio(&s), -0.6), "{}", ratio(&s));
|
||||
}
|
||||
|
||||
/// Opening a design saved rolled back before body names were pinned works the later bodies'
|
||||
/// names out from the whole timeline (#14008), and keeps nothing else that evaluation reads: a
|
||||
/// gear picked on a face after the marker keeps the ratio the file has, and the parts stay
|
||||
/// where the file has them.
|
||||
#[test]
|
||||
fn pinning_the_names_after_the_marker_leaves_a_gear_on_a_later_face_alone() {
|
||||
let s = gear_train();
|
||||
let g1 = s.doc.components.iter().find(|c| c.name == "G1").map(|c| c.id).unwrap_or_else(|| panic!("no G1"));
|
||||
let disc = s.model.state().bodies.iter().find(|b| b.component == g1).map(|b| b.feature).unwrap_or_else(|| panic!("no G1 disc"));
|
||||
let at = s.doc.features.iter().position(|f| f.id == disc).unwrap_or_else(|| panic!("no G1 disc feature"));
|
||||
let mut doc = (*s.doc).clone();
|
||||
for f in &mut doc.features {
|
||||
f.body_names.clear();
|
||||
}
|
||||
doc.reserved_body_names.clear();
|
||||
// Rolled back before G1's disc, so its face is not there, with the ratio the file was saved
|
||||
// with (the whole timeline makes it -0.5).
|
||||
doc.marker = Some(at);
|
||||
if let Some(l) = doc.assembly.links.first_mut() {
|
||||
l.ratio = 0.25;
|
||||
}
|
||||
let text = doc.to_json();
|
||||
let plain = Session::opened(solidcraft_doc::Document::from_json(&text).unwrap(), None);
|
||||
let opened = Session::opened_at(solidcraft_doc::Document::from_json(&text).unwrap(), None, None);
|
||||
assert!(opened.doc.feature(disc).is_some_and(|f| !f.body_names.is_empty()), "the disc after the marker has its name");
|
||||
assert_eq!(opened.doc.marker, Some(at));
|
||||
assert_eq!(opened.doc.assembly.links.first().map(|l| l.ratio), Some(0.25));
|
||||
assert_eq!(opened.doc.assembly, plain.doc.assembly);
|
||||
assert_eq!(opened.doc.occurrences, plain.doc.occurrences);
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ pub(crate) use features::{EdgeRefForm, PLANE_FORMS, axis_dir_form, axis_line_obj
|
||||
pub use features::{auto_operation, extrude_reach, shown_scope};
|
||||
pub(crate) use features::{rail_fit_form, sweep_orientation};
|
||||
pub(crate) use features_more::{MAX_SKETCH_ID, SidePoint, SketchFrameId, axes_mask, side_point, sketch_frame_id};
|
||||
pub(crate) use file::import::realign_imports;
|
||||
pub(crate) use file::mesh_crease_angle;
|
||||
pub use file::open_steps::OpenSteps;
|
||||
pub(crate) use file::{NAMES_MESSAGE, UP_MESSAGE, names_form, up_form, usable_path};
|
||||
|
||||
@@ -45,8 +45,7 @@ fn lines_drawn_from_offset_spline_ends_get_their_own_coincident_points() {
|
||||
assert!(sk.points[*l1_end].pos.dist(sk.points[start].pos) < 1e-9);
|
||||
|
||||
// Moving the source moves the offset, and the lines follow its ends.
|
||||
let from = sk.point(sk.resolve_point("s1.start").unwrap()).unwrap();
|
||||
run(&mut s, "sketch.drag", json!({"entity":"s1.start","from":[from.x, from.y],"to":[from.x - 3.0, from.y + 2.0]}));
|
||||
run(&mut s, "sketch.move", json!({"entities":["s1"],"translate":[-3.0,2.0]}));
|
||||
let moved = solved(&s, id);
|
||||
moved.validate_freeform_offsets().unwrap();
|
||||
assert!(moved.points[start].pos.dist(sk.points[start].pos) > 1.0, "the offset followed its source");
|
||||
|
||||
@@ -1370,6 +1370,39 @@ fn circular_pattern_of_a_moved_copy_adds_one_body_per_instance() {
|
||||
assert!(far > 20.0, "the repeats follow the redefined Move: {far}");
|
||||
}
|
||||
|
||||
/// A pattern of bodies makes copies that look and weigh like their sources (#12115): the same
|
||||
/// part in another place. Two bodies, three along X: each source's two copies take its own
|
||||
/// appearance or material, not the other source's and not the default.
|
||||
#[test]
|
||||
fn a_body_pattern_keeps_each_sources_appearance_and_material() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"center":[0,0,5],"operation":"new","body_name":"Base"}));
|
||||
run(&mut s, "solid.cylinder", json!({"diameter":6,"height":20,"base":[0,0,10],"operation":"new","body_name":"Post"}));
|
||||
run(&mut s, "appearance.assign", json!({"bodies":["Post"],"color":[200,30,30]}));
|
||||
run(&mut s, "material.assign", json!({"bodies":["Base"],"material":"Steel"}));
|
||||
run(&mut s, "solid.pattern.rectangular", json!({"bodies":["Base","Post"],"dir1":[1,0,0],"count1":3,"spacing1":30}));
|
||||
let red = s.doc.appearances.bodies.get("Post").cloned().expect("Post is red");
|
||||
for copy in ["Base (2)", "Base (3)"] {
|
||||
assert_eq!(s.doc.materials.get(copy).map(String::as_str), Some("Steel"), "{copy}");
|
||||
assert!(!s.doc.appearances.bodies.contains_key(copy), "{copy} was not painted");
|
||||
}
|
||||
for copy in ["Post (2)", "Post (3)"] {
|
||||
assert_eq!(s.doc.appearances.bodies.get(copy), Some(&red), "{copy}");
|
||||
assert!(!s.doc.materials.contains_key(copy), "{copy} has no material of its own");
|
||||
}
|
||||
}
|
||||
|
||||
/// A pattern of bodies that makes no copy (a count of 1) is still added: the copies' look is
|
||||
/// copied only where there are copies.
|
||||
#[test]
|
||||
fn a_body_pattern_with_no_copies_is_still_added() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"center":[0,0,5],"operation":"new","body_name":"Base"}));
|
||||
run(&mut s, "material.assign", json!({"bodies":["Base"],"material":"Steel"}));
|
||||
run(&mut s, "solid.pattern.rectangular", json!({"bodies":["Base"],"dir1":[1,0,0],"count1":1,"spacing1":30}));
|
||||
assert_eq!(s.model.state().bodies.len(), 1);
|
||||
}
|
||||
|
||||
/// #12199: a path whose curves are listed out of chain order is the same path as in chain order.
|
||||
#[test]
|
||||
fn pattern_on_path_orders_the_curves_into_one_chain() {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Laying bodies on their print face for a slicer (#8429). The bodies being sent are copied (the
|
||||
//! Laying bodies on their print face for a slicer (#8429), or for an STL or 3MF export with
|
||||
//! `lay_on_print_faces`. The bodies being sent are copied (the
|
||||
//! state the export writes is already a copy), and each copy whose body has a print face is
|
||||
//! turned so the face points down, its centre of mass moved onto the world origin in X and Y,
|
||||
//! and the face moved onto Z = 0: one rigid transform, applied once. The design and its timeline
|
||||
@@ -20,7 +21,7 @@ pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("body.print_face", "Pri
|
||||
.key("Ctrl+B")
|
||||
.contract(&PRINT_FACE_CONTRACT)
|
||||
.output(crate::schema::output_schema_for::<PrintFaceResult>)
|
||||
.params("body: its name; point?: [x, y, z] on a flat face of it, in mm (omit to clear) → {body, face?}: marks the face a slicer export (file.send_to_slicer, file.save_mesh with slicer, file.open_in_easyprint) lays the body on: turned so the face points down at Z = 0, the centre of mass over the origin. One per body, replacing the last; flat faces of solid bodies only. A print face that is gone or no longer flat is reported in the export's warnings, and the body exported as it lies")];
|
||||
.params("body: its name; point?: [x, y, z] on a flat face of it, in mm (omit to clear) → {body, face?}: marks the face a slicer export (file.send_to_slicer, file.save_mesh with slicer, file.open_in_easyprint), or an STL or 3MF export with lay_on_print_faces (file.export, file.export_stl, file.export_3mf, file.save_mesh), lays the body on: turned so the face points down at Z = 0, the centre of mass over the origin. One per body, replacing the last; flat faces of solid bodies only. A print face that is gone or no longer flat is reported in the export's warnings, and the body exported as it lies")];
|
||||
|
||||
const PRINT_FACE_CONTRACT: crate::schema::TypedContract<crate::schema::print_face::PrintFaceParams> = crate::schema::TypedContract::new();
|
||||
|
||||
@@ -353,6 +354,136 @@ mod tests {
|
||||
assert_eq!(kept.doc.print_faces, s.doc.print_faces);
|
||||
}
|
||||
|
||||
/// `name` in a fresh folder of the system's temporary folder, for `case`.
|
||||
fn temp_path(case: &str, name: &str) -> String {
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-lay-on-print-faces-{case}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("temporary folder");
|
||||
dir.join(name).to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
/// The box around every triangle of the STL file at `path`, in mm.
|
||||
fn stl_box(path: &str) -> ([f64; 3], [f64; 3]) {
|
||||
let triangles = solidcraft_io::read_stl(&std::fs::read(path).expect("the STL file")).expect("an STL file");
|
||||
assert!(!triangles.is_empty(), "{path} has no triangles");
|
||||
let (mut min, mut max) = ([f64::INFINITY; 3], [f64::NEG_INFINITY; 3]);
|
||||
for p in triangles.iter().flatten() {
|
||||
for k in 0..3 {
|
||||
min[k] = min[k].min(f64::from(p[k]));
|
||||
max[k] = max[k].max(f64::from(p[k]));
|
||||
}
|
||||
}
|
||||
(min, max)
|
||||
}
|
||||
|
||||
/// Near enough for a coordinate an STL file holds as an f32.
|
||||
fn near_f32(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 1e-4
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_stl_export_lays_the_body_on_its_print_face_only_when_asked() {
|
||||
// The 10 x 5 end at X = 20 is the print face, so laid the 20 mm length stands up at Z = 0,
|
||||
// centred on the origin in X and Y.
|
||||
let mut s = block();
|
||||
run(&mut s, "body.print_face", json!({"body": "Block", "point": [20, 5, 2.5]}));
|
||||
let undo = s.undo.len();
|
||||
let laid_box = |(min, max): ([f64; 3], [f64; 3])| {
|
||||
near_f32(min[2], 0.)
|
||||
&& near_f32(max[2], 20.)
|
||||
&& near_f32(min[0] + max[0], 0.)
|
||||
&& near_f32(min[1] + max[1], 0.)
|
||||
&& near_f32((max[0] - min[0]) * (max[1] - min[1]), 50.)
|
||||
};
|
||||
for (id, name, extra) in [
|
||||
("file.export_stl", "a.stl", json!({})),
|
||||
("file.export", "b.stl", json!({})),
|
||||
("file.export", "c.stl", json!({"format": "stla"})),
|
||||
("file.save_mesh", "d.stl", json!({})),
|
||||
("file.save_mesh", "e.stl", json!({"format": "stl", "ascii": true})),
|
||||
] {
|
||||
let path = temp_path("stl", name);
|
||||
let mut p = json!({"path": path, "lay_on_print_faces": true});
|
||||
p.as_object_mut().expect("object").extend(extra.as_object().expect("object").clone());
|
||||
let reply = run(&mut s, id, p);
|
||||
assert_eq!(reply["print_faces"], json!(["Block"]), "{id}: {reply}");
|
||||
assert!(reply.get("warnings").is_none(), "{id}: {reply}");
|
||||
let bbox = stl_box(&path);
|
||||
assert!(laid_box(bbox), "{id} {name}: {bbox:?}");
|
||||
// Without the key, or with it false, the body is written as it lies in the design.
|
||||
for lay in [json!(null), json!(false)] {
|
||||
let path = temp_path("stl-as-it-lies", name);
|
||||
let mut p = json!({"path": path, "lay_on_print_faces": lay});
|
||||
p.as_object_mut().expect("object").extend(extra.as_object().expect("object").clone());
|
||||
let reply = run(&mut s, id, p);
|
||||
assert!(reply.get("print_faces").is_none(), "{id}: {reply}");
|
||||
let (min, max) = stl_box(&path);
|
||||
assert!(near_f32(min[0], 0.) && near_f32(max[0], 20.) && near_f32(min[2], 0.) && near_f32(max[2], 5.), "{id}: {min:?} {max:?}");
|
||||
}
|
||||
}
|
||||
// The design itself has not moved, and nothing was added to undo.
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
let m = solidcraft_kernel::measure_mass(&s.model.state().bodies[0].body).expect("measure");
|
||||
assert!(near(m.bbox.min.x, 0.) && near(m.bbox.max.x, 20.) && near(m.bbox.max.z, 5.), "{:?}", m.bbox);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_3mf_export_reports_the_bodies_it_laid_on_their_print_face() {
|
||||
let mut s = block();
|
||||
run(&mut s, "body.print_face", json!({"body": "Block", "point": [20, 5, 2.5]}));
|
||||
for (id, name) in [("file.export_3mf", "a.3mf"), ("file.export", "b.3mf"), ("file.save_mesh", "c.3mf")] {
|
||||
let path = temp_path("3mf", name);
|
||||
let reply = run(&mut s, id, json!({"path": path, "lay_on_print_faces": true}));
|
||||
assert_eq!(reply["print_faces"], json!(["Block"]), "{id}: {reply}");
|
||||
assert!(std::fs::metadata(&path).is_ok_and(|m| m.len() > 0), "{id}: {path}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_export_with_lay_on_print_faces_writes_a_body_without_one_as_it_lies() {
|
||||
// A second body with no print face lies where it is; a print face cut away is reported.
|
||||
let mut s = block();
|
||||
run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "corner": [50, 0, 0], "body_name": "Cube", "operation": "new"}));
|
||||
let path = temp_path("none", "cube.stl");
|
||||
let reply = run(&mut s, "file.export_stl", json!({"path": path, "bodies": ["Cube"], "lay_on_print_faces": true}));
|
||||
assert!(reply.get("print_faces").is_none() && reply.get("warnings").is_none(), "{reply}");
|
||||
let (min, max) = stl_box(&path);
|
||||
assert!(near_f32(min[0], 50.) && near_f32(max[0], 54.) && near_f32(max[2], 4.), "{min:?} {max:?}");
|
||||
run(&mut s, "body.print_face", json!({"body": "Block", "point": [10, 5, 5]}));
|
||||
run(&mut s, "solid.box", json!({"length": 30, "width": 20, "height": 5, "corner": [-5, -5, 3], "operation": "cut"}));
|
||||
let path = temp_path("cut", "block.stl");
|
||||
let reply = run(&mut s, "file.export_stl", json!({"path": path, "bodies": ["Block"], "lay_on_print_faces": true}));
|
||||
assert!(reply.get("print_faces").is_none(), "{reply}");
|
||||
assert!(reply["warnings"].as_array().is_some_and(|w| w.len() == 1 && w[0].as_str().is_some_and(|w| w.contains("Block"))), "{reply}");
|
||||
let (min, max) = stl_box(&path);
|
||||
assert!(near_f32(min[0], 0.) && near_f32(max[0], 20.) && near_f32(max[2], 3.), "{min:?} {max:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lay_on_print_faces_is_refused_for_a_format_that_is_not_printed_or_a_value_that_is_not_true_or_false() {
|
||||
let mut s = block();
|
||||
run(&mut s, "body.print_face", json!({"body": "Block", "point": [20, 5, 2.5]}));
|
||||
for (id, name, lay) in [
|
||||
("file.export", "a.step", json!(true)),
|
||||
("file.export", "a.obj", json!(true)),
|
||||
("file.export", "a.glb", json!(true)),
|
||||
("file.export", "a.json", json!(true)),
|
||||
("file.export", "b.stl", json!("yes")),
|
||||
("file.export_stl", "c.stl", json!(1)),
|
||||
("file.export_3mf", "d.3mf", json!([true])),
|
||||
("file.save_mesh", "e.stl", json!({})),
|
||||
] {
|
||||
let path = temp_path("refused", name);
|
||||
let e = s.execute(id, &json!({"path": path, "lay_on_print_faces": lay})).expect_err(id).to_string();
|
||||
assert!(e.contains("lay_on_print_faces"), "{id} {name}: {e}");
|
||||
assert!(std::fs::metadata(&path).is_err(), "{id} {name}: a refused export writes nothing");
|
||||
}
|
||||
// False is accepted for any format: the file is written as the design has it.
|
||||
let path = temp_path("false", "a.step");
|
||||
run(&mut s, "file.export", json!({"path": path, "lay_on_print_faces": false}));
|
||||
assert!(std::fs::metadata(&path).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renaming_the_body_keeps_its_print_face() {
|
||||
let mut s = block();
|
||||
|
||||
@@ -45,7 +45,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.contract(&FLANGE_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params(
|
||||
"base: sketch + profiles? (rule?, flip?) | contour: sketch + curves (open chain of lines) + distance (rule?, flip?, reverse?) | \
|
||||
"base: sketch + profiles? (rule?, flip?) | contour: sketch + curves (open chain of lines and tangent arcs) + distance (rule?, flip?, reverse?) | \
|
||||
edge: edges: [[x,y,z] on top/bottom edges, or {point: [x,y,z], angle?, height?, segments?} for an edge with its own angle and height, \
|
||||
or split into 1…64 segments [{from, to, angle?, height?, flip?}] that tile the stretch the sheet still has there (from, to are \
|
||||
fractions 0…1 of it, first from 0, last to 1; consecutive segments that match make one bend)] + \
|
||||
@@ -67,7 +67,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.contract(&PATH_FLANGE_CONTRACT)
|
||||
.output(output_schema_for::<FeatureResult>)
|
||||
.params(
|
||||
"edge: [x,y,z] on a top or bottom edge of the sheet; sketch + curves: an open chain of sketch lines drawn on a plane across \
|
||||
"edge: [x,y,z] on a top or bottom edge of the sheet; sketch + curves: an open chain of sketch lines and tangent arcs drawn on a plane across \
|
||||
that edge, from the edge out (each line one flange as long as the line, to the outer face from the virtual sharp; each \
|
||||
corner one bend, turning the way the lines turn); radius? (default: rule); body?. One feature, one body",
|
||||
),
|
||||
@@ -421,7 +421,7 @@ fn flange(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let sketch = sketch_id(s, p.get("sketch"), cmd, "sketch")?;
|
||||
let curves = string_list(cmd, p, "curves")?;
|
||||
if curves.is_empty() || curves.len() > 1000 {
|
||||
return Err(bad(cmd, "`curves` must list the open chain's lines"));
|
||||
return Err(bad(cmd, "`curves` must list the open chain's lines and arcs"));
|
||||
}
|
||||
let distance = req_expr(cmd, p, "distance")?;
|
||||
check_expr(s, &distance, Kind::Length, cmd, "distance")?;
|
||||
|
||||
@@ -621,6 +621,49 @@ fn contour_flange_hem_and_tray() {
|
||||
assert!(rel(v, want) < 1e-4 && rel(v, 29666.234) < 1e-4, "{v} {want}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contour_flange_takes_tangent_arcs_and_develops_their_bends() {
|
||||
// A 90° arc after a 30 mm line and before a 20 mm line. The chain turns toward the material,
|
||||
// so the arc is the outer surface: radius 10 with the bend's inner radius 10 - 2 = 8.
|
||||
// Developed length = 30 + (π/2)(8 + 0.5·2) + 20.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sheet.manage_rules", json!({"name": "R", "thickness": 2, "k_factor": 0.5, "bend_radius": 2, "active": true}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Arc"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[0, 0], [30, 0]], "ids": ["l1"]}));
|
||||
let h = 10.0 * std::f64::consts::FRAC_1_SQRT_2;
|
||||
run(&mut s, "sketch.arc.three_point", json!({"start": [30, 0], "end": [40, -10], "through": [30.0 + h, -10.0 + h], "id": "a1"}));
|
||||
run(&mut s, "sketch.line", json!({"points": [[40, -10], [40, -30]], "ids": ["l2"]}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let r = run(&mut s, "sheet.flange", json!({"sketch": "Arc", "curves": ["l1", "a1", "l2"], "distance": 50, "rule": "R"}));
|
||||
assert!(r.get("error").is_none(), "{r}");
|
||||
let f = flat(&mut s);
|
||||
let want = 50.0 + FRAC_PI_2 * (8.0 + 0.5 * 2.0);
|
||||
let got = f["flat_size_mm"][0].as_f64().unwrap_or(f64::NAN);
|
||||
assert!((got - want).abs() < 1e-6, "{got} vs {want}: {f}");
|
||||
let bends = f["bends"].as_array().cloned().unwrap_or_default();
|
||||
assert_eq!(bends.len(), 1, "{f}");
|
||||
assert!((bends[0]["radius"].as_f64().unwrap_or(0.0) - 8.0).abs() < 1e-9, "{f}");
|
||||
assert!((bends[0]["angle_deg"].as_f64().unwrap_or(0.0) - 90.0).abs() < 1e-9, "{f}");
|
||||
assert!((bends[0]["allowance"].as_f64().unwrap_or(0.0) - FRAC_PI_2 * 9.0).abs() < 1e-9, "{f}");
|
||||
// A 180° half pipe alone: outer radius 56 and 1.5 thick, with no flat panel before or
|
||||
// after it.
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sheet.manage_rules", json!({"name": "R", "thickness": 1.5, "k_factor": 0.5, "bend_radius": 1.5, "active": true}));
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Trough"}));
|
||||
run(&mut s, "sketch.arc.three_point", json!({"start": [56, 0], "end": [-56, 0], "through": [0, -56], "id": "a1"}));
|
||||
run(&mut s, "sketch.finish", json!({}));
|
||||
let r = run(&mut s, "sheet.flange", json!({"sketch": "Trough", "curves": ["a1"], "distance": 100, "rule": "R", "flip": true}));
|
||||
assert!(r.get("error").is_none(), "{r}");
|
||||
let want = PI / 2.0 * (56.0f64.powi(2) - 54.5f64.powi(2)) * 100.0;
|
||||
assert!(rel(volume(&mut s), want) < 1e-6, "{} vs {want}", volume(&mut s));
|
||||
let b = bbox(&mut s);
|
||||
assert!((b["min"][1].as_f64().unwrap_or(f64::NAN) + 56.0).abs() < 1e-6, "{b}");
|
||||
assert!(b["max"][1].as_f64().unwrap_or(f64::NAN).abs() < 1e-6, "{b}");
|
||||
let f = flat(&mut s);
|
||||
let want = PI * (54.5 + 0.5 * 1.5);
|
||||
assert!((f["flat_size_mm"][0].as_f64().unwrap_or(f64::NAN) - want).abs() < 1e-6, "{f}");
|
||||
}
|
||||
|
||||
/// A 150 x 100 base on a 2 mm rule with one 40 mm wall on its left edge: the part a chained
|
||||
/// flap (#4094) is made on.
|
||||
fn wall_bracket(s: &mut Session) {
|
||||
@@ -1298,6 +1341,32 @@ fn fold_on_a_flange_panel() {
|
||||
assert!(s.execute("sheet.fold", &json!({"points": [[50, y, 2], [50, y, 25]]})).is_err());
|
||||
}
|
||||
|
||||
/// A sharp bend (radius 0) folds the sheet's outer face only: the inner face has collapsed to
|
||||
/// the edge where the two panels meet, so the body is closed with one cylindrical face per
|
||||
/// bend and no zero-area inner cylinder. Three 90° folds on a 1.2 mm sheet: each bend zone's
|
||||
/// flat strip of width BA becomes a quarter disc of radius T.
|
||||
#[test]
|
||||
fn a_sharp_bend_has_only_its_outer_cylindrical_face() {
|
||||
let (t, k, w) = (1.2, 0.44, 60.0);
|
||||
let ba = FRAC_PI_2 * (k * t);
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sheet.manage_rules", json!({"name": "Sharp", "thickness": t, "k_factor": k, "bend_radius": 0, "active": true}));
|
||||
base(&mut s, 100.0, w);
|
||||
let fixed = json!([10, 30, t]);
|
||||
run(&mut s, "sheet.fold", json!({"points": [[80, 0, t], [80, w, t]], "fixed": fixed, "flip": false, "radius": 0}));
|
||||
run(&mut s, "sheet.fold", json!({"points": [[60, 0, t], [60, w, t]], "fixed": fixed, "flip": false, "radius": 0}));
|
||||
run(&mut s, "sheet.fold", json!({"points": [[20, 0, t], [20, w, t]], "fixed": fixed, "flip": true, "radius": 0}));
|
||||
// The flat strip each bend takes (BA wide) becomes a quarter disc of radius T.
|
||||
let want = 100.0 * w * t + 3.0 * (FRAC_PI_2 * t * (t / 2.0) * w - ba * t * w);
|
||||
assert!(rel(volume(&mut s), want) < 1e-4, "{} {want}", volume(&mut s));
|
||||
let body = &s.model.state().bodies[0].body;
|
||||
assert!(body.validity().is_empty() && body.shell_count() == 1, "{:?}", body.validity());
|
||||
let cylinders = body.faces(1e-6).map(|f| f.iter().filter(|f| f.plane_normal.is_none()).count()).unwrap_or(0);
|
||||
assert_eq!(cylinders, 3, "one outer face per sharp bend, no inner cylinders");
|
||||
let bends = flat(&mut s)["bends"].clone();
|
||||
assert_eq!(bends.as_array().map(Vec::len), Some(3), "{bends}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fold_through_cut_outs() {
|
||||
let mut s = Session::default();
|
||||
|
||||
@@ -20,8 +20,9 @@ use crate::params::{bad, num, req_vec2, str_, string_list};
|
||||
use crate::schema::sketch_create::{
|
||||
ArcSlotCenterParams, ArcSlotResult, ArcSlotThreePointParams, ArcTangentParams, BlendCurveParams, CenterlineParams, ConicParams,
|
||||
ControlPointSplineParams, DrawResult, EditTextParams, EditTextResult, EllipseParams, FitPointSplineParams, InfiniteLineParams, LineTypeResult,
|
||||
MidpointLineParams, SketchTextParams, SlotCenterPointParams, SplineRemovePointParams, SplineRemovePointResult, SplineReverseParams,
|
||||
SplineReverseResult, TextChange, TextEditsResult, TextExpressions, TextResult, ThreeTangentCircleParams, TwoTangentCircleParams,
|
||||
MidpointLineParams, SketchTextParams, SlotCenterPointParams, SplineHandlesParams, SplineHandlesResult, SplineRemovePointParams,
|
||||
SplineRemovePointResult, SplineResetHandlesParams, SplineResetHandlesResult, SplineReverseParams, SplineReverseResult, TextChange,
|
||||
TextEditsResult, TextExpressions, TextResult, ThreeTangentCircleParams, TwoTangentCircleParams,
|
||||
};
|
||||
use crate::schema::{TypedContract, output_schema_for};
|
||||
use crate::{EngineError, Result, Session};
|
||||
@@ -52,6 +53,8 @@ const TEXT_CONTRACT: TypedContract<SketchTextParams> = TypedContract::new();
|
||||
const EDIT_TEXT_CONTRACT: TypedContract<EditTextParams> = TypedContract::new();
|
||||
const SPLINE_REVERSE_CONTRACT: TypedContract<SplineReverseParams> = TypedContract::new();
|
||||
const SPLINE_REMOVE_POINT_CONTRACT: TypedContract<SplineRemovePointParams> = TypedContract::new();
|
||||
const SPLINE_HANDLES_CONTRACT: TypedContract<SplineHandlesParams> = TypedContract::new();
|
||||
const SPLINE_RESET_HANDLES_CONTRACT: TypedContract<SplineResetHandlesParams> = TypedContract::new();
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("sketch.line.midpoint", "Midpoint Line", crate::checked!("sketch.line.midpoint", midpoint_line))
|
||||
@@ -122,7 +125,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.enabled(in_sketch)
|
||||
.contract(&SPLINE_FIT_CONTRACT)
|
||||
.output(output_schema_for::<DrawResult>)
|
||||
.params(concat!("points: [[x,y] or point refs…] (2…500): the spline passes through them, periodic?: boolean (default false); periodic cubic fit closes smoothly through at least 3 distinct samples", created_doc!())),
|
||||
.params(concat!("points: [[x,y] or point refs…] (2…500): the spline passes through them; each fit point has an editable tangent handle whose direction and length control curvature, periodic?: boolean (default false); periodic cubic fit closes smoothly through at least 3 distinct samples", created_doc!())),
|
||||
CommandSpec::new("sketch.spline.control_point", "Control Point Spline", crate::checked!("sketch.spline.control_point", spline_cv3))
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("spline_cv")
|
||||
@@ -147,6 +150,18 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.contract(&SPLINE_REMOVE_POINT_CONTRACT)
|
||||
.output(output_schema_for::<SplineRemovePointResult>)
|
||||
.params("curve: spline id, point: one of its points (a point id, or the curve's start or end such as s1.end): the spline runs through (or over) its other points only, and the point goes with the constraints on it unless another curve uses it. Fit point, control point and closed splines. Refused for a closed spline's closing point, for an end joined to other geometry or held by a constraint, when fewer than 2 points would be left (3 on a closed spline), and for a projected, offset or patterned spline. Returns curve, point: the point's id, deleted: whether the point went too"),
|
||||
CommandSpec::new("sketch.spline.handles", "Add Tangent Handles", crate::checked!("sketch.spline.handles", spline_handles))
|
||||
.icon("spline")
|
||||
.enabled(in_sketch)
|
||||
.contract(&SPLINE_HANDLES_CONTRACT)
|
||||
.output(output_schema_for::<SplineHandlesResult>)
|
||||
.params("curve: fit point spline id: gives it a tangent handle at each fit point (a free point joined to it by a construction line) where the curve already runs, so its shape does not change until a handle is moved; moving a fit point moves the handles nobody has turned with it. In the app a selected spline shows its handles and the first drag of one adds them. Refused for a spline that has handles already, a control point, projected, offset, patterned or equation spline. Returns curve, handles: the handle point ids in fit point order"),
|
||||
CommandSpec::new("sketch.spline.reset_handles", "Reset Tangent Handles", crate::checked!("sketch.spline.reset_handles", spline_reset_handles))
|
||||
.icon("spline")
|
||||
.enabled(in_sketch)
|
||||
.contract(&SPLINE_RESET_HANDLES_CONTRACT)
|
||||
.output(output_schema_for::<SplineResetHandlesResult>)
|
||||
.params("curve: fit point spline id: removes its tangent handles (the handle points, their construction lines and the constraints on them), so the curve runs through its fit points as if the handles had never been moved; selected, it shows them again where they start. Refused for a spline without tangent handles. Returns curve, removed: the removed handle point ids in fit point order"),
|
||||
CommandSpec::new("sketch.spline.reverse", "Reverse Spline Direction", crate::checked!("sketch.spline.reverse", spline_reverse))
|
||||
.icon("spline")
|
||||
.enabled(in_sketch)
|
||||
@@ -619,7 +634,10 @@ fn blend(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (sp, cons) = if order <= 1 {
|
||||
let c1 = sk.add_point(qa + ta * (l / 3.0), None)?;
|
||||
let c2 = sk.add_point(qb + tb * (l / 3.0), None)?;
|
||||
let sp = sk.add_curve(CurveKind::Spline { pts: vec![pa, c1, c2, pb], control: true, degree: 3, periodic: false, nurbs: None }, None)?;
|
||||
let sp = sk.add_curve(
|
||||
CurveKind::Spline { pts: vec![pa, c1, c2, pb], control: true, degree: 3, periodic: false, nurbs: None, handles: Vec::new() },
|
||||
None,
|
||||
)?;
|
||||
let cons = vec![add_c(sk, ConstraintKind::Tangent { a: ca, b: sp })?, add_c(sk, ConstraintKind::Tangent { a: cb, b: sp })?];
|
||||
(sp, cons)
|
||||
} else {
|
||||
@@ -641,7 +659,7 @@ fn blend(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
});
|
||||
}
|
||||
let degree = u8::try_from(cps.len().saturating_sub(1)).map_err(|_| bad(cmd, "the blend curve needs too many control points"))?;
|
||||
let sp = sk.add_curve(CurveKind::Spline { pts, control: true, degree, periodic: false, nurbs: None }, None)?;
|
||||
let sp = sk.add_curve(CurveKind::Spline { pts, control: true, degree, periodic: false, nurbs: None, handles: Vec::new() }, None)?;
|
||||
(sp, Vec::new())
|
||||
};
|
||||
solve_new_only(sk, first_new);
|
||||
@@ -795,7 +813,7 @@ fn spline(s: &mut Session, p: &Value, cmd: &str, control: bool, degree: u8) -> R
|
||||
{
|
||||
pts.push(*pts.first().ok_or_else(|| bad(cmd, "missing first point"))?);
|
||||
}
|
||||
let c = sk.add_curve(CurveKind::Spline { pts, control, degree, periodic, nurbs: None }, None)?;
|
||||
let c = sk.add_curve(CurveKind::Spline { pts, control, degree, periodic, nurbs: None, handles: Vec::new() }, None)?;
|
||||
mark_construction(sk, &[c], p);
|
||||
Ok((ids_of(sk, &[c]), Vec::new()))
|
||||
})?;
|
||||
@@ -850,6 +868,58 @@ fn spline_remove_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
fn spline_handles(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.spline.handles";
|
||||
let id = str_(p, "curve").ok_or_else(|| bad(cmd, "`curve` must be a spline id"))?.to_string();
|
||||
let (handles, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let c = sk.curve_index(&id).ok_or_else(|| bad(cmd, format!("unknown curve `{id}`")))?;
|
||||
if matches!(sk.curves.get(c).map(|x| &x.kind), Some(CurveKind::Spline { handles, .. }) if !handles.is_empty()) {
|
||||
return Err(bad(cmd, format!("`{id}` already has tangent handles")));
|
||||
}
|
||||
sk.add_spline_handles(c).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
Ok(match sk.curves.get(c).map(|x| &x.kind) {
|
||||
Some(CurveKind::Spline { handles, .. }) => handles.iter().filter_map(|h| sk.points.get(*h).map(|p| p.id.clone())).collect(),
|
||||
_ => Vec::new(),
|
||||
})
|
||||
})?;
|
||||
let reply = SplineHandlesResult { curve: id, handles, sketch: info, param_warnings: Vec::new() };
|
||||
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
fn spline_reset_handles(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.spline.reset_handles";
|
||||
let id = str_(p, "curve").ok_or_else(|| bad(cmd, "`curve` must be a spline id"))?.to_string();
|
||||
let (removed, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let c = sk.curve_index(&id).ok_or_else(|| bad(cmd, format!("unknown curve `{id}`")))?;
|
||||
let (spline, handles) = match sk.curves.get(c) {
|
||||
Some(curve) => match &curve.kind {
|
||||
CurveKind::Spline { handles, .. } => (curve.id.clone(), handles.clone()),
|
||||
_ => return Err(bad(cmd, format!("`{id}` is not a spline"))),
|
||||
},
|
||||
None => return Err(bad(cmd, format!("unknown curve `{id}`"))),
|
||||
};
|
||||
if handles.is_empty() {
|
||||
return Err(bad(cmd, format!("`{id}` has no tangent handles")));
|
||||
}
|
||||
let removed: Vec<String> = handles.iter().filter_map(|h| sk.points.get(*h).map(|q| q.id.clone())).collect();
|
||||
// Removing the construction lines takes the handles with them: the spline lets go of its
|
||||
// handle points, which then belong to no curve and go with the constraints on them.
|
||||
let guides: Vec<usize> =
|
||||
sk.curves.iter().enumerate().filter(|(_, x)| x.spline_handle_of.as_deref() == Some(spline.as_str())).map(|(i, _)| i).collect();
|
||||
sk.remove_curves(&guides);
|
||||
let cleared = sk
|
||||
.curve_index(&spline)
|
||||
.and_then(|i| sk.curves.get(i))
|
||||
.is_some_and(|x| matches!(&x.kind, CurveKind::Spline { handles, .. } if handles.is_empty()));
|
||||
if !cleared || removed.iter().any(|h| sk.resolve_point(h).is_some()) {
|
||||
return Err(bad(cmd, format!("`{id}`'s tangent handles could not be removed")));
|
||||
}
|
||||
Ok(removed)
|
||||
})?;
|
||||
let reply = SplineResetHandlesResult { curve: id, removed, sketch: info, param_warnings: Vec::new() };
|
||||
serde_json::to_value(reply).map_err(|e| EngineError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
fn spline_reverse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "sketch.spline.reverse";
|
||||
let id = str_(p, "curve").ok_or_else(|| bad(cmd, "`curve` must be a spline id"))?.to_string();
|
||||
@@ -934,12 +1004,21 @@ pub(super) fn leaving_dir(sk: &Sketch, ci: usize, pi: usize) -> Option<Vec2> {
|
||||
None
|
||||
}
|
||||
}
|
||||
CurveKind::Spline { ref pts, control, periodic, .. } => {
|
||||
CurveKind::Spline { ref pts, control, periodic, ref handles, .. } => {
|
||||
let q: Vec<Vec2> = pts.iter().filter_map(|i| sk.point(*i)).collect();
|
||||
let tangent_handles: Vec<Vec2> = handles.iter().filter_map(|i| sk.point(*i)).collect();
|
||||
if pts.last() == Some(&pi) {
|
||||
solidcraft_sketch::spline_end_tangent_mode(&q, control, periodic, true)
|
||||
if handles.is_empty() {
|
||||
solidcraft_sketch::spline_end_tangent_mode(&q, control, periodic, true)
|
||||
} else {
|
||||
solidcraft_sketch::spline_end_tangent_with_handles(&q, &tangent_handles, periodic, true)
|
||||
}
|
||||
} else if pts.first() == Some(&pi) {
|
||||
solidcraft_sketch::spline_end_tangent_mode(&q, control, periodic, false).map(|t| -t)
|
||||
if handles.is_empty() {
|
||||
solidcraft_sketch::spline_end_tangent_mode(&q, control, periodic, false).map(|t| -t)
|
||||
} else {
|
||||
solidcraft_sketch::spline_end_tangent_with_handles(&q, &tangent_handles, periodic, false).map(|t| -t)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -1321,6 +1400,110 @@ mod blend_tests {
|
||||
s.model.state().sketch(id).unwrap().sketch.clone()
|
||||
}
|
||||
|
||||
/// #6502: Reset Tangent Handles takes a spline's handles, their construction lines and the
|
||||
/// constraints on them away, so the curve runs through its fit points as before.
|
||||
#[test]
|
||||
fn reset_tangent_handles_gives_back_the_spline_through_its_fit_points() {
|
||||
let mut s = new_sketch();
|
||||
let spline_id =
|
||||
ids(&run(&mut s, "sketch.spline.fit_point", json!({"points": [[-40, 0], [-15, 20], [15, -10], [40, 10]]}))["curves"])[0].clone();
|
||||
assert!(s.execute("sketch.spline.reset_handles", &json!({"curve": spline_id})).is_err(), "nothing to reset yet");
|
||||
let constraints = sketch(&s).constraints.len();
|
||||
let points = sketch(&s).points.len();
|
||||
let given = run(&mut s, "sketch.spline.handles", json!({"curve": spline_id}));
|
||||
let turned = given["handles"][1].as_str().unwrap().to_string();
|
||||
let at = {
|
||||
let sk = sketch(&s);
|
||||
sk.resolve_point(&turned).and_then(|i| sk.point(i)).unwrap()
|
||||
};
|
||||
run(&mut s, "sketch.drag", json!({"entity": turned, "from": [at.x, at.y], "to": [at.x - 3.0, at.y + 8.0]}));
|
||||
run(&mut s, "sketch.drag_end", json!({}));
|
||||
let guide = sketch(&s).curves.iter().find(|c| c.spline_handle_of.as_deref() == Some(spline_id.as_str())).unwrap().id.clone();
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [guide], "value": 6}));
|
||||
assert!(sketch(&s).constraints.len() > constraints, "the guide is dimensioned");
|
||||
// The dimension may move the fit point too; the reset keeps the fit points where they are.
|
||||
let fit_before: Vec<Vec2> = {
|
||||
let sk = sketch(&s);
|
||||
let CurveKind::Spline { pts, .. } = &sk.curves[sk.curve_index(&spline_id).unwrap()].kind else { panic!("a fit spline") };
|
||||
pts.iter().map(|p| sk.point(*p).unwrap()).collect()
|
||||
};
|
||||
|
||||
let reset = run(&mut s, "sketch.spline.reset_handles", json!({"curve": spline_id}));
|
||||
assert_eq!(reset["removed"].as_array().map(Vec::len), Some(4), "{reset}");
|
||||
let sk = sketch(&s);
|
||||
let CurveKind::Spline { pts, handles, .. } = &sk.curves[sk.curve_index(&spline_id).unwrap()].kind else { panic!("a fit spline") };
|
||||
assert!(handles.is_empty(), "no handles left");
|
||||
assert!(sk.curves.iter().all(|c| c.spline_handle_of.is_none()), "no tangent lines left");
|
||||
assert!(reset["removed"].as_array().unwrap().iter().all(|h| sk.resolve_point(h.as_str().unwrap()).is_none()), "the handle points went");
|
||||
assert_eq!(sk.points.len(), points);
|
||||
assert_eq!(sk.constraints.len(), constraints, "the guide's dimension went with it");
|
||||
let fit_after: Vec<Vec2> = pts.iter().map(|p| sk.point(*p).unwrap()).collect();
|
||||
assert_eq!(fit_after, fit_before, "the fit points stay");
|
||||
drop(sk);
|
||||
assert!(s.execute("sketch.spline.reset_handles", &json!({"curve": spline_id})).is_err(), "twice is refused");
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
let sk = sketch(&s);
|
||||
assert!(
|
||||
matches!(&sk.curves[sk.curve_index(&spline_id).unwrap()].kind, CurveKind::Spline { handles, .. } if handles.len() == 4),
|
||||
"undo brings them back"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_spline_handles_are_visible_lines_and_accept_existing_constraints() {
|
||||
let mut s = new_sketch();
|
||||
let spline_id = ids(&run(&mut s, "sketch.spline.fit_point", json!({"points": [[0, 0], [8, 6], [17, 2], [25, 10]]}))["curves"])[0].clone();
|
||||
assert!(
|
||||
matches!(&sketch(&s).curves[sketch(&s).curve_index(&spline_id).unwrap()].kind, CurveKind::Spline { handles, .. } if handles.is_empty()),
|
||||
"no handles until asked"
|
||||
);
|
||||
let given = run(&mut s, "sketch.spline.handles", json!({"curve": spline_id}));
|
||||
assert_eq!(given["handles"].as_array().map(Vec::len), Some(4), "{given}");
|
||||
assert!(s.execute("sketch.spline.handles", &json!({"curve": spline_id})).is_err(), "twice is refused");
|
||||
let sk = sketch(&s);
|
||||
let spline_index = sk.curve_index(&spline_id).unwrap();
|
||||
let CurveKind::Spline { pts, handles, .. } = &sk.curves[spline_index].kind else { panic!("expected a fit spline") };
|
||||
assert_eq!(handles.len(), pts.len());
|
||||
let tangent_lines: Vec<_> = sk.curves.iter().filter(|curve| curve.spline_handle_of.as_deref() == Some(spline_id.as_str())).collect();
|
||||
assert_eq!(tangent_lines.len(), pts.len());
|
||||
assert!(tangent_lines.iter().all(|curve| curve.construction));
|
||||
let handle_id = tangent_lines[0].id.clone();
|
||||
let angle_handle_id = tangent_lines[1].id.clone();
|
||||
let final_handle = *handles.last().unwrap();
|
||||
let final_fit_point = *pts.last().unwrap();
|
||||
assert!(
|
||||
matches!(tangent_lines.last().map(|curve| &curve.kind), Some(CurveKind::Line { a, b }) if *a == final_handle && *b == final_fit_point)
|
||||
);
|
||||
drop(sk);
|
||||
let before = s.doc.to_json();
|
||||
assert!(
|
||||
s.execute("sketch.trim", &json!({"curve": &handle_id, "at": [4, 3]})).is_err(),
|
||||
"a tangent construction line cannot be trimmed independently of its spline"
|
||||
);
|
||||
assert_eq!(s.doc.to_json(), before);
|
||||
|
||||
let reference_id = ids(&run(&mut s, "sketch.line", json!({"points": [[40, 0], [40, 20]]}))["curves"])[0].clone();
|
||||
run(&mut s, "sketch.constraint.perpendicular", json!({"a": handle_id, "b": reference_id}));
|
||||
let solved = sketch(&s);
|
||||
let handle = solved.curve_index(&handle_id).unwrap();
|
||||
let reference = solved.curve_index(&reference_id).unwrap();
|
||||
let direction = |index: usize| match solved.curves[index].kind {
|
||||
CurveKind::Line { a, b } => solved.point(b).unwrap() - solved.point(a).unwrap(),
|
||||
ref other => panic!("expected a line, got {other:?}"),
|
||||
};
|
||||
assert!(direction(handle).dot(direction(reference)).abs() < 1e-7);
|
||||
|
||||
let angle_reference = ids(&run(&mut s, "sketch.line", json!({"points": [[50, 0], [50, 20]]}))["curves"])[0].clone();
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [angle_handle_id, angle_reference], "type": "angle", "value": "90 deg"}));
|
||||
let sketch = sketch(&s);
|
||||
assert!(
|
||||
sketch
|
||||
.constraints
|
||||
.iter()
|
||||
.any(|constraint| { matches!(constraint.kind, solidcraft_sketch::ConstraintKind::Angle { .. }) && constraint.param.is_some() })
|
||||
);
|
||||
}
|
||||
|
||||
fn factorial(n: usize) -> f64 {
|
||||
(1..=n).fold(1.0, |a, b| a * b as f64)
|
||||
}
|
||||
@@ -1610,13 +1793,17 @@ mod blend_tests {
|
||||
let mut broken = solidcraft_sketch::Sketch::new();
|
||||
let p0 = broken.add_point(Vec2::new(1.0, 1.0), None).unwrap();
|
||||
let p1 = broken.add_point(Vec2::ZERO, None).unwrap();
|
||||
let l = broken.add_curve(CurveKind::Spline { pts: vec![p1, p0], control: true, degree: 1, periodic: false, nurbs: None }, None).unwrap();
|
||||
let l = broken
|
||||
.add_curve(CurveKind::Spline { pts: vec![p1, p0], control: true, degree: 1, periodic: false, nurbs: None, handles: Vec::new() }, None)
|
||||
.unwrap();
|
||||
broken.points[p0].pos = Vec2::new(f64::NAN, 4.0);
|
||||
assert!(super::joint_derivatives(&broken, l, p1, 2, true, "a", "sketch.blend_curve").is_err(), "non-finite geometry");
|
||||
let mut flat = solidcraft_sketch::Sketch::new();
|
||||
let q0 = flat.add_point(Vec2::new(3.0, 3.0), None).unwrap();
|
||||
let q1 = flat.add_point(Vec2::new(3.0, 3.0), None).unwrap();
|
||||
let d = flat.add_curve(CurveKind::Spline { pts: vec![q0, q1], control: true, degree: 1, periodic: false, nurbs: None }, None).unwrap();
|
||||
let d = flat
|
||||
.add_curve(CurveKind::Spline { pts: vec![q0, q1], control: true, degree: 1, periodic: false, nurbs: None, handles: Vec::new() }, None)
|
||||
.unwrap();
|
||||
let e = super::joint_derivatives(&flat, d, q0, 2, true, "a", "sketch.blend_curve").unwrap_err().to_string();
|
||||
assert!(e.contains("no tangent"), "{e}");
|
||||
assert!(solidcraft_geom::hermite_bezier(Vec2::ZERO, Vec2::X, &[Vec2::Y; 5], &[Vec2::Y; 5]).is_none(), "no order above g4");
|
||||
|
||||
@@ -171,6 +171,15 @@ pub(super) fn drag_entity(
|
||||
let Some((path, hold, n, short)) = prepare(sk, e, with, from, to) else {
|
||||
return (Dragged::default(), Left::Unsolved);
|
||||
};
|
||||
// Splines whose tangent handles nobody turned stay the natural curve through their moved fit
|
||||
// points: their handles follow each step. Dragging a handle itself turns it.
|
||||
let natural: Vec<usize> = {
|
||||
let moved: Vec<usize> = path.points().collect();
|
||||
sk.natural_handle_splines()
|
||||
.into_iter()
|
||||
.filter(|&c| !matches!(sk.curves.get(c).map(|x| &x.kind), Some(CurveKind::Spline { handles, .. }) if handles.iter().any(|h| moved.contains(h))))
|
||||
.collect()
|
||||
};
|
||||
// A point dragged alone that ends tangent lines: each step first tries with their other ends
|
||||
// held too, and only when that can't be solved lets them move. Unheld, the solver spread its
|
||||
// correction over every free point, so the line's far end moved (towards the dragged one)
|
||||
@@ -196,6 +205,7 @@ pub(super) fn drag_entity(
|
||||
// Whether the sketch follows step `t` (with the tangent lines' far ends held, else free).
|
||||
let held = |sk: &mut Sketch, last: &Geometry, t: f64, budget: &mut Budget| {
|
||||
path.apply(sk, t);
|
||||
sk.renew_natural_handles(&natural);
|
||||
let kept = keep.as_ref().is_some_and(|keep| {
|
||||
let ok = counted_solve(sk, keep, Some(&last.pos), Analysis::StatusOnly, Some(&mut *budget)).ok()
|
||||
&& !last.sweep_wrapped(sk)
|
||||
@@ -203,6 +213,7 @@ pub(super) fn drag_entity(
|
||||
if !ok {
|
||||
last.restore(sk);
|
||||
path.apply(sk, t);
|
||||
sk.renew_natural_handles(&natural);
|
||||
}
|
||||
ok
|
||||
});
|
||||
|
||||
@@ -462,3 +462,44 @@ fn a_live_gesture_projects_a_face_sketchs_links_once() {
|
||||
live_frame(&mut s, &point, at, 0.5);
|
||||
assert_eq!(refresh_count::take(), 1);
|
||||
}
|
||||
|
||||
/// A dragged fit point of a spline whose tangent handles nobody turned keeps it the natural
|
||||
/// curve: its handles follow (#6502). A turned handle stays where it was put.
|
||||
#[test]
|
||||
fn untouched_spline_handles_follow_a_dragged_fit_point() {
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "sketch.create", json!({"plane": "XY"}));
|
||||
let id = run(&mut s, "sketch.spline.fit_point", json!({"points": [[0, 0], [10, 8], [20, 0], [30, 6]], "connect": false}))["curves"][0]
|
||||
.as_str()
|
||||
.expect("a spline")
|
||||
.to_owned();
|
||||
run(&mut s, "sketch.spline.handles", json!({"curve": id}));
|
||||
let fit = |s: &Session| {
|
||||
let sk = sketch_of(s);
|
||||
let ci = sk.curve_index(&id).expect("the spline");
|
||||
match &sk.curves[ci].kind {
|
||||
solidcraft_sketch::CurveKind::Spline { pts, handles, .. } => (pts.clone(), handles.clone()),
|
||||
_ => panic!("a spline"),
|
||||
}
|
||||
};
|
||||
let (pts, handles) = fit(&s);
|
||||
let name = sketch_of(&s).points[pts[1]].id.clone();
|
||||
run(&mut s, "sketch.drag", json!({"entity": name, "from": [10, 8], "to": [11, 10], "live": true}));
|
||||
let sk = sketch_of(&s);
|
||||
let at: Vec<_> = pts.iter().map(|i| sk.points[*i].pos).collect();
|
||||
assert!(at[1].dist(solidcore_record::Vec2::new(11.0, 10.0)) < 1e-6, "{:?}", at[1]);
|
||||
let natural = solidcraft_sketch::fit_tangent_handles(&at, false).expect("handles");
|
||||
for (h, n) in handles.iter().zip(&natural) {
|
||||
assert!(sk.points[*h].pos.dist(*n) < 1e-6, "an untouched handle follows the curve");
|
||||
}
|
||||
// Turn the last handle; dragging a fit point now leaves every handle where it is.
|
||||
let turned = sk.points[handles[3]].id.clone();
|
||||
let from = sk.points[handles[3]].pos;
|
||||
run(&mut s, "sketch.drag", json!({"entity": turned, "from": [from.x, from.y], "to": [from.x + 2.0, from.y + 3.0]}));
|
||||
let before: Vec<_> = handles.iter().map(|h| sketch_of(&s).points[*h].pos).collect();
|
||||
run(&mut s, "sketch.drag", json!({"entity": name, "from": [11, 10], "to": [12, 11], "live": true}));
|
||||
let sk = sketch_of(&s);
|
||||
for (h, b) in handles.iter().zip(&before) {
|
||||
assert!(sk.points[*h].pos.dist(*b) < 1e-6, "a turned spline keeps its handles");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ fn add_pieces(sk: &mut Sketch, segs: &[Seg2], start: Option<usize>, end: Option<
|
||||
Seg2::Line { .. } => CurveKind::Line { a: prev, b: to },
|
||||
Seg2::Cubic { p1, p2, .. } => {
|
||||
let (c1, c2) = (sk.add_point(p1, None)?, sk.add_point(p2, None)?);
|
||||
CurveKind::Spline { pts: vec![prev, c1, c2, to], control: true, degree: 3, periodic: false, nurbs: None }
|
||||
CurveKind::Spline { pts: vec![prev, c1, c2, to], control: true, degree: 3, periodic: false, nurbs: None, handles: Vec::new() }
|
||||
}
|
||||
Seg2::Conic { apex, w, .. } => {
|
||||
let x = sk.add_point(apex, None)?;
|
||||
@@ -174,7 +174,7 @@ fn add_pieces(sk: &mut Sketch, segs: &[Seg2], start: Option<usize>, end: Option<
|
||||
pts.push(sk.add_point(*q, None)?);
|
||||
}
|
||||
pts.push(to);
|
||||
CurveKind::Spline { pts, control: true, degree: n, periodic: false, nurbs: None }
|
||||
CurveKind::Spline { pts, control: true, degree: n, periodic: false, nurbs: None, handles: Vec::new() }
|
||||
}
|
||||
Seg2::Arc { .. } => return Err(bad("sketch.trim", "unexpected arc piece")),
|
||||
};
|
||||
|
||||
@@ -146,7 +146,14 @@ pub(super) fn add_geometry(sk: &mut Sketch, geom: &[Geom2], f: &dyn Fn(Vec2) ->
|
||||
idx.push(i);
|
||||
}
|
||||
}
|
||||
(idx.len() >= 2).then_some(CurveKind::Spline { pts: idx, control: *control, degree: *degree, periodic: false, nurbs: None })
|
||||
(idx.len() >= 2).then_some(CurveKind::Spline {
|
||||
pts: idx,
|
||||
control: *control,
|
||||
degree: *degree,
|
||||
periodic: false,
|
||||
nurbs: None,
|
||||
handles: Vec::new(),
|
||||
})
|
||||
}
|
||||
Geom2::Conic { a, apex, b, rho } => {
|
||||
let (a, x, b) = (pt(sk, *a)?, pt(sk, *apex)?, pt(sk, *b)?);
|
||||
|
||||
@@ -184,6 +184,9 @@ fn curve_arg(sk: &Sketch, p: &Value, cmd: &str) -> Result<usize> {
|
||||
|
||||
fn curve_by_id(sk: &Sketch, id: &str, cmd: &str) -> Result<usize> {
|
||||
let c = sk.curve_index(id).ok_or_else(|| bad(cmd, format!("unknown curve `{id}`")))?;
|
||||
if sk.curves.get(c).is_some_and(|curve| curve.spline_handle_of.is_some()) {
|
||||
return Err(bad(cmd, "spline tangent construction lines cannot be trimmed, split, or extended"));
|
||||
}
|
||||
if sk.is_linked_curve(c) {
|
||||
let action = match cmd {
|
||||
"sketch.trim" => "Trim",
|
||||
@@ -305,7 +308,17 @@ fn split_at(sk: &mut Sketch, ci: usize, ts: &[f64], cmd: &str) -> Result<(Vec<us
|
||||
let mut pieces = vec![ci];
|
||||
let mut push = |sk: &mut Sketch, kind: CurveKind, prefix: &str| -> Result<()> {
|
||||
let id = sk.fresh(prefix)?;
|
||||
sk.curves.push(Curve { id, kind, construction, reversed: false, link: None, centerline: false, fixed: false, infinite: false });
|
||||
sk.curves.push(Curve {
|
||||
id,
|
||||
kind,
|
||||
construction,
|
||||
reversed: false,
|
||||
link: None,
|
||||
centerline: false,
|
||||
fixed: false,
|
||||
infinite: false,
|
||||
spline_handle_of: None,
|
||||
});
|
||||
pieces.push(sk.curves.len() - 1);
|
||||
Ok(())
|
||||
};
|
||||
@@ -1140,7 +1153,7 @@ fn line_end(sk: &Sketch, ci: usize, toward: Vec2) -> Result<Option<LineEnd>> {
|
||||
|
||||
// A neighbour running on the same way as the extension has it nowhere to go.
|
||||
for (oi, c) in sk.curves.iter().enumerate() {
|
||||
if oi == ci || !uses(c, end) {
|
||||
if oi == ci || c.spline_handle_of.is_some() || !uses(c, end) {
|
||||
continue;
|
||||
}
|
||||
// Arcs do not block a line: the line runs past the point they share.
|
||||
@@ -1167,7 +1180,7 @@ fn line_aim(sk: &Sketch, ci: usize, at: Vec2) -> Result<Option<LineAim>> {
|
||||
let mut budget = 1_000_000usize;
|
||||
let mut segment_count = 0usize;
|
||||
for (oi, other) in sk.curves.iter().enumerate() {
|
||||
if oi == ci {
|
||||
if oi == ci || other.spline_handle_of.is_some() {
|
||||
continue;
|
||||
}
|
||||
let mut pieces = sk.exact_segs(oi).unwrap_or_else(|| sk.segs(oi));
|
||||
@@ -1301,7 +1314,7 @@ fn arc_aim(sk: &Sketch, ci: usize, at: Vec2, cmd: &str) -> Result<Option<ArcAim>
|
||||
let mut budget = 1_000_000usize;
|
||||
let mut segment_count = 0usize;
|
||||
for (oi, curve) in sk.curves.iter().enumerate() {
|
||||
if oi == ci {
|
||||
if oi == ci || curve.spline_handle_of.is_some() {
|
||||
continue;
|
||||
}
|
||||
// A circle passes over arcs lying on it, so no intersection reports them: an arc on the
|
||||
@@ -1411,7 +1424,7 @@ fn stop_point(sk: &Sketch, ci: usize, q: Vec2) -> Option<usize> {
|
||||
sk.curves
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| *i != ci)
|
||||
.filter(|(i, c)| *i != ci && c.spline_handle_of.is_none())
|
||||
.find_map(|(_, c)| {
|
||||
let (a, b) = c.kind.ends()?;
|
||||
[a, b].into_iter().find(|p| sk.point(*p).is_some_and(|pt| pt.dist(q) <= TOL * 10.0))
|
||||
@@ -1553,7 +1566,7 @@ fn extend(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn grow_line(sk: &mut Sketch, ci: usize, aim: LineAim) -> Result<String> {
|
||||
let end = aim.end;
|
||||
let (q, cutter, stop) = (aim.hit, aim.cutter, aim.stop);
|
||||
let neighbors: Vec<_> = sk.curves.iter().enumerate().filter(|(i, c)| *i != ci && uses(c, end)).collect();
|
||||
let neighbors: Vec<_> = sk.curves.iter().enumerate().filter(|(i, c)| *i != ci && c.spline_handle_of.is_none() && uses(c, end)).collect();
|
||||
|
||||
// The end joined to other geometry stays where it is: add a line from it.
|
||||
if !neighbors.is_empty() {
|
||||
@@ -1984,7 +1997,12 @@ fn meet_lines(sk: &mut Sketch, p: &Value, cmd: &str) -> Result<()> {
|
||||
// The end on the shorter side of the meeting point moves (the nearer end, for a line that
|
||||
// stops short of it).
|
||||
let (na, nb) = (if t >= 0.5 { a1 } else { a0 }, if u >= 0.5 { b1 } else { b0 });
|
||||
// An end already at the meeting point does not move, so what holds it does not matter.
|
||||
let at_meet = |sk: &Sketch, e: usize| sk.point(e).is_some_and(|q| q.dist(meet) <= 1e-9 * (1.0 + meet.len()));
|
||||
for (l, e) in [(la, na), (lb, nb)] {
|
||||
if at_meet(sk, e) {
|
||||
continue;
|
||||
}
|
||||
let id = sk.curves.get(l).map(|c| c.id.clone()).unwrap_or_default();
|
||||
let other_curve = sk.curves.iter().enumerate().any(|(i, c)| i != l && c.kind.uses(e));
|
||||
let positioned = sk.points.get(e).is_some_and(|q| q.fixed || q.exprs.is_some() || q.link.is_some());
|
||||
@@ -2004,11 +2022,64 @@ fn meet_lines(sk: &mut Sketch, p: &Value, cmd: &str) -> Result<()> {
|
||||
q.pos = meet;
|
||||
}
|
||||
}
|
||||
// One corner point: b's moved end becomes a's.
|
||||
for c in sk.curves.iter_mut() {
|
||||
c.kind.map_points(&|x| if x == nb { na } else { x });
|
||||
// One corner point: b's moved end becomes a's, and what held b's end holds the corner. The
|
||||
// sketch origin is never the one merged away: it stays the corner, so what else ends there
|
||||
// keeps sharing it.
|
||||
let (keep, gone) = if nb == 0 { (nb, na) } else { (na, nb) };
|
||||
let placed = |sk: &Sketch, e: usize| sk.points.get(e).map(|q| (q.exprs.clone(), q.link.clone())).unwrap_or_default();
|
||||
let (keep_placed, gone_placed) = (placed(sk, keep), placed(sk, gone));
|
||||
// The merged-away end's placement must be one the kept end already has, or take on (the
|
||||
// origin takes on none).
|
||||
fn clash<T: PartialEq>(keep_is_origin: bool, k: &Option<T>, g: &Option<T>) -> bool {
|
||||
g.is_some() && (keep_is_origin || k.is_some()) && k != g
|
||||
}
|
||||
sk.remove_points(&[nb]);
|
||||
// Nor may one end bring an expression placement and the other a link: the expressions would
|
||||
// overwrite where the linked model vertex moves the corner to.
|
||||
let kinds = |(e, l): &(Option<Box<solidcraft_sketch::PointExprs>>, Option<String>)| (e.is_some(), l.is_some());
|
||||
let mixed = |a: (bool, bool), b: (bool, bool)| a == (true, false) && b == (false, true);
|
||||
let (keep_kinds, gone_kinds) = (kinds(&keep_placed), kinds(&gone_placed));
|
||||
if clash(keep == 0, &keep_placed.0, &gone_placed.0)
|
||||
|| clash(keep == 0, &keep_placed.1, &gone_placed.1)
|
||||
|| mixed(keep_kinds, gone_kinds)
|
||||
|| mixed(gone_kinds, keep_kinds)
|
||||
{
|
||||
return Err(bad(
|
||||
cmd,
|
||||
"the lines' ends at the corner are each placed by their own expressions or reference, and one corner point can't follow both",
|
||||
));
|
||||
}
|
||||
if let Some(held) = sk.points.get(gone).map(|q| (q.fixed, q.exprs.clone(), q.link.clone()))
|
||||
&& let Some(q) = sk.points.get_mut(keep)
|
||||
{
|
||||
q.fixed |= held.0;
|
||||
if q.exprs.is_none() {
|
||||
q.exprs = held.1;
|
||||
}
|
||||
if q.link.is_none() {
|
||||
q.link = held.2;
|
||||
}
|
||||
}
|
||||
for c in sk.curves.iter_mut() {
|
||||
c.kind.map_points(&|x| if x == gone { keep } else { x });
|
||||
}
|
||||
// Only a constraint the move changed can now repeat another: such a repeat is said once. Equal
|
||||
// constraints elsewhere in the sketch are not this fillet's to touch.
|
||||
let mut moved: Vec<(solidcraft_sketch::Constraint, bool)> = Vec::with_capacity(sk.constraints.len());
|
||||
for mut k in std::mem::take(&mut sk.constraints) {
|
||||
let before = k.kind.clone();
|
||||
k.kind.remap(&|x| Some(if x == gone { keep } else { x }), &Some);
|
||||
let changed = k.kind != before;
|
||||
moved.push((k, changed));
|
||||
}
|
||||
let same = |a: &solidcraft_sketch::Constraint, b: &solidcraft_sketch::Constraint| a.kind == b.kind && a.param == b.param;
|
||||
let repeats: Vec<bool> = moved
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (k, changed))| *changed && moved.iter().enumerate().any(|(j, (o, o_changed))| j != i && same(k, o) && (j < i || !*o_changed)))
|
||||
.collect();
|
||||
let kept: Vec<solidcraft_sketch::Constraint> = moved.into_iter().zip(repeats).filter(|(_, repeat)| !repeat).map(|((k, _), _)| k).collect();
|
||||
sk.constraints = kept;
|
||||
sk.remove_points(&[gone]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2824,7 +2895,11 @@ fn offset_side(sk: &mut Sketch, cmd: &str, order: &[(usize, bool)], sources: &[(
|
||||
idx.push(pb);
|
||||
idx.dedup();
|
||||
if idx.len() >= 2 {
|
||||
made.push(sk.add_curve(CurveKind::Spline { pts: idx, control: false, degree: 3, periodic: false, nurbs: None }, None)?);
|
||||
let spline = sk.add_curve(
|
||||
CurveKind::Spline { pts: idx, control: false, degree: 3, periodic: false, nurbs: None, handles: Vec::new() },
|
||||
None,
|
||||
)?;
|
||||
made.push(spline);
|
||||
}
|
||||
}
|
||||
Piece::Line(..) => {
|
||||
@@ -3515,10 +3590,25 @@ fn copy_through(
|
||||
keep: &dyn Fn(Vec2) -> bool,
|
||||
scale_r: f64,
|
||||
) -> Result<(Vec<usize>, Vec<(usize, usize)>)> {
|
||||
if sk.curves.len().saturating_add(cs.len()) > solidcraft_sketch::limits::max_curves() {
|
||||
let mut sources = cs.to_vec();
|
||||
let spline_ids: std::collections::BTreeSet<String> = cs
|
||||
.iter()
|
||||
.filter_map(|index| sk.curves.get(*index))
|
||||
.filter(|curve| matches!(curve.kind, CurveKind::Spline { .. }))
|
||||
.map(|curve| curve.id.clone())
|
||||
.collect();
|
||||
sources.extend(
|
||||
sk.curves
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, curve)| curve.spline_handle_of.as_ref().is_some_and(|parent| spline_ids.contains(parent)).then_some(index)),
|
||||
);
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
sources.retain(|source| seen.insert(*source));
|
||||
if sk.curves.len().saturating_add(sources.len()) > solidcraft_sketch::limits::max_curves() {
|
||||
return Err(solidcraft_sketch::SketchError::TooLarge.into());
|
||||
}
|
||||
let pts = involved_points(sk, cs, ps);
|
||||
let pts = involved_points(sk, &sources, ps);
|
||||
let mut map: Vec<(usize, usize)> = Vec::new();
|
||||
for q in &pts {
|
||||
let pos = sk.point(*q).unwrap_or_default();
|
||||
@@ -3527,10 +3617,13 @@ fn copy_through(
|
||||
}
|
||||
let m = |q: usize| map.iter().find(|x| x.0 == q).map(|x| x.1).unwrap_or(q);
|
||||
let mut made = Vec::new();
|
||||
for c in cs {
|
||||
let mut clones = Vec::with_capacity(sources.len());
|
||||
let mut curve_map = std::collections::BTreeMap::new();
|
||||
for c in &sources {
|
||||
let Some(cu) = sk.curves.get(*c).cloned() else { continue };
|
||||
let mut kind = cu.kind.clone();
|
||||
kind.map_points(&m);
|
||||
let helper_line = cu.spline_handle_of.is_some();
|
||||
let (kind, prefix) = match cu.kind {
|
||||
CurveKind::Ellipse { r, .. } => {
|
||||
if let CurveKind::Ellipse { r: slot, .. } = &mut kind {
|
||||
@@ -3540,7 +3633,7 @@ fn copy_through(
|
||||
}
|
||||
CurveKind::Spline { .. } => (kind, "s"),
|
||||
CurveKind::Conic { .. } => (kind, "n"),
|
||||
CurveKind::Line { a, b } => (CurveKind::Line { a: m(a), b: m(b) }, "l"),
|
||||
CurveKind::Line { a, b } => (CurveKind::Line { a: m(a), b: m(b) }, if helper_line { "h" } else { "l" }),
|
||||
CurveKind::Circle { c, r } => (CurveKind::Circle { c: m(c), r: r * scale_r }, "c"),
|
||||
CurveKind::Arc { c, a, b } => {
|
||||
if flip {
|
||||
@@ -3553,7 +3646,7 @@ fn copy_through(
|
||||
let id = sk.fresh(prefix)?;
|
||||
// A mirrored or patterned infinite line is an infinite line too (#1359).
|
||||
let infinite = cu.infinite && matches!(kind, CurveKind::Line { .. });
|
||||
sk.curves.push(Curve {
|
||||
let clone = Curve {
|
||||
id,
|
||||
kind,
|
||||
construction: cu.construction,
|
||||
@@ -3562,7 +3655,14 @@ fn copy_through(
|
||||
centerline: false,
|
||||
fixed: false,
|
||||
infinite,
|
||||
});
|
||||
spline_handle_of: cu.spline_handle_of.clone(),
|
||||
};
|
||||
curve_map.insert(cu.id, clone.id.clone());
|
||||
clones.push(clone);
|
||||
}
|
||||
for clone in &mut clones {
|
||||
clone.spline_handle_of = clone.spline_handle_of.as_ref().and_then(|parent| curve_map.get(parent).cloned());
|
||||
sk.curves.push(clone.clone());
|
||||
made.push(sk.curves.len() - 1);
|
||||
}
|
||||
Ok((made, map))
|
||||
@@ -3620,7 +3720,10 @@ fn mirror(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
cons.push(add_c(sk, ConstraintKind::Equal { a: *o, b: *n })?);
|
||||
}
|
||||
}
|
||||
Ok((ids_of(sk, &made), cons))
|
||||
let mut selected = std::collections::BTreeSet::new();
|
||||
let selected_count = cs.iter().filter(|source| selected.insert(**source)).count();
|
||||
let reported = made.get(..selected_count).ok_or_else(|| bad(cmd, "copied curves changed while mirroring"))?;
|
||||
Ok((ids_of(sk, reported), cons))
|
||||
})?;
|
||||
reply(cmd, MirrorResult { curves: out.0, constraints: out.1, sketch: info, param_warnings: Vec::new() })
|
||||
}
|
||||
|
||||
@@ -2019,7 +2019,8 @@ fn a_point_added_to_a_spline_keeps_its_constraints_and_undoes() {
|
||||
run(&mut s, "sketch.line", json!({"points": [format!("{sid}.end"), [40, 6]], "connect": false}));
|
||||
let sk = sketch(&s);
|
||||
let si = sk.curve_index(&sid).unwrap();
|
||||
let old = sk.curves[si].kind.point_ids();
|
||||
let fit_ids = |k: &CurveKind| if let CurveKind::Spline { pts, .. } = k { pts.clone() } else { Vec::new() };
|
||||
let old = fit_ids(&sk.curves[si].kind);
|
||||
let names: Vec<String> = old.iter().map(|i| sk.points[*i].id.clone()).collect();
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [names[0], names[1]], "value": 12.8}));
|
||||
let before = sketch(&s);
|
||||
@@ -2031,7 +2032,7 @@ fn a_point_added_to_a_spline_keeps_its_constraints_and_undoes() {
|
||||
let out = run(&mut s, "sketch.spline.insert_point", json!({"curve": sid, "at": [15, 6]}));
|
||||
let after = sketch(&s);
|
||||
let new = after.point_index(out["point"].as_str().unwrap()).unwrap();
|
||||
let now = after.curves[si].kind.point_ids();
|
||||
let now = fit_ids(&after.curves[si].kind);
|
||||
assert_eq!(now.len(), old.len() + 1);
|
||||
assert_eq!(now[2], new, "between the second and third fit points: {now:?}");
|
||||
let q = after.point(new).unwrap();
|
||||
@@ -2077,7 +2078,9 @@ fn a_point_removed_from_a_spline_goes_with_its_dimension_and_undoes() {
|
||||
run(&mut s, "sketch.line", json!({"points": [format!("{sid}.end"), [40, 6]], "connect": false}));
|
||||
let sk = sketch(&s);
|
||||
let si = sk.curve_index(&sid).unwrap();
|
||||
let names: Vec<String> = sk.curves[si].kind.point_ids().iter().map(|i| sk.points[*i].id.clone()).collect();
|
||||
// Its fit points, not their tangent handles (#6502).
|
||||
let CurveKind::Spline { pts, .. } = &sk.curves[si].kind else { panic!("a spline") };
|
||||
let names: Vec<String> = pts.iter().map(|i| sk.points[*i].id.clone()).collect();
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [names[1], names[2]], "value": 12.8}));
|
||||
let before = sketch(&s);
|
||||
let at = |sk: &solidcraft_sketch::Sketch, id: &str| sk.point(sk.point_index(id).unwrap()).unwrap();
|
||||
@@ -2086,7 +2089,8 @@ fn a_point_removed_from_a_spline_goes_with_its_dimension_and_undoes() {
|
||||
assert_eq!((r["curve"].as_str(), r["point"].as_str(), r["deleted"].as_bool()), (Some(sid.as_str()), Some(names[1].as_str()), Some(true)), "{r}");
|
||||
let after = sketch(&s);
|
||||
assert!(after.point_index(&names[1]).is_none(), "the point went");
|
||||
let left: Vec<String> = after.curves[si].kind.point_ids().iter().map(|i| after.points[*i].id.clone()).collect();
|
||||
let CurveKind::Spline { pts: left, .. } = &after.curves[si].kind else { panic!("a spline") };
|
||||
let left: Vec<String> = left.iter().map(|i| after.points[*i].id.clone()).collect();
|
||||
assert_eq!(left, [names[0].clone(), names[2].clone(), names[3].clone()]);
|
||||
let segs = after.exact_segs(si).unwrap();
|
||||
for id in &left {
|
||||
@@ -2152,10 +2156,18 @@ fn a_reversed_spline_runs_the_other_way_along_the_same_path() {
|
||||
let r = run(&mut s, "sketch.spline.reverse", json!({"curve": id}));
|
||||
assert_eq!(r["curve"], json!(id), "{r}");
|
||||
let after = sketch(&s);
|
||||
let mut reversed = before.curves[ci].kind.point_ids();
|
||||
let fit_points = |k: &CurveKind| match k {
|
||||
CurveKind::Spline { pts, handles, .. } => (pts.clone(), handles.clone()),
|
||||
_ => (Vec::new(), Vec::new()),
|
||||
};
|
||||
let (mut reversed, handles) = fit_points(&before.curves[ci].kind);
|
||||
reversed.reverse();
|
||||
assert_eq!(after.curves[ci].kind.point_ids(), reversed, "{id}");
|
||||
assert_eq!((&after.points, &after.constraints), (&before.points, &before.constraints), "{id}: points and constraints stay");
|
||||
assert_eq!(fit_points(&after.curves[ci].kind).0, reversed, "{id}");
|
||||
// Tangent handles (#6502) turn about their fit points; every other point stays.
|
||||
let others = |sk: &solidcraft_sketch::Sketch| {
|
||||
sk.points.iter().enumerate().filter(|(i, _)| !handles.contains(i)).map(|(_, p)| p.clone()).collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!((others(&after), &after.constraints), (others(&before), &before.constraints), "{id}: points and constraints stay");
|
||||
let segs = after.exact_segs(ci).unwrap();
|
||||
for (a, b) in [(&path, &segs), (&segs, &path)] {
|
||||
for seg in a {
|
||||
@@ -2827,7 +2839,8 @@ fn irregular_closed_spline_offset_certificate_edits_undoes_and_reopens() {
|
||||
let result = run(&mut s, "sketch.offset", json!({"curves":["s1"],"distance":2.0,"chain":true}));
|
||||
let output = result["curves"][0].as_str().unwrap().to_string();
|
||||
let parameter = result["param"].as_str().unwrap().to_string();
|
||||
let point_ids = sketch(&s).curves[1].kind.point_ids();
|
||||
let output_index = sketch(&s).curve_index(&output).unwrap();
|
||||
let point_ids = sketch(&s).curves[output_index].kind.point_ids();
|
||||
let verify = |s: &Session, distance: f64| {
|
||||
let state = s.world_state().unwrap();
|
||||
let sk = &state.sketch(1).unwrap().sketch;
|
||||
@@ -4434,7 +4447,10 @@ fn inside_offset_wall(s: &Session, output: &str) -> Vec2 {
|
||||
let sk = sketch(s);
|
||||
let index = sk.curve_index(output).unwrap();
|
||||
let q = sk.exact_pieces(index)[0].point_at(0.5);
|
||||
let source: Vec<_> = (0..sk.curves.len()).filter(|i| *i < index && !sk.freeform_offsets.iter().any(|o| o.output == sk.curves[*i].id)).collect();
|
||||
// The source curves, not a spline's tangent handle lines (#6502) or an offset's output.
|
||||
let source: Vec<_> = (0..sk.curves.len())
|
||||
.filter(|i| *i < index && sk.curves[*i].spline_handle_of.is_none() && !sk.freeform_offsets.iter().any(|o| o.output == sk.curves[*i].id))
|
||||
.collect();
|
||||
let p = source
|
||||
.iter()
|
||||
.flat_map(|i| sk.exact_pieces(*i))
|
||||
@@ -4468,12 +4484,13 @@ fn freeform_offsets_trim_folds_and_round_chain_corners_into_an_extrudable_wall()
|
||||
]);
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "sketch.spline.fit_point", json!({"points": cookie, "periodic": true}));
|
||||
let source_dof = run(&mut s, "sketch.inspect", json!({}))["dof"].as_u64().unwrap();
|
||||
let (before, params) = (s.doc.to_json(), s.doc.params.len());
|
||||
let out = run(&mut s, "sketch.offset", json!({"curves": ["s1"], "distance": 1.26}));
|
||||
let curves = ids(&out["curves"]);
|
||||
assert!(!curves.is_empty(), "{out}");
|
||||
assert_eq!(out["param"], Value::Null, "a trimmed offset has no distance to drive: {out}");
|
||||
assert_eq!(out["sketch"]["dof"], 30, "the offset adds no freedom (only the source's fit points move): {out}");
|
||||
assert_eq!(out["sketch"]["dof"], source_dof, "the offset adds no freedom: {out}");
|
||||
assert_eq!(s.doc.params.len(), params, "no orphan distance parameter");
|
||||
let sk = sketch(&s);
|
||||
assert!(sk.freeform_offsets.is_empty() && !sk.constraints.iter().any(|c| matches!(c.kind, ConstraintKind::OffsetDistance { .. })));
|
||||
@@ -5767,6 +5784,100 @@ fn split_failures_name_the_public_command() {
|
||||
assert!(!text.contains("sketch.modify"), "{text}");
|
||||
}
|
||||
|
||||
/// #11523: two lines whose ends already sit at one place, as points of their own, make a corner
|
||||
/// to round without any end having to move, however much holds those ends (a fixed position, a
|
||||
/// length). The fillet was refused as if each end had to move to the meeting point.
|
||||
#[test]
|
||||
fn fillet_of_lines_that_already_meet_needs_no_end_to_move() {
|
||||
for hold in ["fixed", "length", "both"] {
|
||||
let mut s = new_sketch();
|
||||
let a = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [40, 0]], "connect": false, "infer": false}))["curves"])[0].clone();
|
||||
let b = ids(&run(&mut s, "sketch.line", json!({"points": [[40, 0], [40, 50]], "connect": false, "infer": false}))["curves"])[0].clone();
|
||||
if hold != "length" {
|
||||
run(&mut s, "sketch.constraint.fix", json!({"entities": [format!("{a}.end")]}));
|
||||
}
|
||||
if hold != "fixed" {
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [a.clone()], "type": "length", "value": "40"}));
|
||||
run(&mut s, "sketch.dimension", json!({"entities": [b.clone()], "type": "length", "value": "50"}));
|
||||
}
|
||||
let r = s.execute("sketch.fillet", &json!({"a": a, "b": b, "radius": 5})).unwrap_or_else(|e| panic!("{hold}: {e}"));
|
||||
let sk = sketch(&s);
|
||||
// What held the lines is still there (a length now measures to the kept corner), none merged.
|
||||
let held = |sk: &solidcraft_sketch::Sketch, f: fn(&ConstraintKind) -> bool| sk.constraints.iter().filter(|k| f(&k.kind)).count();
|
||||
assert_eq!(
|
||||
held(&sk, |k| matches!(k, ConstraintKind::Length { .. } | ConstraintKind::Distance { .. })),
|
||||
if hold == "fixed" { 0 } else { 2 },
|
||||
"{hold}"
|
||||
);
|
||||
assert_eq!(held(&sk, |k| matches!(k, ConstraintKind::Fix { .. })), if hold == "length" { 0 } else { 1 }, "{hold}");
|
||||
let arcs: Vec<_> = sk.curves.iter().filter(|c| matches!(c.kind, CurveKind::Arc { .. })).collect();
|
||||
assert_eq!(arcs.len(), 1, "{hold}: {r}");
|
||||
// The arc's tangent points: 5 mm from the corner (40, 0) along each line.
|
||||
let CurveKind::Arc { a, b, .. } = arcs[0].kind else { panic!("an arc") };
|
||||
let ends: Vec<Vec2> = vec![sk.point(a).unwrap(), sk.point(b).unwrap()];
|
||||
for want in [Vec2::new(35.0, 0.0), Vec2::new(40.0, 5.0)] {
|
||||
assert!(ends.iter().any(|e| e.dist(want) < 1e-6), "{hold}: {ends:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #11523: a line ending at the sketch origin keeps the origin as the corner when another line
|
||||
/// is brought to meet it there, so a third line still ending at the origin still shares it.
|
||||
#[test]
|
||||
fn fillet_meeting_at_the_origin_keeps_the_origin_shared() {
|
||||
let mut s = new_sketch();
|
||||
let a = ids(&run(&mut s, "sketch.line", json!({"points": [[20, 0], [5, 0]], "connect": false, "infer": false}))["curves"])[0].clone();
|
||||
let b = ids(&run(&mut s, "sketch.line", json!({"points": ["origin", [0, 30]], "connect": false, "infer": false}))["curves"])[0].clone();
|
||||
let c = ids(&run(&mut s, "sketch.line", json!({"points": ["origin", [-20, -20]], "connect": false, "infer": false}))["curves"])[0].clone();
|
||||
let r = run(&mut s, "sketch.fillet", json!({"a": a, "b": b, "radius": 2}));
|
||||
let sk = sketch(&s);
|
||||
let ci = sk.curve_index(&c).expect("the third line");
|
||||
assert!(matches!(sk.curves[ci].kind, CurveKind::Line { a: 0, .. }), "the third line still starts at the origin: {r}");
|
||||
assert!(!sk.curves.iter().enumerate().any(|(i, l)| i != ci && matches!(l.kind, CurveKind::Line { a, b } if (a == 0) != (b == 0) && sk.point(if a == 0 { b } else { a }).is_some_and(|q| q.len() < 1e-9))), "no other point sits on the origin");
|
||||
}
|
||||
|
||||
/// #11523: two ends already at one place but each placed by its own expressions can't become one
|
||||
/// corner point that follows both: the fillet is refused instead of dropping one end's expressions.
|
||||
#[test]
|
||||
fn fillet_refuses_meeting_ends_placed_by_different_expressions() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "parameters.add", json!({"name": "W", "expression": "40 mm"}));
|
||||
run(&mut s, "parameters.add", json!({"name": "X", "expression": "40 mm"}));
|
||||
let a =
|
||||
ids(&run(&mut s, "sketch.line", json!({"points": [["0 mm", "0 mm"], ["W", "0 mm"]], "connect": false, "infer": false}))["curves"])[0].clone();
|
||||
let b =
|
||||
ids(&run(&mut s, "sketch.line", json!({"points": [["X", "0 mm"], ["X", "30 mm"]], "connect": false, "infer": false}))["curves"])[0].clone();
|
||||
let err = s.execute("sketch.fillet", &json!({"a": a, "b": b, "radius": 5})).expect_err("two placements can't both hold one corner").to_string();
|
||||
assert!(err.contains("one corner point can't follow both"), "{err}");
|
||||
// The same expressions on both ends are one placement: that corner rounds.
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "parameters.add", json!({"name": "W", "expression": "40 mm"}));
|
||||
let a =
|
||||
ids(&run(&mut s, "sketch.line", json!({"points": [["0 mm", "0 mm"], ["W", "0 mm"]], "connect": false, "infer": false}))["curves"])[0].clone();
|
||||
let b =
|
||||
ids(&run(&mut s, "sketch.line", json!({"points": [["W", "0 mm"], ["W", "30 mm"]], "connect": false, "infer": false}))["curves"])[0].clone();
|
||||
s.execute("sketch.fillet", &json!({"a": a, "b": b, "radius": 5})).expect("one placement on both ends");
|
||||
}
|
||||
|
||||
/// #11523: an end placed by expressions and one linked to a model vertex, at one place, can't
|
||||
/// become one corner point: the expressions would overwrite where the vertex moves it. Refused.
|
||||
#[test]
|
||||
fn fillet_refuses_meeting_ends_placed_by_an_expression_and_a_link() {
|
||||
let mut s = new_sketch();
|
||||
run(&mut s, "parameters.add", json!({"name": "W", "expression": "40 mm"}));
|
||||
let a =
|
||||
ids(&run(&mut s, "sketch.line", json!({"points": [["0 mm", "0 mm"], ["W", "0 mm"]], "connect": false, "infer": false}))["curves"])[0].clone();
|
||||
let b = ids(&run(&mut s, "sketch.line", json!({"points": [[40, 0], [40, 30]], "connect": false, "infer": false}))["curves"])[0].clone();
|
||||
let id = s.active_sketch.unwrap();
|
||||
let start = sketch(&s).resolve_point(&format!("{b}.start")).expect("b's start");
|
||||
s.doc_mut().sketch_mut(id).unwrap().points[start].link = Some("model-vertex".into());
|
||||
let err = s
|
||||
.execute("sketch.fillet", &json!({"a": a, "b": b, "radius": 5}))
|
||||
.expect_err("an expression and a link can't both hold one corner")
|
||||
.to_string();
|
||||
assert!(err.contains("one corner point can't follow both"), "{err}");
|
||||
}
|
||||
|
||||
/// #2980: a rectangle drawn from the origin and a circle, all moved: they move as drawn. The
|
||||
/// corner's coincidence with the origin is let go instead of pulling that corner back and
|
||||
/// bending the rectangle; its own horizontals and verticals stay.
|
||||
|
||||
@@ -200,6 +200,7 @@ pub(super) fn run(s: &mut Session, p: &Value, circular: bool) -> Result<Value> {
|
||||
None => sk.fresh("pat")?,
|
||||
};
|
||||
let center_id = center.and_then(|i| sk.points.get(i)).map(|p| p.id.clone());
|
||||
let reported_curve_count = entity_ids.iter().filter(|id| sk.curve_index(id).is_some()).count();
|
||||
// Removing old copies can retire an earlier pattern seeded by one, so the old index may be past the end.
|
||||
let index = old_index.unwrap_or(sk.patterns.len()).min(sk.patterns.len());
|
||||
let pattern = SketchPattern {
|
||||
@@ -213,7 +214,7 @@ pub(super) fn run(s: &mut Session, p: &Value, circular: bool) -> Result<Value> {
|
||||
sk.patterns.insert(index, pattern);
|
||||
sk.update_pattern(index, kind)?;
|
||||
let pattern = sk.patterns.get(index).ok_or_else(|| bad(cmd, "missing sketch pattern"))?;
|
||||
let curves: Vec<_> = pattern.instances.iter().flat_map(|i| i.curves.iter().cloned()).collect();
|
||||
let curves: Vec<_> = pattern.instances.iter().flat_map(|instance| instance.curves.iter().take(reported_curve_count).cloned()).collect();
|
||||
let constraints: Vec<_> = pattern.instances.iter().flat_map(|i| i.constraints.iter().cloned()).collect();
|
||||
Ok((id, center_id, curves, constraints))
|
||||
})?;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user