diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 8f859a0ff7..4f7e92d4a0 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -37,6 +37,8 @@ Editable drawing code, native projects and shipped exports are collected under ` | assets/icons/app/macos/exports/Assets.car | SolidCraft contributors | Apple actool compilation of assets/icons/app/macos/source/SolidCraft.icon for macOS; contains original artwork only | MIT OR Apache-2.0 | | assets/archive/icons/macos-v1/source/icon.vectorcraft | SolidCraft contributors | retained editable historical VectorCraft source, with named face and background layers; redundant exports retired | MIT OR Apache-2.0 | | assets/icons/app/macos/exports/icon.icns | SolidCraft contributors | macOS fallback compiled by Apple's actool from the approved assets/icons/app/macos/source/SolidCraft.icon composition | MIT OR Apache-2.0 | +| assets/models/test_boat.bin | 3DBenchy.com | 3DBenchy.com | Public Domain | +| assets/models/make_test_boat.py | SolidCraft contributors | original script that packs a model into the compact file above | MIT OR Apache-2.0 | | docs/screenshots/class-a-blend.png | SolidCraft contributors | screenshot of SolidCraft itself: two boxes bridged by `surface.blend` (a 40x10 sheet, `solidcraft-cli snapshot` of plan/evidence script ev_blend) | MIT OR Apache-2.0 | | docs/screenshots/class-a-crown.png | SolidCraft contributors | screenshot of SolidCraft itself: a patch crowned with `surface.patch crown=0.3` over a saddle rim (`solidcraft-cli snapshot`) | MIT OR Apache-2.0 | | docs/screenshots/class-a-boundary.png | SolidCraft contributors | screenshot of SolidCraft itself: a patch through two sketch lines built by `surface.boundary` with `crown: 0.4` (`solidcraft-cli snapshot`) | MIT OR Apache-2.0 | @@ -355,6 +357,7 @@ Editable drawing code, native projects and shipped exports are collected under ` | assets/icons/ui/design/svg/flat/stop.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 | | assets/icons/ui/design/svg/flat/shield.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 | | assets/icons/ui/design/svg/model/status_recovery.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 | +| assets/icons/ui/design/svg/model/frame_member.svg | SolidCraft contributors | original icon master (64 grid) | MIT OR Apache-2.0 | | assets/icons/ui/design/svg/model/placeholder.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | | assets/icons/ui/design/svg/model/plane.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | | assets/icons/ui/design/svg/model/plane_angle.svg | SolidCraft contributors | two-tone modelling icon master (64 grid), original SolidCraft artwork | MIT OR Apache-2.0 | @@ -538,9 +541,9 @@ crates/engine/src/tests/gltf_import.rs); no third-party models are committed. ## FreeCAD and Rhino import library FreeCAD `.FCStd` and Rhino `.3dm` files are read with [cadmpeg](https://github.com/cadmpeg/cadmpeg) -0.6.1 (Apache-2.0), linked as the crates `cadmpeg-core`, `cadmpeg-ir`, `cadmpeg-registry`, +0.6.3 (Apache-2.0), linked as the crates `cadmpeg-core`, `cadmpeg-ir`, `cadmpeg-registry`, `cadmpeg-container`, `cadmpeg-codec-freecad`, `cadmpeg-codec-rhino` and `cadmpeg-codec-step` -in native builds (`crates/io/src/native_cad.rs`). Only those open-format codecs are built: no +in native and browser builds (`crates/io/src/native_cad.rs`). Only those open-format codecs are built: no default features, so cadmpeg's codecs for commercial CAD formats and its ShapeManager/ACIS codec are not compiled in. Its licence text is in THIRD-PARTY-LICENSES.txt; no code from it is copied into SolidCraft. diff --git a/Cargo.lock b/Cargo.lock index 72f93172ba..1a0e19b408 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -590,9 +590,9 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadmpeg-codec-freecad" -version = "0.6.1" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a5257265c2549cf59a8457b3c7aadcc96c95abe20205232553766993a808812" +checksum = "3c4a8fa61748cbb3ce80838b00a8cebfe553460583941f5aba6e2b97c16b4d37" dependencies = [ "cadmpeg-container", "cadmpeg-core", @@ -601,14 +601,14 @@ dependencies = [ "serde", "serde_json", "tempfile", - "zip 8.6.0", + "zip", ] [[package]] name = "cadmpeg-codec-rhino" -version = "0.6.1" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d88b42fb91c7e5af4c75c0433029771f7e3233a0b72ec3d0b2e5ff2c72bed07" +checksum = "d7af41440d4c1d69e88e612de02cc3c9bad79d03429531e640ac924fef53c6bf" dependencies = [ "cadmpeg-container", "cadmpeg-core", @@ -625,53 +625,53 @@ dependencies = [ [[package]] name = "cadmpeg-codec-step" -version = "0.6.1" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e83a52258c129a683f77fb360ca30af54ae34339625ad890be12621128a5811c" +checksum = "35e1b6d17cf26b31f59f2edab8ef5c29d2a33216c581fd913f012ad4869bd559" dependencies = [ "base64", "cadmpeg-container", "cadmpeg-core", "cadmpeg-ir", "encoding_rs", + "serde", "serde_json", "thiserror 2.0.21", - "zip 8.6.0", + "zip", ] [[package]] name = "cadmpeg-container" -version = "0.6.1" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c110508cd9155e905f52906de505dfd42dc70a0321f5e90b5f68c28ba207686e" +checksum = "5ab7bcef48ac7480b4917820272d85cd2c9533e17904e15b0e4d22f19a6045bd" dependencies = [ "cadmpeg-core", "crc32fast", "flate2", - "zip 8.6.0", - "zstd 0.14.2", + "zip", ] [[package]] name = "cadmpeg-core" -version = "0.6.1" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b1147e51a74e7c52da5b393a0344597238cdc622fac6443eb2fd526759f6fa" +checksum = "e86bb7a40521832504bb1f5953a1c706b2c52aad73bf6d84e1b35e5cb0beae3f" dependencies = [ "flate2", "memchr", "roxmltree", + "ruzstd", "serde", "serde_json", "thiserror 2.0.21", - "zstd 0.14.2", ] [[package]] name = "cadmpeg-ir" -version = "0.6.1" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71ff19f019f013d97e353bb802c5d2e253c2859aaa5422009aa933dc2f2e7dbc" +checksum = "b570a1a8c579e407a9fd7bb10fa1900f8827f1d578b8fcb4ea2a056b5f323c72" dependencies = [ "base64", "cadmpeg-core", @@ -684,9 +684,9 @@ dependencies = [ [[package]] name = "cadmpeg-registry" -version = "0.6.1" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66046f1a694926707bea8944f861e699964b04c68972708588f97e14ccee3c5d" +checksum = "b2be23469a9bca0b684f1811118fffb581ebd32ded08e6cc8b0716a41f8c1377" dependencies = [ "cadmpeg-codec-freecad", "cadmpeg-codec-rhino", @@ -3786,6 +3786,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "ruzstd" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69e4ce4a039f42bf25920b95667e4d789e98749ab11bbb8d2c2936f788421bb5" +dependencies = [ + "twox-hash", +] + [[package]] name = "same-file" version = "1.0.6" @@ -4199,7 +4208,7 @@ dependencies = [ [[package]] name = "solidcraft" -version = "0.21.0-rc.2" +version = "0.21.0" dependencies = [ "block2 0.6.2", "eframe", @@ -4231,11 +4240,11 @@ dependencies = [ [[package]] name = "solidcraft-3dx-client" -version = "0.21.0-rc.2" +version = "0.21.0" [[package]] name = "solidcraft-cli" -version = "0.21.0-rc.2" +version = "0.21.0" dependencies = [ "image", "log", @@ -4247,7 +4256,7 @@ dependencies = [ [[package]] name = "solidcraft-derive" -version = "0.21.0-rc.2" +version = "0.21.0" dependencies = [ "proc-macro2", "quote", @@ -4256,7 +4265,7 @@ dependencies = [ [[package]] name = "solidcraft-doc" -version = "0.21.0-rc.2" +version = "0.21.0" dependencies = [ "log", "serde", @@ -4273,7 +4282,7 @@ dependencies = [ [[package]] name = "solidcraft-engine" -version = "0.21.0-rc.2" +version = "0.21.0" dependencies = [ "log", "schemars", @@ -4292,7 +4301,7 @@ dependencies = [ [[package]] name = "solidcraft-geom" -version = "0.21.0-rc.2" +version = "0.21.0" dependencies = [ "serde", "serde_json", @@ -4301,7 +4310,7 @@ dependencies = [ [[package]] name = "solidcraft-gpu" -version = "0.21.0-rc.2" +version = "0.21.0" dependencies = [ "image", "log", @@ -4320,7 +4329,7 @@ dependencies = [ [[package]] name = "solidcraft-io" -version = "0.21.0-rc.2" +version = "0.21.0" dependencies = [ "cadmpeg-core", "cadmpeg-ir", @@ -4338,12 +4347,12 @@ dependencies = [ "solidcraft-geom", "solidcraft-kernel", "thiserror 2.0.21", - "zip 9.0.4", + "zip", ] [[package]] name = "solidcraft-kernel" -version = "0.21.0-rc.2" +version = "0.21.0" dependencies = [ "log", "proptest", @@ -4363,7 +4372,7 @@ dependencies = [ [[package]] name = "solidcraft-mcp" -version = "0.21.0-rc.2" +version = "0.21.0" dependencies = [ "serde_json", "solidcraft-engine", @@ -4372,7 +4381,7 @@ dependencies = [ [[package]] name = "solidcraft-render" -version = "0.21.0-rc.2" +version = "0.21.0" dependencies = [ "image", "moxcms", @@ -4386,7 +4395,7 @@ dependencies = [ [[package]] name = "solidcraft-sketch" -version = "0.21.0-rc.2" +version = "0.21.0" dependencies = [ "serde", "serde_json", @@ -4397,14 +4406,14 @@ dependencies = [ [[package]] name = "solidcraft-space-mouse" -version = "0.21.0-rc.2" +version = "0.21.0" dependencies = [ "serde", ] [[package]] name = "solidcraft-ui-egui" -version = "0.21.0-rc.2" +version = "0.21.0" dependencies = [ "arboard", "egui", @@ -4412,6 +4421,7 @@ dependencies = [ "image", "log", "minisign-verify", + "nix 0.31.3", "pollster 0.4.0", "proc-macro2", "semver", @@ -4427,12 +4437,12 @@ dependencies = [ "ttf-parser", "web-time", "winreg", - "zip 9.0.4", + "zip", ] [[package]] name = "solidcraft-web" -version = "0.21.0-rc.2" +version = "0.21.0" dependencies = [ "eframe", "egui", @@ -4881,6 +4891,12 @@ version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +[[package]] +name = "twox-hash" +version = "2.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86a801b3cea342a06d468c8710662aa29e5e05e4f5c0d62f00bbb7f2ad7941c2" + [[package]] name = "type-map" version = "0.5.1" @@ -5962,7 +5978,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.21.0-rc.2" +version = "0.21.0" dependencies = [ "proc-macro2", "serde_json", @@ -6255,20 +6271,6 @@ dependencies = [ "syn 3.0.6", ] -[[package]] -name = "zip" -version = "8.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" -dependencies = [ - "crc32fast", - "flate2", - "indexmap", - "memchr", - "typed-path", - "zstd 0.13.3", -] - [[package]] name = "zip" version = "9.0.4" @@ -6308,52 +6310,6 @@ dependencies = [ "simd-adler32", ] -[[package]] -name = "zstd" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" -dependencies = [ - "zstd-safe 7.3.0", -] - -[[package]] -name = "zstd" -version = "0.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "057cfd910cfac363a0ada849592624b4c9ff2e10bef504c3433810d78ed96f93" -dependencies = [ - "zstd-safe 8.1.0", -] - -[[package]] -name = "zstd-safe" -version = "7.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" -dependencies = [ - "zstd-sys", -] - -[[package]] -name = "zstd-safe" -version = "8.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdd44c6a7284e91f3717755b24315a302edd9153a01f753c3cba3d765e8eafac" -dependencies = [ - "zstd-sys", -] - -[[package]] -name = "zstd-sys" -version = "2.1.1+zstd.1.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aeec9eaf2dffbbd09201e23bd0ffcbaa33bb8e9266a10734fd7ed90a85eca078" -dependencies = [ - "cc", - "pkg-config", -] - [[package]] name = "zune-core" version = "0.5.3" diff --git a/Cargo.toml b/Cargo.toml index 51171adbd1..22af805d7c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"] exclude = ["vendor"] [workspace.package] -version = "0.21.0-rc.2" +version = "0.21.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -67,17 +67,20 @@ encoding_rs = "0.8" # read by crates/io itself, so no image decoding or file access comes with it. gltf = { version = "1.4", default-features = false, features = ["names"] } -# FreeCAD and Rhino import (Apache-2.0, pure Rust apart from zstd; native builds only). +# FreeCAD and Rhino import (Apache-2.0, pure Rust; native and browser builds). # Pinned: its IR and codec APIs change between minor versions. Open formats only: no default # features, so cadmpeg's codecs for commercial formats (f3d, sldprt, inventor, catia, creo, nx) # and its ShapeManager/ACIS `sat` codec are not compiled in. `step` is the writer we restate through. -cadmpeg-core = "=0.6.1" -cadmpeg-ir = "=0.6.1" -cadmpeg-registry = { version = "=0.6.1", default-features = false, features = ["fcstd", "rhino", "step"] } +cadmpeg-core = "=0.6.3" +cadmpeg-ir = "=0.6.3" +cadmpeg-registry = { version = "=0.6.3", default-features = false, features = ["fcstd", "rhino", "step"] } # fonts (sketch text outlines; MIT OR Apache-2.0, pure Rust) ttf-parser = { version = "0.25", default-features = false, features = ["std"] } +# Ends a process group with the system call (MIT; already in the tree through zip). +nix = { version = "0.31", default-features = false, features = ["signal"] } + # images image = { version = "0.25", default-features = false, features = ["png", "hdr"] } png = "0.18.1" diff --git a/ROADMAP.md b/ROADMAP.md index e2dcd216a8..01168c25b2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -51,9 +51,9 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings. |---|---|---|---| | M0 | Vertical slice | done | — | | M1 | Sketch depth: command coverage implemented — projection/intersect/include as linked geometry (auto-project on faces, lost-reference warnings, break link), full SKETCH create/modify set (slots, polygons, tangent arcs/circles, ellipse, fit/control splines, conic, text, trim/extend/break, fillet/chamfer, offset, mirror, patterns, move/scale, blend curve, centerline), constraints incl. curvature and polygon with over-constraint refusal, Shift-picked line midpoints in Horizontal/Vertical using persisted points and ordinary constraints (#2087), driven/arc-length/linear-diameter dimensions, AutoConstrain, DXF/SVG insert, 3D sketch curves (Project To Surface, Intersection Curve, Isoparametric Curve, Spun Profile), exact free-form profiles (B-spline/NURBS edges, any spline degree, curves cut by crossings), sweeps and pipes along 3D sketch curves, canvases and decals, curvature combs. Exact free-form Extend and certified associative offsets now preserve geometry and IDs through edits, deletion and reopen. Occurrence-frame 3D wires now store explicit coordinate spaces and retain points and analytic fits through moves, refresh, undo and reopen. Constructive sketch solving (Owen 1991, Fudos & Hoffmann 1997) ahead of the numerical solver: points and rigid clusters are placed on the branch the sketch had, so dimension edits and drags keep their solution; edits that would turn a sketch over are refused; drags re-solve only the sketch per frame. Remaining workflow repairs: mixed offset junctions, legacy natural-spline seams, and damaged legacy wire links lacking frame provenance | in progress | 4 | -| M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 | +| M2 | Robust modelling: coplanar booleans in the kernel, holes (placed on faces, at distances from reference edges, and on sketch points), threads (cosmetic, then modelled), patterns, mirror, shell, draft, split, construction geometry | in progress | 16 | | M3 | General fillets and chamfers (curved edges, chains, vertex blends); saddle rolling-ball surfaces now use continuous seam sections and arc-length fitting with shared contact boundaries and bounded accuracy checks; tiny rim edges remain selected instead of nearby wall seams when filleting (#2498) | in progress | 30 | -| M4 | Files: STEP import (done — [docs/step-import.md](docs/step-import.md)), FreeCAD/Rhino solids through cadmpeg (done, desktop and CLI), 3MF export and 3MF/STL import as mesh bodies (done), 3MF with B-rep face tags ([docs/formats/3mf-face-tags.md](docs/formats/3mf-face-tags.md), done), constant-section parametric mesh reconstruction, DXF sketches, OBJ import | in progress | 6 | +| M4 | Files: STEP import (done — [docs/step-import.md](docs/step-import.md)), FreeCAD/Rhino solids through cadmpeg (done, desktop, browser and CLI), 3MF export and 3MF/STL import as mesh bodies (done), 3MF with B-rep face tags ([docs/formats/3mf-face-tags.md](docs/formats/3mf-face-tags.md), done), constant-section parametric mesh reconstruction, DXF sketches, OBJ import | in progress | 6 | | M5 | Persistent naming of faces and edges (docs/book/src/dev/naming.md): faces named from their exact kernel-reported history (sketch curve sides, caps, revolve sections, primitive sides, blends, shell inner walls, pattern/mirror copies; split pieces told apart by the faces around them, lumps of a cut body named after their faces), edges by their faces; fillets/chamfers, sketches on faces, shell/draft/offset/replace faces, hole placements, joint origins and face appearances resolve by name first and warn when the entity split or vanished: done. sheet metal edges by base sketch line and flange side, shell inner faces, sweep and loft roles: done. kernel-reported face history: done (merged faces answer to every name; operations without history yet fall back to geometric names, flagged unverified; format 14 keeps older designs' geometric names). Left: history through sheet metal, plastic, surface and emboss features; FaceSource certificates through history | in progress | 20 | | M6 | UI depth: selection feedback and origin (done), timeline editing (done: hover and selection highlight modifier faces, including fillets and chamfers; edit feature, history marker drag, dependency-checked single and multi-item reorder, suppress, rename, delete with dependents, re-resolved references; redefining selections refreshes persistent edge/face references while retaining feature identity and dimension names; responsive feature history with width-driven icons/names/title, snapping width resize, bottom/left/browser-stack docking, shared-column collapse, rollback bar, multi-selection and persistent folders), right-docked dialogs (done), live previews for every feature dialog (done), keyboard-first commands with on-canvas value boxes and sketch inline dimensions (done), drag manipulators with automatic join/cut (done: extrude incl. two sides, fillet, shell, hole depth, move, pattern spacing, revolve rotator, extrude taper), dark theme (done), right-click menu with Repeat (done), measure tool (done), section analysis (done), selection filter (done), drag-solve of sketch points (done); appearance controls now preserve body/face/component targeting, named transparent looks, reuse/search and override removal; preferences | in progress | 6 | | M7 | Thin extrude (walls along open or closed curves: centre, inside, outside or two sides; mitred corners, crossing walls joined; #721), sweep, loft (incl. to a point), rib and web (to next / depth), emboss (planar faces and exact radial emboss/deboss of bounded orthogonal profiles on complete exterior cylinders/sleeves), thread (cosmetic, and modelled M3–M20 outside and inside: the ISO profile screwed in as a faceted cut), coil (smooth B-spline helical walls), pipe, pattern on path, patterns of bodies and components, replace face (parallel), align, remove, feature copy/paste: done. Unique eligible cylindrical targets are inferred for new Emboss features. Left: general curved-face emboss and exact (unfaceted) modelled threads | in progress | 12 | @@ -64,11 +64,15 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings. | M12 | Release: cross-platform packages, verification and deliberate publication implemented; original current-UI demo refreshed. Trusted distribution signing and broader performance work remain | in progress | 16 | | M14 | Configurations: a table of rows (parameter expressions and feature suppressions per row) switched with config.activate (undoable, all or nothing), kept through parameter renames and feature deletes (config.configure, config.table, config.column/row/activate). Per-row body materials and appearances (library looks, custom colour/opacity and clearing overrides), undoable activation and save/reopen: done. Ordered typed configuration rules, atomic validation and structured rule editing: done. Component appearance and certified semantic face style columns, atomic preview/apply and independent-copy remapping: done. Exact constructed sketch-side and immutable imported-face lineage now preserve legacy names and reject retargeting. Left: ambiguous shifted/two-sided caps | in progress | 4 | | M13 | Never lose work: atomic saves (temporary file, flush, rename; a crash at any moment leaves the old file or the new one) with the replaced version kept as `.bak`; autosave of unsaved changes to the recovery folder every 5 minutes (preference) and after big operations, only when something changed; "Recover unsaved design?" on the next launch (`doc.recovery_list`, `doc.recover`, `doc.recovery_discard`); a kill-mid-save test (process killed while saving a 0.5 MB design 25 times). File format: `"format": "solidcraft/12"`, older formats upgraded step by step (`doc::format`), newer ones refused with a message, out-of-range numbers and broken placements refused; a corpus of designs saved by M0, components-era, joints-era and current builds (apps/solidcraft-cli/tests/corpus) that must open and evaluate as saved; damaged and hostile variants (truncation, bit flips, hostile values, removed keys, repeated features, deep nesting) never panic | done | 6 | +| M15 | Frames and weldments (#4003): structural members along 2D and 3D sketch lines, butt and mitre corners, trim to faces and bodies, end caps, gussets, cosmetic welds and a cut list. Done: the profile library (`frame.profile.list`, `frame.profile.properties`, `frame.profile.insert`): bar, round bar, rectangular and round tube, angle, channel and I-beam, each SolidCraft's own parametric definition with exact fillet arcs and exact section properties (area, centroid, second moments), tested against hand-computed values; members (`frame.member`): one body per straight line of a 2D or 3D frame sketch, following parameter edits, with a built-in profile (its dimensions parameter expressions) or a profile sketch (its drawn material by alternating nested loops), anchor, angle and mirror; each member's name kept with its line. Left: corners, trims, end caps, gussets, welds, cut list, FRAME tab and dialogs | in progress | 60 | M6 navigation preferences include **Touchpad**: Shift + pointer movement pans, Alt/Option + pointer movement orbits without a mouse button, and vertical scrolling zooms. Other presets retain their saved two-finger scroll preference; camera motion is available through `view.navigate` and modifier-aware `ui.move` automation. +With the **Fusion** preset and two-finger pan enabled, two fingers pan and Shift + two fingers +orbit freely, with pinch zoom and mouse bindings unchanged. The shared navigation handler +applies the same gestures to model and Render views and blocks orbit on flat pages. UI redesign (#2016): the File/start view (#2027) uses shared light/dark navigation, action tiles, recent rows and sample cards, with recovery review and Save As/Export @@ -583,6 +587,10 @@ completed drawings publish atomically. The browser still exports synchronously. continuous collision detection; local thin walls and small details remain limitations. See [contact-set limits and recovery](docs/contact-sets.md). +## Assembly dragging + +The design's first part is grounded when it is made, and a left-button drag repositions a free part (`occurrence.drag`: one undo step, exact occurrence paths including repeated jointed definitions, any floating-chain member carries the chain, grounded parts and joints hold where present, Escape cancels). + ## 3D sketch bridges 3D sketch drawing snaps to points on other visible sketch planes without flattening them onto diff --git a/THIRD-PARTY-LICENSES.txt b/THIRD-PARTY-LICENSES.txt index 96c9926f65..73546a9446 100644 --- a/THIRD-PARTY-LICENSES.txt +++ b/THIRD-PARTY-LICENSES.txt @@ -1,6 +1,6 @@ Third-party software in SolidCraft -SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 563 crates below. +SolidCraft (the desktop app, solidcraft-cli and the web app) is built from the 559 crates below. Generated by `cargo xtask licences` from Cargo.lock and the crates' own licence files. SolidCraft's own licence is in LICENSE-MIT and LICENSE-APACHE, and NOTICE. @@ -60,13 +60,13 @@ bytemuck_derive 1.12.1 (Zlib OR Apache-2.0 OR MIT) byteorder 1.5.0 (Unlicense OR MIT) byteorder-lite 0.1.0 (Unlicense OR MIT) bytes 1.12.1 (MIT) -cadmpeg-codec-freecad 0.6.1 (Apache-2.0) -cadmpeg-codec-rhino 0.6.1 (Apache-2.0) -cadmpeg-codec-step 0.6.1 (Apache-2.0) -cadmpeg-container 0.6.1 (Apache-2.0) -cadmpeg-core 0.6.1 (Apache-2.0) -cadmpeg-ir 0.6.1 (Apache-2.0) -cadmpeg-registry 0.6.1 (Apache-2.0) +cadmpeg-codec-freecad 0.6.3 (Apache-2.0) +cadmpeg-codec-rhino 0.6.3 (Apache-2.0) +cadmpeg-codec-step 0.6.3 (Apache-2.0) +cadmpeg-container 0.6.3 (Apache-2.0) +cadmpeg-core 0.6.3 (Apache-2.0) +cadmpeg-ir 0.6.3 (Apache-2.0) +cadmpeg-registry 0.6.3 (Apache-2.0) calloop 0.13.0 (MIT) calloop 0.14.5 (MIT) calloop-wayland-source 0.3.0 (MIT) @@ -355,6 +355,7 @@ rustls-platform-verifier 0.7.1 (MIT OR Apache-2.0) rustls-platform-verifier-android 0.2.0 (MIT OR Apache-2.0) rustls-webpki 0.103.15 (ISC) rustversion 1.0.23 (MIT OR Apache-2.0) +ruzstd 0.9.1 (MIT) schannel 0.1.29 (MIT) schemars 1.2.3 (MIT) schemars_derive 1.2.3 (MIT) @@ -432,6 +433,7 @@ truck-shapeops 0.4.0 (Apache-2.0) truck-stepio 0.3.0 (Apache-2.0) truck-topology 0.6.0 (Apache-2.0) ttf-parser 0.25.1 (MIT OR Apache-2.0) +twox-hash 2.1.5 (MIT) type-map 0.5.1 (MIT/Apache-2.0) typed-path 0.12.3 (MIT OR Apache-2.0) typeid 1.0.3 (MIT OR Apache-2.0) @@ -549,16 +551,10 @@ zeroize_derive 1.5.0 (Apache-2.0 OR MIT) zerotrie 0.2.5 (Unicode-3.0) zerovec 0.11.8 (Unicode-3.0) zerovec-derive 0.11.6 (Unicode-3.0) -zip 8.6.0 (MIT) zip 9.0.4 (MIT) zlib-rs 0.6.8 (Zlib) zmij 1.0.23 (MIT) zopfli 0.8.3 (Apache-2.0) -zstd 0.13.3 (MIT) -zstd 0.14.2 (BSD-3-Clause) -zstd-safe 7.3.0 (BSD-3-Clause) -zstd-safe 8.1.0 (BSD-3-Clause) -zstd-sys 2.1.1+zstd.1.5.7 (BSD-3-Clause) zune-core 0.5.3 (MIT OR Apache-2.0 OR Zlib) zune-jpeg 0.5.15 (MIT OR Apache-2.0 OR Zlib) zvariant 4.2.0 (MIT) @@ -12457,6 +12453,32 @@ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. The files under third-party/chromium are licensed as described in third-party/chromium/LICENSE. +================================================================================ +ruzstd 0.9.1 +================================================================================ + +MIT License + +Copyright (c) 2019 Moritz Borcherding + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + ================================================================================ schannel 0.1.29 ================================================================================ @@ -13718,6 +13740,32 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +================================================================================ +twox-hash 2.1.5 +================================================================================ + +The MIT License (MIT) + +Copyright (c) 2015 Jake Goulding + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + ================================================================================ type-map 0.5.1 ================================================================================ @@ -16339,7 +16387,7 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================================================ -zip 8.6.0, zip 9.0.4 +zip 9.0.4 ================================================================================ The MIT License (MIT) @@ -16594,89 +16642,6 @@ Apache License See the License for the specific language governing permissions and limitations under the License. -================================================================================ -zstd 0.13.3 -================================================================================ - -The MIT License (MIT) -Copyright (c) 2016 Alexandre Bury - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -================================================================================ -zstd 0.14.2, zstd-safe 7.3.0, zstd-safe 8.1.0, zstd-sys 2.1.1+zstd.1.5.7 -================================================================================ - -BSD 3-Clause License - -Copyright (c) 2026, Alexandre Bury - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -1. Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - -2. Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -3. Neither the name of the copyright holder nor the names of its - contributors may be used to endorse or promote products derived from - this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -================================================================================ -zstd-sys 2.1.1+zstd.1.5.7 -================================================================================ - -The auto-generated bindings are under the 3-clause BSD license: - -BSD License - -For Zstandard software - -Copyright (c) Meta Platforms, Inc. and affiliates. All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: - - * Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - - * Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - - * Neither the name Facebook, nor Meta, nor the names of its contributors may - be used to endorse or promote products derived from this software without - specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON -ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - ================================================================================ zune-core 0.5.3, zune-jpeg 0.5.15 ================================================================================ @@ -16744,13 +16709,13 @@ accesskit_winit 0.32.2: Apache-2.0 (https://github.com/AccessKit/accesskit) array-macro 2.1.8: MIT OR Apache-2.0 (https://codeberg.org/xfix/array-macro) block2 0.5.1: MIT (https://github.com/madsmtm/objc2) block2 0.6.2: MIT (https://github.com/madsmtm/objc2) -cadmpeg-codec-freecad 0.6.1: Apache-2.0 (https://github.com/cadmpeg/cadmpeg) -cadmpeg-codec-rhino 0.6.1: Apache-2.0 (https://github.com/cadmpeg/cadmpeg) -cadmpeg-codec-step 0.6.1: Apache-2.0 (https://github.com/cadmpeg/cadmpeg) -cadmpeg-container 0.6.1: Apache-2.0 (https://github.com/cadmpeg/cadmpeg) -cadmpeg-core 0.6.1: Apache-2.0 (https://github.com/cadmpeg/cadmpeg) -cadmpeg-ir 0.6.1: Apache-2.0 (https://github.com/cadmpeg/cadmpeg) -cadmpeg-registry 0.6.1: Apache-2.0 (https://github.com/cadmpeg/cadmpeg) +cadmpeg-codec-freecad 0.6.3: Apache-2.0 (https://github.com/cadmpeg/cadmpeg) +cadmpeg-codec-rhino 0.6.3: Apache-2.0 (https://github.com/cadmpeg/cadmpeg) +cadmpeg-codec-step 0.6.3: Apache-2.0 (https://github.com/cadmpeg/cadmpeg) +cadmpeg-container 0.6.3: Apache-2.0 (https://github.com/cadmpeg/cadmpeg) +cadmpeg-core 0.6.3: Apache-2.0 (https://github.com/cadmpeg/cadmpeg) +cadmpeg-ir 0.6.3: Apache-2.0 (https://github.com/cadmpeg/cadmpeg) +cadmpeg-registry 0.6.3: Apache-2.0 (https://github.com/cadmpeg/cadmpeg) clipboard-win 5.4.1: BSL-1.0 (https://github.com/DoumanAsh/clipboard-win) convert_case 0.4.0: MIT (https://github.com/rutrum/convert-case) dispatch 0.2.0: MIT (http://github.com/SSheldon/rust-dispatch) diff --git a/apps/solidcraft-cli/src/bench_agent.rs b/apps/solidcraft-cli/src/bench_agent.rs index a5cb4bc1c5..d218671905 100644 --- a/apps/solidcraft-cli/src/bench_agent.rs +++ b/apps/solidcraft-cli/src/bench_agent.rs @@ -36,8 +36,9 @@ impl Script { ); } + /// A part placed after it is built: free, even as the design's first part (#2804). fn component(&mut self, name: &str) { - self.c("component.create", json!({"name": name})); + self.c("component.create", json!({"name": name, "grounded": false})); } fn root(&mut self) { diff --git a/apps/solidcraft-cli/src/main.rs b/apps/solidcraft-cli/src/main.rs index c4fb7b3278..2636ea6726 100644 --- a/apps/solidcraft-cli/src/main.rs +++ b/apps/solidcraft-cli/src/main.rs @@ -15,7 +15,7 @@ //! solidcraft-cli commands the command registry as JSON //! solidcraft-cli recipe translate an oracle recipe to a script //! solidcraft-cli oracle ... replay recipes and compare with expected.json -//! solidcraft-cli mcp [--in design|script] [--connect HOST:PORT [--token T]] [--allow-path DIR]... [--allow-any-path] +//! solidcraft-cli mcp [--headless] [--in design|script] [--connect HOST:PORT [--token T]] [--allow-path DIR]... [--allow-any-path] //! [--param-check off|warn|strict] //! MCP server on stdio (docs/mcp.md) //! solidcraft-cli licences SolidCraft's licence and the third-party licences @@ -82,7 +82,7 @@ const USAGE: &str = "usage: solidcraft-cli recipe solidcraft-cli oracle ... [--json] solidcraft-cli fuzz [--from N] [--count N] [--jobs J] [--timeout S] [--steps N] [--minimise] [--out DIR] [--report FILE] - solidcraft-cli mcp [--in design.solidcraft|script.json] [--connect 127.0.0.1:PORT [--token T]] [--allow-path DIR]... [--allow-any-path] + solidcraft-cli mcp [--headless] [--in design.solidcraft|script.json] [--connect 127.0.0.1:PORT [--token T]] [--allow-path DIR]... [--allow-any-path] [--param-check off|warn|strict] solidcraft-cli crashes [list [--json] | show | replay | dir | clear []] solidcraft-cli licences @@ -601,17 +601,37 @@ fn cmd_mcp(args: &[String]) -> Result<(), String> { Box::new(remote) } (None, Some(path)) => Box::new(solidcraft_mcp::Headless::new(load_checked(path, param_check)?)), + // No arguments: the running desktop app, found through the discovery file it wrote + // (docs/control-protocol.md); none running (or `--headless`): a session of its own. (None, None) => { + if !args.iter().any(|a| a == "--headless") + && let Some(port) = discover_app() + { + eprintln!("solidcraft mcp: bridged to the running app at 127.0.0.1:{port} (--headless starts a session of its own)"); + return serve_mcp(Box::new(solidcraft_mcp::Remote::new(&format!("127.0.0.1:{port}"), flag(args, "--token"))), scope); + } let mut headless = solidcraft_mcp::Headless::default(); headless.session.param_check = param_check; Box::new(headless) } }; + serve_mcp(backend, scope) +} + +fn serve_mcp(backend: Box, scope: Option) -> Result<(), String> { let stdin = std::io::stdin(); let stdout = std::io::stdout(); solidcraft_mcp::Server::new(backend).with_path_scope(scope).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string()) } +/// The port of the running desktop app: the one its discovery file names, if something listens +/// there (a file left by an app that crashed names a closed port). +fn discover_app() -> Option { + let port = solidcraft_engine::control_auth::read_current_port()?; + let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port)); + std::net::TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(500)).ok().map(|_| port) +} + /// `save-stress `: save a design of a few hundred kilobytes `rounds` times, its /// parameter `save_round` set to the round number each time. fn cmd_save_stress(args: &[String]) -> Result<(), String> { diff --git a/apps/solidcraft-cli/tests/action_matrix.rs b/apps/solidcraft-cli/tests/action_matrix.rs index 6a21e2dcdb..5d0a52b4f0 100644 --- a/apps/solidcraft-cli/tests/action_matrix.rs +++ b/apps/solidcraft-cli/tests/action_matrix.rs @@ -1412,7 +1412,7 @@ fn a_sketch_on_a_patterned_boss_top() { fn construction_geometry_and_holes_on_a_moved_component() { let moved = || { vec![ - json!({"command": "component.create", "params": {"name": "Arm"}}), + json!({"command": "component.create", "params": {"name": "Arm", "grounded": false}}), json!({"command": "solid.box", "params": {"corner": [20, 0, 0], "length": 10, "width": 4, "height": 4, "body_name": "ArmBody"}}), json!({"command": "component.activate", "params": {"component": 0}}), json!({"command": "occurrence.move", "params": {"occurrence": "Arm:1", "translate": [0, 50, 0]}}), diff --git a/apps/solidcraft-cli/tests/corpus/scripts/components-arm.json b/apps/solidcraft-cli/tests/corpus/scripts/components-arm.json index 3b7cb3563f..3cd6d06334 100644 --- a/apps/solidcraft-cli/tests/corpus/scripts/components-arm.json +++ b/apps/solidcraft-cli/tests/corpus/scripts/components-arm.json @@ -1,6 +1,6 @@ {"commands": [ {"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10, "body_name": "Base"}}, - {"command": "component.create", "params": {"name": "Arm"}}, + {"command": "component.create", "params": {"name": "Arm", "grounded": false}}, {"command": "solid.box", "params": {"corner": [20, 0, 0], "length": 10, "width": 4, "height": 4, "body_name": "ArmBody"}}, {"command": "solid.fillet", "params": {"edges": [[25, 4, 4]], "radius": 1}}, {"command": "component.activate", "params": {"component": 0}}, diff --git a/apps/solidcraft-cli/tests/mcp_connect.rs b/apps/solidcraft-cli/tests/mcp_connect.rs index e96ebd7d2b..1697152b8e 100644 --- a/apps/solidcraft-cli/tests/mcp_connect.rs +++ b/apps/solidcraft-cli/tests/mcp_connect.rs @@ -89,3 +89,65 @@ fn mcp_connect_without_a_port_is_refused_at_start() { assert!(stderr.contains("HOST:PORT"), "{addr}: {stderr}"); } } + +/// `mcp` with no arguments finds the running app through the discovery file and uses that +/// launch's token; with no app behind the file it stays a headless session. +#[test] +fn mcp_without_arguments_finds_the_running_app() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let control_dir = std::env::temp_dir().join(format!("solidcraft-mcp-discover-{}-{port}", std::process::id())); + std::fs::create_dir_all(&control_dir).unwrap(); + let token = "fedcba9876543210".repeat(4); + std::fs::write(control_dir.join(format!("{port}.token")), &token).unwrap(); + std::fs::write(control_dir.join("current.port"), format!("{port}\n")).unwrap(); + let app = std::thread::spawn(move || { + // The discovery probe connects and leaves without a request; the tool call follows. + let (stream, line) = loop { + let (stream, _) = listener.accept().unwrap(); + let mut line = String::new(); + BufReader::new(stream.try_clone().unwrap()).read_line(&mut line).unwrap(); + if !line.is_empty() { + break (stream, line); + } + }; + let req: Value = serde_json::from_str(&line).unwrap(); + let ok = req["token"] == json!(token); + let reply = json!({"id": req["id"], "ok": ok, "result": {"bodies": [], "method": req["method"]}, "error": "unauthorized"}); + writeln!(&stream, "{reply}").unwrap(); + }); + let mut child = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")) + .args(["mcp", "--allow-any-path"]) + .env("SOLIDCRAFT_CONTROL_DIR", &control_dir) + .env_remove("SOLIDCRAFT_CONTROL_TOKEN") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let mut stdin = child.stdin.take().unwrap(); + let mut stdout = BufReader::new(child.stdout.take().unwrap()); + writeln!(stdin, "{}", json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "measure", "arguments": {}}})).unwrap(); + let mut line = String::new(); + stdout.read_line(&mut line).unwrap(); + let r: Value = serde_json::from_str(&line).unwrap_or_else(|e| panic!("{e}: {line:?}")); + assert_ne!(r["result"]["isError"], true, "{r}"); + assert!(r["result"]["content"][0]["text"].as_str().unwrap_or_default().contains("engine.execute"), "{r}"); + app.join().unwrap(); + drop(stdin); + assert!(child.wait().unwrap().success()); + + // The file names a port nothing listens on (the app crashed): a headless session instead. + let closed = TcpListener::bind("127.0.0.1:0").unwrap(); + let dead = closed.local_addr().unwrap().port(); + drop(closed); + std::fs::write(control_dir.join("current.port"), format!("{dead}\n")).unwrap(); + let out = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")) + .args(["mcp", "--allow-any-path"]) + .env("SOLIDCRAFT_CONTROL_DIR", &control_dir) + .stdin(Stdio::null()) + .output() + .unwrap(); + assert!(!String::from_utf8_lossy(&out.stderr).contains("bridged to the running app"), "{}", String::from_utf8_lossy(&out.stderr)); + std::fs::remove_dir_all(&control_dir).ok(); +} diff --git a/apps/solidcraft-web/src/disk.rs b/apps/solidcraft-web/src/disk.rs index 19af6aa5f5..c9aa086cf1 100644 --- a/apps/solidcraft-web/src/disk.rs +++ b/apps/solidcraft-web/src/disk.rs @@ -294,7 +294,7 @@ mod web { let options = Object::new(); let _ = Reflect::set(&options, &"multiple".into(), &JsValue::FALSE); let extensions = Array::new(); - for e in [".solidcraft", ".solvecraft", ".json", ".step", ".stp", ".3mf", ".stl", ".obj", ".glb", ".gltf"] { + for e in [".solidcraft", ".solvecraft", ".json", ".step", ".stp", ".3mf", ".stl", ".obj", ".glb", ".gltf", ".fcstd", ".3dm"] { extensions.push(&e.into()); } let accept = Object::new(); diff --git a/apps/solidcraft-web/src/files.rs b/apps/solidcraft-web/src/files.rs index 4bedaed404..5e7d67fcc0 100644 --- a/apps/solidcraft-web/src/files.rs +++ b/apps/solidcraft-web/src/files.rs @@ -499,8 +499,9 @@ fn input_picker(files: &Rc, intent: Intent) { let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return }; let Ok(input) = doc.create_element("input").map(|e| e.unchecked_into::()) else { return }; input.set_type("file"); - // Everything the browser build imports, from the format registry (no cadmpeg formats here). - let accept = solidcraft_engine::io::formats::open_extensions(false).iter().map(|e| format!(".{e}")).collect::>().join(","); + // Everything the browser build imports, from the format registry: cadmpeg is pure Rust, so + // FreeCAD and Rhino files are part of it. + let accept = solidcraft_engine::io::formats::open_extensions(true).iter().map(|e| format!(".{e}")).collect::>().join(","); input.set_accept(&accept); // Attached (hidden) while it is open: some browsers ignore a click on a detached input. let _ = input.set_attribute("style", "display:none"); diff --git a/apps/solidcraft-web/src/space_mouse.rs b/apps/solidcraft-web/src/space_mouse.rs index 3fecfd0279..47629211a8 100644 --- a/apps/solidcraft-web/src/space_mouse.rs +++ b/apps/solidcraft-web/src/space_mouse.rs @@ -246,11 +246,11 @@ mod tests { let axis = |source, rest, full| solidcraft_ui_egui::space_mouse::AxisCalibration { source, reversed: false, rest, dead_zone: 0, full: [full; 2] }; // A short-range device: 15 of its 50 is under the raw dead zone, but 105 of 350 calibrated. - let short = Calibration { dead_zone_share: 0.0, axes: std::array::from_fn(|r| axis(r, 0, 50)) }; + let short = Calibration { dead_zone_share: 0.0, kind: Default::default(), axes: std::array::from_fn(|r| axis(r, 0, 50)) }; let push = Axes { translation: [15, 0, 0], rotation: [0; 3] }; assert!(!push.moving() && moving(push, Some(short))); // A device resting at 100 on every axis: past the raw dead zone, but at rest calibrated. - let offset = Calibration { dead_zone_share: 0.0, axes: std::array::from_fn(|r| axis(r, 100, 350)) }; + let offset = Calibration { dead_zone_share: 0.0, kind: Default::default(), axes: std::array::from_fn(|r| axis(r, 100, 350)) }; let resting = Axes { translation: [100; 3], rotation: [100; 3] }; assert!(resting.moving() && !moving(resting, Some(offset))); // Without a calibration, as read. diff --git a/apps/solidcraft/src/control_server.rs b/apps/solidcraft/src/control_server.rs index 5466f0b4e9..ea4a65d4dc 100644 --- a/apps/solidcraft/src/control_server.rs +++ b/apps/solidcraft/src/control_server.rs @@ -11,6 +11,7 @@ use std::time::{Duration, Instant}; use serde_json::{Value, json}; use solidcraft_engine::MAX_LINE; use solidcraft_engine::control_auth; +use solidcraft_engine::control_pending; use solidcraft_ui_egui::ControlRequest; /// Connections served at once, so a flood must not be able to spawn threads without end. @@ -19,7 +20,7 @@ const MAX_CONNECTIONS: usize = 16; const MAX_BAD_LINES: usize = 8; static OPEN: AtomicUsize = AtomicUsize::new(0); -/// The token files this launch wrote (its `--control` port, the agent chat's endpoint), removed +/// The token files this launch wrote (its control port, the agent chat's endpoint), removed /// when the app ends. static TOKEN_FILES: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); @@ -37,14 +38,42 @@ pub fn remove_token_file() { let _ = std::fs::remove_file(p); } } + if let Some(port) = APP_PORT.lock().ok().and_then(|p| *p) { + control_auth::remove_current_port(port); + } } -pub fn start(port: u16, ctx: egui::Context) -> Receiver { +/// The port the app's own channel listens on, for removing the discovery file at exit. +static APP_PORT: std::sync::Mutex> = std::sync::Mutex::new(None); + +pub fn start(listen_on: control_auth::Listen, ctx: egui::Context) -> Receiver { let (tx, rx) = channel::(); // Every connection must show the token before anything runs (docs/control-protocol.md). match control_auth::env_token().or_else(new_token) { Some(token) => { - listen(port, token, tx, ctx, false); + let listener = match listen_on { + control_auth::Listen::Exact(port) => TcpListener::bind(("127.0.0.1", port)), + control_auth::Listen::Default => control_auth::bind_with_fallback(control_auth::DEFAULT_PORT, control_auth::PORT_TRIES), + }; + match listener { + Ok(l) => { + if let Some(port) = listen_on_listener(l, token, tx, ctx, false) { + if matches!(listen_on, control_auth::Listen::Default) { + eprintln!("solidcraft: control channel on the default port 127.0.0.1:{port} (Preferences > Assistant turns it off)"); + } + // The discovery file: where `solidcraft-cli mcp` finds this app with no arguments. + match control_auth::write_current_port(port) { + Ok(_) => { + if let Ok(mut p) = APP_PORT.lock() { + *p = Some(port); + } + } + Err(e) => eprintln!("solidcraft: could not write the control discovery file ({e})"), + } + } + } + Err(e) => eprintln!("solidcraft: control server failed to bind 127.0.0.1: {e}"), + } } None => eprintln!("solidcraft: control server not started: no random token available"), } @@ -60,20 +89,20 @@ pub fn listen_agent(tx: Sender, ctx: egui::Context) -> Option, ctx: egui::Context, patient: bool) -> Option { - let listener = match TcpListener::bind(("127.0.0.1", port)) { + let listener = match TcpListener::bind(("127.0.0.1", 0)) { Ok(l) => l, Err(e) => { - eprintln!("solidcraft: control server failed to bind 127.0.0.1:{port}: {e}"); + eprintln!("solidcraft: control server failed to bind 127.0.0.1: {e}"); return None; } }; - let port = listener.local_addr().map(|a| a.port()).unwrap_or(port); + listen_on_listener(listener, token, tx, ctx, true) +} + +/// Serve `listener` (loopback) with `token`; the port it listens on. +/// `patient`: every request may wait as long as a batch (a person decides on it). +fn listen_on_listener(listener: TcpListener, token: String, tx: Sender, ctx: egui::Context, patient: bool) -> Option { + let port = listener.local_addr().ok()?.port(); match control_auth::token_path(port) { Some(path) => match control_auth::write_token_file(&path, &token) { Ok(()) => { @@ -176,6 +205,8 @@ const WAITS: Waits = Waits { short: Duration::from_secs(60), long: Duration::fro struct Pending { id: Value, method: String, + /// The request in messages ("`engine.execute` (solid.fillet)"). + what: String, reply: Receiver, } @@ -296,6 +327,35 @@ fn watch(reply: Receiver, ticket: Ticket) -> Receiver { late_rx } +/// The reply text for a request that outlasts its wait. A request that only reads changes +/// nothing when it finishes; an export or a save still writes its file, and any other request +/// still applies, as it was not cancelled. +fn timeout_message(what: &str, effect: control_pending::Effect) -> String { + match effect { + control_pending::Effect::ReadsOnly => { + format!("timeout: {what} is still running in the app ({}); ask `control.pending` for its answer", effect.when_done()) + } + control_pending::Effect::WritesFile => format!( + "timeout: {what} is still running in the app and will still write the file when it finishes (it was not cancelled: \ + don't send it again; the design does not change); ask `control.pending` for its result" + ), + control_pending::Effect::Changes => format!( + "timeout: {what} is still running in the app and will still apply when it finishes (it was not cancelled: don't send it \ + again); ask `control.pending` for its result" + ), + } +} + +/// The reply text for a request refused because an earlier one on the connection still runs. +fn busy_message(p: &Pending, method: &str) -> String { + let method: String = method.chars().take(128).collect(); + format!( + "busy: request {} ({}) is still running, so `{method}` was not run; ask `control.pending` for that request's result, then \ + send this one again", + p.id, p.what + ) +} + fn serve(stream: TcpStream, token: &str, tx: Sender, ctx: egui::Context, patient: bool) { serve_with(stream, token, tx, ctx, patient, WAITS, &RUNNING); } @@ -364,7 +424,7 @@ fn serve_with( } } else if let Some(p) = pending.as_ref().filter(|_| late.is_none()) { // Nothing more is queued behind it, to run unseen when it ends. - json!({"ok": false, "busy": true, "error": format!("busy: request {} (`{}`) is still running; ask `control.pending` for its result", p.id, p.method)}) + json!({"ok": false, "busy": true, "error": busy_message(p, &method)}) } else { if late.is_some() { // Finished, and the client moved on without asking: it is dropped. @@ -372,6 +432,8 @@ fn serve_with( } // A batch replaces many short requests: the whole of it may take long (#743). let wait = if patient || method == "engine.batch" { waits.long } else { waits.short }; + let what = control_pending::describe(&method, ¶ms); + let effect = control_pending::effect(&method, ¶ms); let (req, rrx) = ControlRequest::new(method.clone(), params); let ticket = running.add(connection, &id, &req); if tx.send(req).is_err() { @@ -381,8 +443,8 @@ fn serve_with( match rrx.recv_timeout(wait) { Ok(r) => r, Err(_) => { - let r = json!({"ok": false, "running": true, "error": format!("timeout: `{method}` is still running in the app and will still apply; ask `control.pending` for its result")}); - pending = Some(Pending { id: id.clone(), method, reply: watch(rrx, ticket) }); + let r = json!({"ok": false, "running": true, "error": timeout_message(&what, effect)}); + pending = Some(Pending { id: id.clone(), method, what, reply: watch(rrx, ticket) }); r } } @@ -577,6 +639,25 @@ mod tests { assert_eq!((&r["result"]["pending"], &r["result"]["done"]), (&json!(7), &json!(true)), "{r}"); } + /// A timed-out request that only reads is not said to "still apply", nor is one that only + /// writes a file (an export, #1989); a change is, by name. + #[test] + fn timeout_text_tells_reads_from_changes() { + let says = |method: &str, p: Value| timeout_message(&control_pending::describe(method, &p), control_pending::effect(method, &p)); + let read = says("ui.inspect", json!({})); + assert!(read.starts_with("timeout") && read.contains("only reads") && !read.contains("will still apply"), "{read}"); + let read = says("engine.execute", json!({"command": "sketch.inspect"})); + assert!(read.contains("only reads") && read.contains("sketch.inspect"), "{read}"); + let change = says("engine.execute", json!({"command": "solid.fillet"})); + assert!(change.starts_with("timeout") && change.contains("will still apply") && change.contains("solid.fillet"), "{change}"); + let export = says("engine.execute", json!({"command": "file.export", "params": {"path": "/tmp/wall.step"}})); + assert!(export.contains("will still write the file") && !export.contains("apply") && export.contains("file.export"), "{export}"); + let save = says("engine.execute", json!({"command": "file.save"})); + assert!(save.contains("will still write the file") && !save.contains("apply"), "{save}"); + let render = says("ui.render", json!({"path": "/tmp/a.png"})); + assert!(render.contains("will still write the file"), "{render}"); + } + /// #205: a connection runs nothing until a request carries the launch's token; a wrong or /// missing token, or garbage, is refused and the connection closed. Once shown, the token /// need not be repeated on that connection. diff --git a/apps/solidcraft/src/main.rs b/apps/solidcraft/src/main.rs index f5eac643e5..4cc2f50550 100644 --- a/apps/solidcraft/src/main.rs +++ b/apps/solidcraft/src/main.rs @@ -10,6 +10,7 @@ //! //! `--control ` (or `SOLIDCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control //! server: `{"id":1,"method":"engine.execute","params":{"command":"solid.box","params":{…}}}`. +//! Without them the channel still opens, on the default port, unless Preferences turns it off. //! See `solidcraft_ui_egui::control` and docs/control-protocol.md. #![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -140,14 +141,17 @@ impl eframe::App for App { ctx.send_viewport_cmd(egui::ViewportCommand::Close); } } - fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { + fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { #[cfg(target_os = "macos")] if let Some(menu) = &mut self.native_menu { - menu.raw_input_hook(&mut self.solve, _ctx, raw); + menu.raw_input_hook(&mut self.solve, ctx, raw); } // Before the app's own hook, which adds the control channel's events. #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] self.space_mouse.raw_input_hook(self.solve.preferences.space_mouse_enabled, raw); + // The 3D mouse's buttons, before the app reads the frame: Esc and held modifiers join its input. + #[cfg(any(target_os = "windows", target_os = "linux", target_os = "macos"))] + solidcraft_ui_egui::space_mouse::buttons_hook(&mut self.solve, ctx, self.space_mouse.buttons(), raw); self.solve.raw_input_hook(raw); } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { @@ -741,8 +745,10 @@ fn main() -> eframe::Result { )); } mark("viewport pipelines"); - if let Some(port) = control_port { - let rx = control_server::start(port, cc.egui_ctx.clone()); + // `--control`/the environment name a port; otherwise the preference (on by default) + // opens the default one (docs/control-protocol.md). + if let Some(listen_on) = solidcraft_engine::control_auth::plan(control_port, app.preferences.agent_control) { + let rx = control_server::start(listen_on, cc.egui_ctx.clone()); app = app.with_control(rx); } // The agent chat's MCP bridge reaches the app through an endpoint of its own, started diff --git a/apps/solidcraft/src/space_mouse.rs b/apps/solidcraft/src/space_mouse.rs index c037f8f6c6..7b8514abf2 100644 --- a/apps/solidcraft/src/space_mouse.rs +++ b/apps/solidcraft/src/space_mouse.rs @@ -14,7 +14,7 @@ mod windows_hid; use std::ffi::CString; #[cfg(target_os = "linux")] use std::sync::atomic::AtomicU8; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -33,6 +33,10 @@ use solidcraft_ui_egui::prefs::SpaceMouseDriver; use solidcraft_ui_egui::space_mouse::DriversPresent; use solidcraft_ui_egui::space_mouse::{Axes, AxisRule, Calibration, DriverMapping, WheelGuard, calibrated, calibration, navigate_frame}; +/// The fastest the window repaints for the device's reports: a device sends them faster than anyone +/// sees, and each one used to be a frame of its own. +const REPAINT_EVERY: Duration = Duration::from_millis(16); + const STALE_AFTER: Duration = Duration::from_millis(150); /// How long a held (spacenavd) reading that is nearly at rest may go without a new record before /// it reads as rest: spacenavd sends no final record when the cap springs back, only the last @@ -200,6 +204,25 @@ enum Source { } impl Source { + /// Which read path the source is, for the log. + fn path(self) -> &'static str { + match self { + Self::Hid(_) => "the hidraw device", + #[cfg(target_os = "linux")] + Self::Spacenavd => "spacenavd", + #[cfg(target_os = "macos")] + Self::Driver => "the 3Dconnexion driver", + } + } + + /// The key a device's saved settings are kept under: where the reading comes from and, for a device + /// read directly, its vendor and product ids (`hid 046d:c62b`). Never the hidraw node or a device + /// number, which change at every replug. Two mice of one model share a slot (the kernel gives no + /// serial here); the calibration is of the model's axes, so that is the same calibration. A reading + /// through spacenavd or the 3Dconnexion driver is a different reading of the device (processed by + /// them), so it has its own key and its own calibration. That key is shared by every device the + /// daemon reads, so it cannot hold a device's own settings: Automatic therefore reads a device + /// directly whenever it opens, and the daemon only when no device does (see `choose`). fn key(self) -> String { match self { Self::Hid(Some((vendor, product))) => format!("hid {vendor:04x}:{product:04x}"), @@ -214,8 +237,11 @@ impl Source { #[derive(Clone, Copy)] struct Reading { - /// The device being read; none between devices. + /// The device being read; none between devices, and none while a daemon's socket is only reachable. device: Option, + /// A source that is connected but has not yet shown a device behind it (spacenavd's socket answers + /// with no mouse plugged in; its protocol has no enumeration, so the first record is the evidence). + awaiting: Option, axes: Axes, /// When each of X, Y, Z, Rx, Ry and Rz last arrived. Each ages on its own: a device may send /// translation and rotation in separate reports, or spread even one group over several. @@ -226,21 +252,80 @@ struct Reading { /// `HELD_SETTLE_AFTER`. A HID device repeats its report while the cap is held, so it ages out /// per axis instead. held: bool, + /// The buttons that are down: bit N for button N + 1, as the device (or spacenavd) numbers them. + buttons: u32, + /// The buttons that went down since the UI last looked, even if they came up again before it + /// did: a quick tap between two frames is still a press. It is a set, not a queue: two taps of one + /// button between two looks are one press, and run its job once. + taps: u32, + /// A motion report or record arrived since the UI last looked. Not whether the value changed: a + /// ball rolling at one speed sends the same reading again and again, and a daemon that truncates + /// it can send two different records that decode alike. The calibration window needs the + /// difference between that and a last value kept with nothing arriving. + arrived: bool, + /// The buttons' state has been seen once since this source connected. A HID device's first report + /// that carries buttons says which are down; one already down then is held (a reader swapped under + /// it, or the device was plugged in with it down), not pressed. Sources that send each press and + /// release as an event (spacenavd, the macOS driver) start from nothing down. + buttons_known: bool, + /// Which connection of the reader this reading belongs to; it changes at every connect, so a look + /// that missed a whole disconnect and reconnect of the same device can tell it was one. + generation: u32, } impl Reading { + /// The buttons that are down now; those that just went down are kept as taps until taken. The first + /// state a HID source reports is only taken as the baseline: a button down in it is held, not a tap. + fn set_buttons(&mut self, buttons: u32) { + if !self.buttons_known { + self.buttons_known = true; + self.buttons = buttons; + return; + } + self.taps |= buttons & !self.buttons; + self.buttons = buttons; + } + + /// A record arrived from the source being waited on: there is a device. + fn heard(&mut self) { + if self.device.is_none() { + self.device = self.awaiting.take(); + } + } + fn new(now: Instant) -> Self { - Self { device: None, axes: Axes::default(), at: [now; 6], held: false } + Self { + device: None, + awaiting: None, + axes: Axes::default(), + at: [now; 6], + held: false, + buttons: 0, + taps: 0, + arrived: false, + buttons_known: true, + generation: 0, + } } /// The axes at `now`. One with nothing fresh reads as `rest`: the device's own rest when it /// is calibrated, so a device that rests off zero calibrates to no motion rather than drifting. + #[cfg(test)] fn current(self, now: Instant, rest: Axes) -> Axes { + self.current_with(now, rest, Some(HELD_SETTLE_BAND)) + } + + /// [`Reading::current`] with the held-reading settle's `band` (see [`settle_band`]); `None` + /// turns it off, as for a trackball, whose reading is a speed and a steady slow roll is meant. + /// + /// Reads only `self.at[0]` for the age: `publish_held`, the only producer of a held reading, + /// stamps all six axes together, so they are never apart. + fn current_with(self, now: Instant, rest: Axes, band: Option) -> Axes { let mut axes = self.axes; if self.held { let values = axes.translation.into_iter().chain(axes.rotation); let rests = rest.translation.into_iter().chain(rest.rotation); - let near_rest = values.zip(rests).all(|(value, rest)| value.abs_diff(rest) <= HELD_SETTLE_BAND); + let near_rest = band.is_some_and(|band| values.zip(rests).all(|(value, rest)| value.abs_diff(rest) <= band)); if near_rest && now.duration_since(self.at[0]) >= HELD_SETTLE_AFTER { return rest; } @@ -257,6 +342,21 @@ impl Reading { } /// The raw axes at the device's rest, which calibrate to no motion; 0 without a calibration. +/// How near rest (raw units) every axis of a held reading must be for it to settle, or `None` for a +/// device whose steady reading is meant (a trackball). Without a calibration it is +/// `HELD_SETTLE_BAND`; with one it shrinks in step with the device's shortest calibrated travel (the +/// band is for a travel of 350), so a cap whose full push is 50 units does not lose a held full push. +fn settle_band(calibration: Option) -> Option { + let Some(calibration) = calibration else { return Some(HELD_SETTLE_BAND) }; + if calibration.kind == solidcraft_space_mouse::Kind::Trackball { + return None; + } + let travel = + calibration.axes.iter().filter(|axis| axis.source != solidcraft_space_mouse::NO_SOURCE).flat_map(|axis| axis.full).min().unwrap_or(350); + let band = u32::from(HELD_SETTLE_BAND) * u32::from(travel) / 350; + Some(u16::try_from(band).unwrap_or(HELD_SETTLE_BAND).min(HELD_SETTLE_BAND)) +} + fn at_rest(calibration: Option) -> Axes { calibration.map(|calibration| calibration.raw_rest().into()).unwrap_or_default() } @@ -281,6 +381,10 @@ fn decode_report(report: &[u8], layout: Layout, reading: &mut Reading, now: Inst *at = now; } } + // A report that carries buttons says which are down, all up included. + if let Some(buttons) = decoded.buttons { + reading.set_buttons(buttons); + } true } @@ -292,9 +396,13 @@ fn publish(ctx: &Context, shared: &Mutex, layout: Layout, report: &[u8] return; } if let Ok(mut reading) = shared.lock() { - let before = reading.axes; - if decode_report(report, layout, &mut reading, Instant::now()) && reading.axes != before { - ctx.request_repaint(); + let before = (reading.axes, reading.buttons, reading.taps); + if decode_report(report, layout, &mut reading, Instant::now()) { + // The calibration window needs to know a report arrived even when it reads the same. + reading.arrived = true; + if (reading.axes, reading.buttons, reading.taps) != before { + ctx.request_repaint_after(REPAINT_EVERY); + } } } } @@ -303,18 +411,61 @@ fn publish(ctx: &Context, shared: &Mutex, layout: Layout, report: &[u8] #[cfg(target_os = "linux")] fn publish_held(ctx: &Context, shared: &Mutex, axes: Axes) { if let Ok(mut reading) = shared.lock() { - *reading = Reading { device: reading.device, axes, held: true, ..Reading::new(Instant::now()) }; - ctx.request_repaint(); + reading.heard(); + *reading = Reading { + device: reading.device, + axes, + held: true, + buttons: reading.buttons, + taps: reading.taps, + arrived: true, + generation: reading.generation, + ..Reading::new(Instant::now()) + }; + ctx.request_repaint_after(REPAINT_EVERY); } } +/// A button pressed or released, from spacenavd, which numbers them from 0. +#[cfg(target_os = "linux")] +fn publish_button(ctx: &Context, shared: &Mutex, button: u32, down: bool) { + if let (Ok(mut reading), Some(bit)) = (shared.lock(), 1_u32.checked_shl(button)) { + reading.heard(); + let buttons = if down { reading.buttons | bit } else { reading.buttons & !bit }; + reading.set_buttons(buttons); + ctx.request_repaint_after(REPAINT_EVERY); + } +} + +/// Whether a connection to `source` says nothing about a device being there until it sends a record. +fn needs_evidence(source: Source) -> bool { + #[cfg(target_os = "linux")] + { + matches!(source, Source::Spacenavd) + } + #[cfg(not(target_os = "linux"))] + { + let _ = source; + false + } +} + +/// How many times a reader has connected to a source, for `Reading::generation`. +static CONNECTIONS: AtomicU32 = AtomicU32::new(0); + /// A device is open: say which, so its calibration applies. fn connected(ctx: &Context, shared: &Mutex, source: Source) { if let Ok(mut reading) = shared.lock() { // Nothing has arrived from it yet, so every axis starts out stale. let now = Instant::now(); let stale = now.checked_sub(STALE_AFTER).unwrap_or(now); - *reading = Reading { device: Some(source), at: [stale; 6], ..Reading::new(now) }; + // A daemon is not yet a device: it counts once a record comes from it. + let (device, awaiting) = if needs_evidence(source) { (None, Some(source)) } else { (Some(source), None) }; + // A HID device reports the state of its buttons, so the first state seen is a baseline; the daemon + // and the driver send presses and releases, which start from nothing down. + let buttons_known = !matches!(source, Source::Hid(_)); + let generation = CONNECTIONS.fetch_add(1, Ordering::Relaxed).wrapping_add(1); + *reading = Reading { device, awaiting, at: [stale; 6], buttons_known, generation, ..Reading::new(now) }; } ctx.request_repaint(); } @@ -431,14 +582,18 @@ fn device_layout(device: &hidapi::HidDevice, vendor_id: u16) -> Option { struct Drivers { /// `SpaceMouseDriver::to_u8` of the preference. choice: AtomicU8, - /// `PRESENT_SPACENAVD` and `PRESENT_DIRECT`, as of the reader's last look. + /// `PRESENT_SPACENAVD`, `PRESENT_DIRECT` and `PRESENT_DENIED`, as of the reader's last look. found: AtomicU8, + /// Ends the reader. + quit: AtomicBool, } #[cfg(target_os = "linux")] const PRESENT_SPACENAVD: u8 = 1; #[cfg(target_os = "linux")] const PRESENT_DIRECT: u8 = 2; +#[cfg(target_os = "linux")] +const PRESENT_DENIED: u8 = 4; /// How often a connected reader looks again at what is there, and how long a read of spacenavd /// waits for a record before it checks the choice. #[cfg(target_os = "linux")] @@ -454,11 +609,13 @@ impl Drivers { fn present(&self) -> DriversPresent { let found = self.found.load(Ordering::Relaxed); - DriversPresent { spacenavd: found & PRESENT_SPACENAVD != 0, direct: found & PRESENT_DIRECT != 0 } + DriversPresent { spacenavd: found & PRESENT_SPACENAVD != 0, direct: found & PRESENT_DIRECT != 0, denied: found & PRESENT_DENIED != 0 } } fn publish(&self, present: DriversPresent) { - let found = (u8::from(present.spacenavd) * PRESENT_SPACENAVD) | (u8::from(present.direct) * PRESENT_DIRECT); + let found = (u8::from(present.spacenavd) * PRESENT_SPACENAVD) + | (u8::from(present.direct) * PRESENT_DIRECT) + | (u8::from(present.denied) * PRESENT_DENIED); self.found.store(found, Ordering::Relaxed); } } @@ -471,13 +628,66 @@ enum Pick { Direct, } +/// How long a direct device that failed to open stays out of the choice before it is tried again. +#[cfg(target_os = "linux")] +const UNREADABLE_RETRY: Duration = Duration::from_secs(30); + +/// When the direct device last failed to open, and which node has had its failure said out loud: a +/// device that is there but unreadable (no permission) does not keep the choice from a daemon that +/// works, and the warning about the udev rule is made once per node, not every retry. +#[cfg(target_os = "linux")] +#[derive(Default)] +struct Unreadable { + at: Option, + said: Option, +} + +#[cfg(target_os = "linux")] +impl Unreadable { + /// `path` could not be opened. Whether to say so: not if this node's failure was already said. + fn failed(&mut self, path: &std::path::Path) -> bool { + self.at = Some(Instant::now()); + let new = self.said.as_deref() != Some(path); + if new { + self.said = Some(path.to_owned()); + } + new + } + + /// A device opened: nothing is held against it, and another node's failure is news again. + fn opened(&mut self) { + *self = Self::default(); + } +} + +/// Whether the direct reader can be chosen: a device was found, and it did not just fail to open +/// (`since_failed`, how long ago, if it did): without that a device that is there but unreadable +/// (no permission) would keep winning and a working spacenavd would never be tried. +#[cfg(target_os = "linux")] +fn direct_usable(found: bool, since_failed: Option, retry: Duration) -> bool { + found && since_failed.is_none_or(|ago| ago >= retry) +} + +/// Whether a read of the daemon should end because the direct device would be the choice now: it was +/// away and is back, or it failed to open, the wait (`retry`) is over (`failed`, how long since) and +/// the caller has found that it opens. A read of anything else is left alone: a device that works is +/// not given up for a daemon that has appeared. +#[cfg(target_os = "linux")] +fn switch_back(wanted: SpaceMouseDriver, pick: Option, present: DriversPresent, failed: Option, retry: Duration) -> bool { + pick == Some(Pick::Spacenavd) && choose(wanted, present.spacenavd, direct_usable(present.direct, failed, retry)) == Some(Pick::Direct) +} + /// Which reader to use: the one asked for when it is there, else the other, else none. Automatic -/// asks for spacenavd, which is what has always been read first. +/// asks for the device itself (`/dev/hidraw`) and falls back to spacenavd when no device opens (none +/// there, no permission, still backing off after a failed open): spacenavd has been a known source of +/// trouble, and a device read directly keeps its own settings (key `hid VVVV:PPPP`) across a replug, +/// where the daemon's key is shared by whatever it reads. Direct and Spacenavd mean what they say, with +/// the other as the fallback. #[cfg(target_os = "linux")] fn choose(wanted: SpaceMouseDriver, spacenavd: bool, direct: bool) -> Option { let first = match wanted { - SpaceMouseDriver::Automatic | SpaceMouseDriver::Spacenavd => Pick::Spacenavd, - SpaceMouseDriver::Direct => Pick::Direct, + SpaceMouseDriver::Automatic | SpaceMouseDriver::Direct => Pick::Direct, + SpaceMouseDriver::Spacenavd => Pick::Spacenavd, }; let there = |pick| match pick { Pick::Spacenavd => spacenavd, @@ -487,27 +697,85 @@ fn choose(wanted: SpaceMouseDriver, spacenavd: bool, direct: bool) -> Option>, drivers: Arc) { - use std::os::unix::fs::OpenOptionsExt; - let look = || DriversPresent { spacenavd: spacenavd::exists(), direct: hidraw::find().is_some() }; - let mut denied = None; +trait System { + fn find(&self) -> Option; + fn daemon_exists(&self) -> bool; + fn daemon_connect(&self) -> Option; + fn open(&self, path: &std::path::Path) -> std::io::Result>; + fn times(&self) -> Times; +} + +/// The waits of the reader. +#[cfg(target_os = "linux")] +#[derive(Clone, Copy)] +struct Times { + /// How often a read looks again at what is there. + look_every: Duration, + /// How long before the next try when nothing could be read. + retry_after: Duration, + /// How long a device that failed to open stays out of the choice. + unreadable_retry: Duration, + /// How long a read of the daemon waits for a record before it checks for a change. + wake: Duration, +} + +/// The machine itself. +#[cfg(target_os = "linux")] +struct Machine; + +#[cfg(target_os = "linux")] +impl System for Machine { + fn find(&self) -> Option { + hidraw::find() + } + + fn daemon_exists(&self) -> bool { + spacenavd::exists() + } + + fn daemon_connect(&self) -> Option { + spacenavd::connect() + } + + fn open(&self, path: &std::path::Path) -> std::io::Result> { + Ok(Box::new(hidraw::open(path)?)) + } + + fn times(&self) -> Times { + Times { look_every: LOOK_EVERY, retry_after: RETRY_AFTER, unreadable_retry: UNREADABLE_RETRY, wake: SPACENAVD_WAKE } + } +} + +/// A change of driver, or the device being back, is looked at when a read goes idle (250 ms of quiet) +/// and, for the daemon's stream, at least every 250 ms while records keep arriving, so the wait is +/// bounded whatever the cap is doing. +/// Reads the 3D mouse through spacenavd or `/dev/hidrawN`, as `Drivers::choice` says (Automatic +/// is the device itself when it opens, else spacenavd, whose `spnavcfg` settings, swapped and +/// inverted axes, sensitivity, dead zones, apply then as in other programs). Both reads wake up when idle to see whether the choice changed, which then +/// moves to the other reader without a restart. hidraw is opened non-blocking and ends with an +/// error when the device is unplugged. Opening needs permission: most distributions grant it to +/// the logged-in user for devices that carry the `uaccess` udev tag, which a rule for the 3D mouse +/// has to add. Ends when `Drivers::quit` is set. +#[cfg(target_os = "linux")] +fn reader(ctx: Context, shared: Arc>, drivers: Arc, system: S) { + let times = system.times(); + let mut unreadable = Unreadable::default(); let mut told = None; - loop { - let device = hidraw::find(); - let present = DriversPresent { spacenavd: spacenavd::exists(), direct: device.is_some() }; + while !drivers.quit.load(Ordering::Relaxed) { + let device = system.find(); + let present = + DriversPresent { spacenavd: system.daemon_exists(), direct: device.is_some(), denied: unreadable.at.is_some() && device.is_some() }; drivers.publish(present); let wanted = drivers.choice(); - let mut pick = choose(wanted, present.spacenavd, present.direct); - // A socket file with no daemon behind it is no spacenavd. - let daemon = if pick == Some(Pick::Spacenavd) { spacenavd::connect() } else { None }; + // The wait after a failed open is for a daemon that works to be read meanwhile; with none there + // is nothing else to read, so the device is tried again at the usual pace. + let since_failed = unreadable.at.filter(|_| present.spacenavd).map(|at| at.elapsed()); + let direct = direct_usable(present.direct, since_failed, times.unreadable_retry); + let mut pick = choose(wanted, present.spacenavd, direct); + // A socket file with no daemon behind it is no spacenavd, and then the device is tried again too. + let daemon = if pick == Some(Pick::Spacenavd) { system.daemon_connect() } else { None }; if pick == Some(Pick::Spacenavd) && daemon.is_none() { pick = choose(wanted, false, present.direct); } @@ -525,44 +793,78 @@ fn reader(ctx: Context, shared: Arc>, drivers: Arc) { } // Asked at every idle moment of a read: has the choice changed, and what is there now. let mut looked = Instant::now(); + let mut failed_at = unreadable.at; let mut stop = || { - if looked.elapsed() >= LOOK_EVERY { + if drivers.quit.load(Ordering::Relaxed) { + return true; + } + if looked.elapsed() >= times.look_every { looked = Instant::now(); - drivers.publish(look()); + let now_device = system.find(); + let now_present = DriversPresent { + spacenavd: system.daemon_exists(), + direct: now_device.is_some(), + // Not while the device itself is what is being read: it opened. + denied: pick != Some(Pick::Direct) && failed_at.is_some() && now_device.is_some(), + }; + drivers.publish(now_present); + // A device that failed to open is tried again once its wait is over, by opening it, so a + // daemon that works is only left for a device that now works, and a device that still + // cannot be read is not tried again for another wait. + if let (Some(at), Some(device), Some(Pick::Spacenavd)) = (failed_at, &now_device, pick) + && at.elapsed() >= times.unreadable_retry + && system.open(&device.path).is_err() + { + failed_at = Some(Instant::now()); + } + // The device came back (replugged), or became readable, while the daemon was read. + if switch_back(wanted, pick, now_present, failed_at.map(|at| at.elapsed()), times.unreadable_retry) { + return true; + } } drivers.choice() != wanted }; match (pick, daemon, device) { (Some(Pick::Spacenavd), Some(daemon), _) => { log::info!("3D mouse: reading spacenavd, which applies the spnavcfg settings"); - if let Err(error) = daemon.set_read_timeout(Some(SPACENAVD_WAKE)) { + if let Err(error) = daemon.set_read_timeout(Some(times.wake)) { log::warn!("3D mouse: spacenavd reads cannot time out ({error}); a change of driver waits for the next event"); } connected(&ctx, &shared, Source::Spacenavd); - spacenavd::read(daemon, |axes| publish_held(&ctx, &shared, axes), &mut stop); + spacenavd::read( + daemon, + |axes| publish_held(&ctx, &shared, axes), + |button, down| publish_button(&ctx, &shared, button, down), + &mut stop, + times.wake, + ); reset(&ctx, &shared); + // What the read found out about the device (it was tried again, and still would not + // open) is kept for the next choice. + unreadable.at = failed_at; } - (Some(Pick::Direct), _, Some(device)) => match std::fs::OpenOptions::new().read(true).custom_flags(hidraw::O_NONBLOCK).open(&device.path) - { + (Some(Pick::Direct), _, Some(device)) => match system.open(&device.path) { Ok(file) => { - denied = None; + unreadable.opened(); log::info!("3D mouse: reading {}", device.path.display()); connected(&ctx, &shared, Source::Hid(device.ids)); hidraw::read_reports(file, |report| publish(&ctx, &shared, device.layout, report), &mut stop); reset(&ctx, &shared); } - // Say so once per device node, not every retry. - Err(error) if denied.as_ref() != Some(&device.path) => { - log::warn!("3D mouse: cannot open {}: {error}. A udev rule granting the logged-in user access is needed.", device.path.display()); - denied = Some(device.path); + Err(error) => { + if unreadable.failed(&device.path) { + log::warn!( + "3D mouse: cannot open {}: {error}. A udev rule granting the logged-in user access is needed.", + device.path.display() + ); + } } - Err(_) => {} }, _ => {} } // A new choice is taken up at once; anything else waits to try again. if drivers.choice() == wanted { - std::thread::sleep(RETRY_AFTER); + std::thread::sleep(times.retry_after); } } } @@ -589,10 +891,16 @@ fn start_driver_client(ctx: &Context, shared: &Arc>) { let now = Instant::now(); reading.axes = Axes { translation, rotation }; reading.at = [now; 6]; - ctx.request_repaint(); + reading.arrived = true; + ctx.request_repaint_after(REPAINT_EVERY); + } + } + Event::Buttons(buttons) => { + if let Ok(mut reading) = shared.lock() { + reading.set_buttons(buttons); + ctx.request_repaint_after(REPAINT_EVERY); } } - Event::Buttons(_) => {} }, |connected| match connected { Ok(()) => DRIVER_REGISTERED.store(true, Ordering::Relaxed), @@ -631,7 +939,7 @@ impl SpaceMouse { let worker_drivers = Arc::clone(&drivers); let spawned = std::thread::Builder::new().name("solidcraft-3d-mouse".into()).spawn(move || { #[cfg(target_os = "linux")] - reader(worker_ctx, worker_state, worker_drivers); + reader(worker_ctx, worker_state, worker_drivers, Machine); #[cfg(not(target_os = "linux"))] reader(worker_ctx, worker_state); }); @@ -669,12 +977,27 @@ impl SpaceMouse { .map(|reading| { // Only the calibration of the device being read: another's would misjudge it. let calibration = self.calibration.filter(|(source, _)| Some(*source) == reading.device).map(|(_, calibration)| calibration); - calibrated(reading.current(now, at_rest(calibration)), calibration).moving() + calibrated(reading.current_with(now, at_rest(calibration), settle_band(calibration)), calibration).moving() }) .unwrap_or(false); self.wheel.filter(moving, now.duration_since(self.started).as_secs_f64(), &mut raw.events); } + /// The device being read, the buttons down on it (bit N for button N + 1) and those that went + /// down since the last call, all from one look at the reading, so a button never gets a + /// device it did not come from. + pub fn buttons(&self) -> solidcraft_ui_egui::space_mouse::Buttons { + let Ok(mut reading) = self.shared.lock() else { return Default::default() }; + let taps = std::mem::take(&mut reading.taps); + solidcraft_ui_egui::space_mouse::Buttons { + device: reading.device.map(Source::key), + down: reading.buttons, + taps, + pending: !reading.buttons_known, + generation: reading.generation, + } + } + pub fn tick(&mut self, app: &mut SolveApp, ctx: &Context) { self.tick_at(app, ctx, Instant::now()); } @@ -696,8 +1019,30 @@ impl SpaceMouse { self.drivers.choice.store(app.preferences.space_mouse_driver.to_u8(), Ordering::Relaxed); app.space_mouse.drivers = Some(self.drivers.present()); } - let Ok(reading) = self.shared.lock().map(|reading| *reading) else { return }; + // What the reader has published, and whether anything arrived since the last frame (taken, so + // the next frame sees only what comes after). + let reading = { + let Ok(mut shared) = self.shared.lock() else { return }; + let seen = *shared; + shared.arrived = false; + seen + }; app.space_mouse.device = reading.device.map(Source::key); + // One line per connect or reconnect: which device, which read path, and what is saved for it. + if app.space_mouse.logged_device != app.space_mouse.device { + if let (Some(key), Some(source)) = (&app.space_mouse.device, reading.device) { + log::info!( + "3D mouse: {key} read through {} (driver setting {:?}); saved calibration: {}; calibration offered before: {}", + source.path(), + app.preferences.space_mouse_driver, + app.preferences.space_mouse_calibrations.contains_key(key), + app.preferences.space_mouse_offered.contains(key), + ); + } else { + log::info!("3D mouse: no device"); + } + app.space_mouse.logged_device = app.space_mouse.device.clone(); + } let calibration = calibration(app); self.calibration = reading.device.zip(calibration); // While this device is being calibrated, an axis with nothing fresh reads 0, as a HID @@ -705,7 +1050,8 @@ impl SpaceMouse { // calibration of another device doesn't count; it ends in `navigate_frame`. let calibrating = app.space_mouse.calibrating.as_ref().is_some_and(|(device, _)| app.space_mouse.device.as_ref() == Some(device)); let rest = if calibrating { Axes::default() } else { at_rest(calibration) }; - navigate_frame(app, ctx, self.driver_mapping, reading.current(now, rest), seconds); + app.space_mouse.retained = !reading.arrived; + navigate_frame(app, ctx, self.driver_mapping, reading.current_with(now, rest, settle_band(calibration)), seconds); } } @@ -777,7 +1123,7 @@ mod tests { use SpaceMouseDriver::{Automatic, Direct as AskDirect, Spacenavd as AskSpacenavd}; // (asked, spacenavd there, device there) and what is read. let cases = [ - (Automatic, true, true, Some(Spacenavd)), + (Automatic, true, true, Some(Direct)), (Automatic, true, false, Some(Spacenavd)), (Automatic, false, true, Some(Direct)), (Automatic, false, false, None), @@ -801,8 +1147,8 @@ mod tests { let drivers = Drivers::default(); assert_eq!(drivers.present(), DriversPresent::default()); for (spacenavd, direct) in [(true, false), (false, true), (true, true), (false, false)] { - drivers.publish(DriversPresent { spacenavd, direct }); - assert_eq!(drivers.present(), DriversPresent { spacenavd, direct }); + drivers.publish(DriversPresent { spacenavd, direct, denied: false }); + assert_eq!(drivers.present(), DriversPresent { spacenavd, direct, denied: false }); } assert_eq!(drivers.choice(), SpaceMouseDriver::Automatic); for choice in SpaceMouseDriver::ALL { @@ -914,6 +1260,29 @@ mod tests { assert!(asked(), "a changed reading asks for a frame"); } + /// A button press asks for a frame even though no axis moved, and a repeated report still counts + /// as arrived (the calibration window needs that) without asking for one. + #[test] + fn a_button_report_asks_for_a_frame_and_a_repeat_only_marks_arrival() { + let mut layout = Layout::default(); + layout.buttons[0] = + Some(solidcraft_space_mouse::Field { report_id: 3, bit_offset: 0, bits: 1, logical_min: 0, logical_max: 1, relative: false }); + let ctx = Context::default(); + let shared = Mutex::new(Reading::new(Instant::now())); + let asked = || { + let mut out = ctx.run_ui(egui::RawInput::default(), |_| {}); + out.textures_delta.clear(); + ctx.repaint_causes().iter().any(|c| c.file.ends_with("space_mouse.rs")) + }; + asked(); + publish(&ctx, &shared, layout, &[3, 1]); + assert!(asked(), "a button going down asks for a frame"); + shared.lock().unwrap().arrived = false; + publish(&ctx, &shared, layout, &[3, 1]); + assert!(!asked(), "the same buttons again are no news"); + assert!(shared.lock().unwrap().arrived, "but the report did arrive"); + } + #[test] fn a_held_reading_stays_until_the_next_one_and_a_hid_reading_goes_stale() { let now = Instant::now(); @@ -949,6 +1318,174 @@ mod tests { } } + /// Unplug and replug under a driver choice of Direct: the daemon is read while the device is + /// away, and the read ends when the device is back, so the device's own key, and with it the saved + /// calibration, applies again (it stayed on the daemon, whose key has no calibration, before). + #[cfg(target_os = "linux")] + #[test] + fn a_replugged_device_takes_the_reading_back_from_the_daemon_it_fell_back_to() { + use SpaceMouseDriver::{Direct, Spacenavd}; + let both = DriversPresent { spacenavd: true, direct: true, denied: false }; + let only_daemon = DriversPresent { spacenavd: true, direct: false, denied: false }; + // Direct asked for, unplugged: the daemon is read. It comes back: switch. + let pick = choose(Direct, true, false); + assert_eq!(pick, Some(Pick::Spacenavd)); + assert!(switch_back(Direct, pick, both, None, UNREADABLE_RETRY)); + assert!(!switch_back(Direct, pick, only_daemon, None, UNREADABLE_RETRY), "still away"); + // A device that was there at the start (but could not be opened) does not make the read flap. + assert!(!switch_back(Direct, pick, both, Some(Duration::ZERO), UNREADABLE_RETRY)); + // Within the backoff after a failed open, not yet. + assert!(!switch_back(Direct, pick, both, Some(Duration::from_secs(1)), UNREADABLE_RETRY)); + // Spacenavd, asked for, keeps the daemon whatever else appears. + assert!(!switch_back(Spacenavd, Some(Pick::Spacenavd), both, None, UNREADABLE_RETRY)); + // The key a replugged device gets is its ids, whatever hidraw node it comes up on. + assert_eq!(Source::Hid(Some((0x046d, 0xc62b))).key(), "hid 046d:c62b"); + } + + /// Automatic reads the device whenever it opens, calibrated or not, and the daemon only when no device + /// does; Direct and Spacenavd keep meaning what they say. + #[cfg(target_os = "linux")] + #[test] + fn automatic_reads_the_device_when_it_opens_and_otherwise_the_daemon() { + use SpaceMouseDriver::{Automatic, Direct, Spacenavd}; + // Both live: the device. + assert_eq!(choose(Automatic, true, true), Some(Pick::Direct)); + // The device does not open (no permission, backing off): the daemon, under its own key. + let unopenable = direct_usable(true, Some(Duration::from_secs(1)), UNREADABLE_RETRY); + assert_eq!(choose(Automatic, true, unopenable), Some(Pick::Spacenavd)); + // Only one source is there: that one. Neither: nothing. + assert_eq!(choose(Automatic, false, true), Some(Pick::Direct)); + assert_eq!(choose(Automatic, true, false), Some(Pick::Spacenavd)); + assert_eq!(choose(Automatic, false, false), None); + // Asked for explicitly, they mean what they say. + assert_eq!(choose(Spacenavd, true, true), Some(Pick::Spacenavd)); + assert_eq!(choose(Direct, true, true), Some(Pick::Direct)); + // The replug path: unplugged -> daemon, replugged -> the read ends and the device is taken. + let (both, only_daemon) = + (DriversPresent { spacenavd: true, direct: true, denied: false }, DriversPresent { spacenavd: true, direct: false, denied: false }); + let pick = choose(Automatic, true, false); + assert_eq!(pick, Some(Pick::Spacenavd)); + assert!(!switch_back(Automatic, pick, only_daemon, None, UNREADABLE_RETRY), "still away"); + assert!(switch_back(Automatic, pick, both, None, UNREADABLE_RETRY), "back"); + assert!(!switch_back(Automatic, pick, both, Some(Duration::from_secs(1)), UNREADABLE_RETRY), "backing off after a failed open"); + // Already reading the device: nothing to switch. + assert!(!switch_back(Automatic, Some(Pick::Direct), both, None, UNREADABLE_RETRY)); + // Spacenavd, asked for, is never left for the device. + assert!(!switch_back(Spacenavd, Some(Pick::Spacenavd), both, None, UNREADABLE_RETRY)); + } + + /// A device that failed to open and is readable now ends the read of the daemon once the wait is over, + /// and not before; a device that works is never given up for a daemon. + #[cfg(target_os = "linux")] + #[test] + fn a_device_that_failed_to_open_ends_the_daemon_read_when_its_wait_is_over() { + use SpaceMouseDriver::{Automatic, Direct, Spacenavd}; + let both = DriversPresent { spacenavd: true, direct: true, denied: false }; + let wait = UNREADABLE_RETRY; + for wanted in [Direct, Automatic] { + assert!(!switch_back(wanted, Some(Pick::Spacenavd), both, Some(Duration::from_secs(1)), wait), "{wanted:?}: inside the wait"); + assert!(switch_back(wanted, Some(Pick::Spacenavd), both, Some(wait), wait), "{wanted:?}: the wait is over"); + assert!(!switch_back(wanted, Some(Pick::Direct), both, Some(wait), wait), "{wanted:?}: already reading the device"); + } + // Spacenavd, asked for, stays on the daemon whatever the wait. + assert!(!switch_back(Spacenavd, Some(Pick::Spacenavd), both, Some(wait), wait)); + // A device read directly because there was no daemon is not given up when one appears. + assert!(!switch_back(Automatic, Some(Pick::Direct), both, None, wait)); + } + + #[cfg(target_os = "linux")] + #[test] + fn a_direct_device_that_cannot_be_opened_gives_way_to_a_working_daemon_for_a_while() { + use SpaceMouseDriver::{Automatic, Direct, Spacenavd}; + for wanted in [Automatic, Spacenavd, Direct] { + // Found, but the open just failed: the daemon is used, whichever was asked for. + let ok = direct_usable(true, Some(Duration::from_secs(1)), UNREADABLE_RETRY); + assert!(!ok); + assert_eq!(choose(wanted, true, ok), Some(Pick::Spacenavd), "{wanted:?}"); + } + // Found and never failed: Direct wins when asked for. After the wait it is tried again. + assert_eq!(choose(Direct, true, direct_usable(true, None, UNREADABLE_RETRY)), Some(Pick::Direct)); + assert_eq!(choose(Direct, true, direct_usable(true, Some(UNREADABLE_RETRY), UNREADABLE_RETRY)), Some(Pick::Direct)); + // Not found is not usable however long ago. + assert!(!direct_usable(false, None, UNREADABLE_RETRY)); + // With no daemon either, nothing is read rather than a loop on the device that fails. + assert_eq!(choose(Direct, false, false), None); + } + + #[test] + fn the_held_settle_band_follows_the_calibrated_travel() { + let axis = |source, full: [u16; 2]| solidcraft_space_mouse::AxisCalibration { source, reversed: false, rest: 0, dead_zone: 0, full }; + let calibration = + |full| Calibration { dead_zone_share: 0.0, kind: Default::default(), axes: std::array::from_fn(|source| axis(source, [full; 2])) }; + assert_eq!(settle_band(None), Some(HELD_SETTLE_BAND)); + assert_eq!(settle_band(Some(calibration(350))), Some(HELD_SETTLE_BAND)); + assert_eq!(settle_band(Some(calibration(3500))), Some(HELD_SETTLE_BAND)); + assert_eq!(settle_band(Some(calibration(50))), Some(21)); + // A sustained full push on a cap whose full travel is 50 stays a full push. + let now = Instant::now(); + let push = Axes { translation: [50, 0, 0], rotation: [0; 3] }; + let held = Reading { axes: push, held: true, ..Reading::new(now) }; + let later = now + HELD_SETTLE_AFTER * 4; + assert_eq!(held.current_with(later, Axes::default(), settle_band(Some(calibration(50)))), push); + // While the same reading on an ordinary cap (full 350) is a leftover and settles. + assert_eq!(held.current_with(later, Axes::default(), settle_band(Some(calibration(350)))), Axes::default()); + } + + #[cfg(target_os = "linux")] + #[test] + fn a_reachable_daemon_is_not_a_device_until_it_sends_a_record() { + let ctx = Context::default(); + let shared = Mutex::new(Reading::new(Instant::now())); + connected(&ctx, &shared, Source::Spacenavd); + assert_eq!(shared.lock().unwrap().device, None, "a socket that answers is no evidence"); + publish_held(&ctx, &shared, Axes::default()); + assert_eq!(shared.lock().unwrap().device, Some(Source::Spacenavd), "the first record is"); + // A button record is evidence too. + connected(&ctx, &shared, Source::Spacenavd); + assert_eq!(shared.lock().unwrap().device, None); + publish_button(&ctx, &shared, 2, true); + assert_eq!(shared.lock().unwrap().device, Some(Source::Spacenavd)); + // An opened device is evidence at once. + connected(&ctx, &shared, Source::Hid(Some((0x046d, 0xc62b)))); + assert_eq!(shared.lock().unwrap().device, Some(Source::Hid(Some((0x046d, 0xc62b))))); + // Gone, and nothing again. + reset(&ctx, &shared); + assert_eq!(shared.lock().unwrap().device, None); + } + + #[test] + fn a_tap_between_two_frames_is_still_a_press_and_ends_with_nothing_held() { + let mut reading = Reading::new(Instant::now()); + // Down and up before the UI looks: the mask is back to zero, the press is not lost. + reading.set_buttons(0b100); + reading.set_buttons(0); + assert_eq!((reading.buttons, reading.taps), (0, 0b100)); + // Holding, then more taps, accumulate until taken; held buttons are not new taps. + reading.set_buttons(0b001); + reading.set_buttons(0b001); + assert_eq!(reading.taps, 0b101); + } + + #[test] + fn a_trackballs_held_reading_never_settles_but_a_caps_and_an_uncalibrated_devices_do() { + let now = Instant::now(); + let axes = Axes { translation: [40, 0, 0], rotation: [0; 3] }; + let held = Reading { axes, held: true, ..Reading::new(now) }; + let later = now + HELD_SETTLE_AFTER * 4; + let calibration = |kind| { + let axis = |source| solidcraft_space_mouse::AxisCalibration { source, reversed: false, rest: 0, dead_zone: 0, full: [350; 2] }; + Calibration { dead_zone_share: 0.0, kind, axes: std::array::from_fn(axis) } + }; + for (calibration, settles_to_rest) in [ + (None, true), + (Some(calibration(solidcraft_space_mouse::Kind::ReturnToCenter)), true), + (Some(calibration(solidcraft_space_mouse::Kind::Trackball)), false), + ] { + let read = held.current_with(later, Axes::default(), settle_band(calibration)); + assert_eq!(read == Axes::default(), settles_to_rest, "{calibration:?}"); + } + } + #[test] fn a_settled_held_reading_reads_as_the_devices_own_rest() { let now = Instant::now(); @@ -1051,7 +1588,8 @@ mod tests { }; // A window that has run a frame with focus, as the app's always has by now. let ctx = Context::default(); - let _ = ctx.run_ui(egui::RawInput::default(), |_| {}); + // Clear the frame's texture changes, or dropping them panics in debug builds (as in #60). + ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear(); mouse.tick_at(&mut app, &ctx, now); let DeviceMotion { motion, seconds } = app.viewport.device_motion.expect("the frame hands the page its motion"); assert_eq!(motion, Motion { translation: [0.0; 3], rotation: [0.0, 0.0, -1.0] }); @@ -1087,6 +1625,7 @@ mod tests { fn resting_at_100() -> Calibration { Calibration { dead_zone_share: 0.0, + kind: Default::default(), axes: std::array::from_fn(|source| solidcraft_space_mouse::AxisCalibration { source, reversed: false, @@ -1194,4 +1733,627 @@ mod tests { cap.raw_input_hook_at(true, &mut raw, later); assert_eq!(raw.events.len(), 1, "kept once the cap has been still"); } + + /// A stand-in for the machine: a device that can be plugged, unplugged and made unreadable, and a + /// daemon that can be there or not. Every read is a socket pair, so what the reader reads is + /// whatever the test writes, and a device is unplugged by closing its end. + #[cfg(target_os = "linux")] + mod machine { + use super::super::*; + use std::io::Write; + use std::os::unix::net::UnixStream; + use std::path::{Path, PathBuf}; + + pub const IDS: (u16, u16) = (0x046d, 0xc62b); + + #[derive(Default)] + pub struct World { + pub device: bool, + pub daemon: bool, + pub openable: bool, + pub device_end: Option, + pub daemon_end: Option, + pub opens: u32, + pub connects: u32, + } + + #[derive(Clone, Default)] + pub struct Fake(pub Arc>); + + impl Fake { + pub fn world(&self) -> std::sync::MutexGuard<'_, World> { + self.0.lock().unwrap() + } + + pub fn unplug(&self) { + let mut world = self.world(); + world.device = false; + world.device_end = None; + } + + /// The daemon sends a record of its own (kind, then seven numbers). + pub fn daemon_sends(&self, data: [i32; 8]) { + let mut bytes = [0_u8; 32]; + for (chunk, value) in bytes.as_chunks_mut::<4>().0.iter_mut().zip(data) { + *chunk = value.to_ne_bytes(); + } + if let Some(end) = self.world().daemon_end.as_mut() { + end.write_all(&bytes).ok(); + } + } + + /// The daemon sends a motion record (all zeros: the daemon's rest), which is what shows + /// a device behind it. + pub fn daemon_sends_a_record(&self) { + if let Some(end) = self.world().daemon_end.as_mut() { + end.write_all(&[0_u8; 32]).ok(); + } + } + } + + impl System for Fake { + fn find(&self) -> Option { + self.world().device.then(|| hidraw::Device { path: PathBuf::from("/dev/hidraw-fake"), layout: Layout::default(), ids: Some(IDS) }) + } + + fn daemon_exists(&self) -> bool { + self.world().daemon + } + + fn daemon_connect(&self) -> Option { + let mut world = self.world(); + if !world.daemon { + return None; + } + let (ours, theirs) = UnixStream::pair().unwrap(); + world.daemon_end = Some(theirs); + world.connects += 1; + Some(ours) + } + + fn open(&self, _: &Path) -> std::io::Result> { + let mut world = self.world(); + world.opens += 1; + if !world.openable { + return Err(std::io::ErrorKind::PermissionDenied.into()); + } + let (ours, theirs) = UnixStream::pair().unwrap(); + ours.set_nonblocking(true).unwrap(); + world.device_end = Some(theirs); + Ok(Box::new(ours)) + } + + fn times(&self) -> Times { + Times { + look_every: Duration::from_millis(10), + retry_after: Duration::from_millis(10), + unreadable_retry: Duration::from_millis(300), + wake: Duration::from_millis(5), + } + } + } + + pub struct Running { + pub fake: Fake, + pub shared: Arc>, + pub drivers: Arc, + thread: Option>, + } + + impl Running { + pub fn start(driver: SpaceMouseDriver, fake: Fake) -> Self { + let shared = Arc::new(Mutex::new(Reading::new(Instant::now()))); + let drivers = Arc::new(Drivers::default()); + drivers.choice.store(driver.to_u8(), Ordering::Relaxed); + let (ctx, state, quit, system) = (Context::default(), Arc::clone(&shared), Arc::clone(&drivers), fake.clone()); + let thread = Some(std::thread::spawn(move || reader(ctx, state, quit, system))); + Self { fake, shared, drivers, thread } + } + + /// The key of the device being read, if any. + pub fn key(&self) -> Option { + self.shared.lock().unwrap().device.map(Source::key) + } + + /// Waits (up to three seconds) until the device being read has this key. + pub fn reads(&self, key: &str) { + let until = Instant::now() + Duration::from_secs(3); + while self.key().as_deref() != Some(key) { + assert!(Instant::now() < until, "still reading {:?}, not {key}", self.key()); + // Only while the daemon is what is wanted: a daemon that streams without a pause is not + // left for the device until it pauses (a read looks for a change when it goes idle), so + // sending to it while the device is awaited would keep the test from ever seeing the switch. + if key == "spacenavd" { + self.fake.daemon_sends_a_record(); + } + std::thread::sleep(Duration::from_millis(5)); + } + } + } + + impl Drop for Running { + fn drop(&mut self) { + self.drivers.quit.store(true, Ordering::Relaxed); + if let Some(thread) = self.thread.take() { + thread.join().ok(); + } + } + } + } + + /// The reader itself, on a stand-in machine: Automatic reads the device; unplugged, the daemon; + /// replugged, the device again (so its own key, and the calibration saved under it, applies), and + /// the saved driver setting is never touched. + #[cfg(target_os = "linux")] + #[test] + fn the_reader_follows_a_device_through_an_unplug_and_a_replug() { + use machine::{Fake, IDS, Running}; + let device_key = Source::Hid(Some(IDS)).key(); + let fake = Fake::default(); + *fake.world() = machine::World { device: true, daemon: true, openable: true, ..Default::default() }; + let reader = Running::start(SpaceMouseDriver::Automatic, fake.clone()); + reader.reads(&device_key); + fake.unplug(); + reader.reads("spacenavd"); + fake.world().device = true; + reader.reads(&device_key); + assert_eq!(reader.drivers.choice(), SpaceMouseDriver::Automatic, "the saved choice is not changed by any of it"); + } + + /// Spacenavd, asked for, is read even with the device there and openable, and the device is not opened. + #[cfg(target_os = "linux")] + #[test] + fn spacenavd_asked_for_is_read_with_the_device_there() { + use machine::{Fake, Running}; + let fake = Fake::default(); + *fake.world() = machine::World { device: true, daemon: true, openable: true, ..Default::default() }; + let reader = Running::start(SpaceMouseDriver::Spacenavd, fake.clone()); + reader.reads("spacenavd"); + std::thread::sleep(Duration::from_millis(200)); + assert_eq!(reader.key().as_deref(), Some("spacenavd")); + assert_eq!(fake.world().opens, 0, "the device was never opened"); + } + + /// The failure of a device to open is said once per node, and forgotten when one opens. + #[cfg(target_os = "linux")] + #[test] + fn a_failed_open_is_said_once_per_node_and_forgotten_after_a_success() { + let (zero, one) = (std::path::Path::new("/dev/hidraw0"), std::path::Path::new("/dev/hidraw1")); + let mut unreadable = Unreadable::default(); + assert!(unreadable.failed(zero), "the first failure is said"); + assert!(!unreadable.failed(zero), "not again for the same node"); + assert!(unreadable.failed(one), "another node is news"); + assert!(unreadable.at.is_some()); + // The permission was fixed: it opens. A later failure of any node is said again. + unreadable.opened(); + assert!(unreadable.at.is_none()); + assert!(unreadable.failed(one), "after a success the next failure is said, though this node was before"); + assert!(unreadable.at.is_some(), "and the wait starts again from it"); + assert!(unreadable.failed(zero)); + } + + /// The daemon read keeps what it learned of the device: a restart of the daemon, a while after the + /// device was last found unopenable, goes back to the daemon without trying the device again first. + #[cfg(target_os = "linux")] + #[test] + fn a_daemon_restart_does_not_retry_a_device_the_read_just_found_unopenable() { + use machine::{Fake, Running}; + let fake = Fake::default(); + *fake.world() = machine::World { device: true, daemon: true, openable: false, ..Default::default() }; + let reader = Running::start(SpaceMouseDriver::Automatic, fake.clone()); + reader.reads("spacenavd"); + // Wait for a probe by the read (the second open), which refreshes the failure. + let until = Instant::now() + Duration::from_secs(3); + while fake.world().opens < 2 { + assert!(Instant::now() < until, "the read never tried the device again"); + std::thread::sleep(Duration::from_millis(2)); + } + let (opens, connects) = { + let world = fake.world(); + (world.opens, world.connects) + }; + fake.world().daemon_end = None; + while fake.world().connects == connects { + assert!(Instant::now() < until, "the daemon was never read again"); + std::thread::sleep(Duration::from_millis(2)); + } + assert_eq!(fake.world().opens, opens, "the daemon was read again without opening the device first"); + } + + /// The same restart while the daemon is streaming without a pause: the read is ended by the timer's + /// look, not by silence, and it still goes back to the daemon without trying the device first. + #[cfg(target_os = "linux")] + #[test] + fn a_daemon_restart_during_a_continuous_stream_does_not_retry_the_unopenable_device_either() { + use machine::{Fake, Running}; + let fake = Fake::default(); + *fake.world() = machine::World { device: true, daemon: true, openable: false, ..Default::default() }; + let reader = Running::start(SpaceMouseDriver::Automatic, fake.clone()); + reader.reads("spacenavd"); + let until = Instant::now() + Duration::from_secs(3); + // Records keep coming every 2 ms until the read has probed the device a second time. + while fake.world().opens < 2 { + assert!(Instant::now() < until, "the busy read never tried the device again"); + fake.daemon_sends_a_record(); + std::thread::sleep(Duration::from_millis(2)); + } + let (opens, connects) = { + let world = fake.world(); + (world.opens, world.connects) + }; + fake.world().daemon_end = None; + while fake.world().connects == connects { + assert!(Instant::now() < until, "the daemon was never read again"); + std::thread::sleep(Duration::from_millis(2)); + } + assert_eq!(fake.world().opens, opens, "read again without opening the device first"); + } + + /// With no daemon at all, a device that failed to open is tried again at the usual pace, not after the + /// 30 s wait that is for a daemon to be read meanwhile. + #[cfg(target_os = "linux")] + #[test] + fn without_a_daemon_a_device_that_becomes_readable_is_taken_at_the_usual_pace() { + use machine::{Fake, IDS, Running}; + let fake = Fake::default(); + *fake.world() = machine::World { device: true, daemon: false, openable: false, ..Default::default() }; + let reader = Running::start(SpaceMouseDriver::Automatic, fake.clone()); + let until = Instant::now() + Duration::from_secs(3); + while fake.world().opens < 1 { + assert!(Instant::now() < until); + std::thread::sleep(Duration::from_millis(2)); + } + // Found but not allowed, and nothing else to read: the reader says so. + let denied = Instant::now() + Duration::from_secs(1); + while !reader.drivers.present().denied { + assert!(Instant::now() < denied, "denied is never published"); + std::thread::sleep(Duration::from_millis(2)); + } + assert!(reader.drivers.present().direct && !reader.drivers.present().spacenavd); + fake.world().openable = true; + let key = Source::Hid(Some(IDS)).key(); + // The wait would be 300 ms here; the usual pace is 10 ms. + let soon = Instant::now() + Duration::from_millis(200); + while reader.key().as_deref() != Some(key.as_str()) { + assert!(Instant::now() < soon, "still not reading the device 200 ms after it became readable"); + std::thread::sleep(Duration::from_millis(2)); + } + let cleared = Instant::now() + Duration::from_secs(1); + while reader.drivers.present().denied { + assert!(Instant::now() < cleared, "denied stays published after the device opened"); + std::thread::sleep(Duration::from_millis(2)); + } + } + + /// A button held down on one reader is not a press, nor held, on the other when the reader swaps. + #[cfg(target_os = "linux")] + #[test] + fn a_button_held_while_the_reader_swaps_from_the_daemon_to_the_device_is_not_a_press() { + use machine::{Fake, IDS, Running}; + let fake = Fake::default(); + *fake.world() = machine::World { device: false, daemon: true, openable: true, ..Default::default() }; + let reader = Running::start(SpaceMouseDriver::Automatic, fake.clone()); + reader.reads("spacenavd"); + let now = Instant::now(); + let mouse = SpaceMouse { + shared: Arc::clone(&reader.shared), + drivers: Arc::clone(&reader.drivers), + last_frame: now, + driver_mapping: DriverMapping::object_mode(), + mapping_checked_at: now, + wheel: WheelGuard::default(), + started: now, + calibration: None, + }; + // Button 1 goes down on the daemon and stays down. + fake.daemon_sends([1, 0, 0, 0, 0, 0, 0, 0]); + let until = Instant::now() + Duration::from_secs(3); + let on_daemon = loop { + let seen = mouse.buttons(); + if seen.down != 0 { + break seen; + } + assert!(Instant::now() < until, "the daemon's button never showed"); + std::thread::sleep(Duration::from_millis(2)); + }; + assert_eq!((on_daemon.device.as_deref(), on_daemon.down), (Some("spacenavd"), 1)); + // The device comes up and the reader moves to it. + fake.world().device = true; + let key = Source::Hid(Some(IDS)).key(); + while reader.key().as_deref() != Some(key.as_str()) { + assert!(Instant::now() < until, "never moved to the device"); + std::thread::sleep(Duration::from_millis(2)); + } + // A record that arrived on the daemon does not become fresh again on the device. + assert!(!reader.shared.lock().unwrap().arrived, "nothing has arrived on the device yet"); + let on_device = mouse.buttons(); + assert_eq!((on_device.device.as_deref(), on_device.down, on_device.taps), (Some(key.as_str()), 0, 0), "nothing held, nothing pressed"); + } + + /// The same ball on the daemon's path, through the reader and the tick: newly arriving records are + /// fresh even when they decode to the same value (spacenavd truncates 16 and 17 alike), and a last value + /// kept with nothing arriving is not. + #[cfg(target_os = "linux")] + #[test] + fn a_decelerating_ball_on_the_daemon_is_rolling_while_records_arrive_and_stopped_when_they_do_not() { + use machine::{Fake, Running}; + use solidcraft_space_mouse::{Kind, Stage, Wizard}; + // (what arrives after the ball slows, whether the step ends): different values that decode alike, + // the same value over and over, or one record and then silence. + for (mode, step_done) in [("alternating", false), ("identical", false), ("silence", true)] { + let fake = Fake::default(); + *fake.world() = machine::World { device: false, daemon: true, openable: true, ..Default::default() }; + let reader = Running::start(SpaceMouseDriver::Automatic, fake.clone()); + reader.reads("spacenavd"); + let mut app = SolveApp::new(solidcraft_engine::Session::default(), solidcraft_ui_egui::Services::default()); + app.home.open = false; + let start = Instant::now(); + let mut mouse = SpaceMouse { + shared: Arc::clone(&reader.shared), + drivers: Arc::clone(&reader.drivers), + last_frame: start, + driver_mapping: DriverMapping::default(), + mapping_checked_at: start, + wheel: WheelGuard::default(), + started: start, + calibration: None, + }; + let ctx = Context::default(); + ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear(); + // One frame of 20 ms of real time (readings are stamped with the real clock): the daemon sends + // `record` (or nothing), the reader publishes it, the tick feeds the wizard. + let mut next = start; + let mut tick = |app: &mut SolveApp, record: Option| { + if let Some(x) = record { + reader.shared.lock().unwrap().arrived = false; + fake.daemon_sends([0, x, 0, 0, 0, 0, 0, 8]); + let until = Instant::now() + Duration::from_secs(3); + while !reader.shared.lock().unwrap().arrived { + assert!(Instant::now() < until, "the record never arrived"); + std::thread::sleep(Duration::from_millis(1)); + } + } + next += Duration::from_millis(20); + std::thread::sleep(next.saturating_duration_since(Instant::now())); + mouse.tick_at(app, &ctx, Instant::now()); + }; + // The first record shows the daemon's device; calibrate it as a trackball. + tick(&mut app, Some(0)); + app.space_mouse.calibrating = Some((app.space_mouse.device.clone().expect("the daemon is a device now"), Wizard::new(Kind::Trackball))); + let stage = |app: &SolveApp| app.space_mouse.calibrating.as_ref().map(|(_, wizard)| wizard.stage()); + for _ in 0..400 { + if stage(&app) == Some(Stage::Step(0)) { + break; + } + tick(&mut app, Some(0)); + } + assert_eq!(stage(&app), Some(Stage::Step(0)), "the rest stage is over"); + // The ball at X = 58 on the wire (40 after the daemon's scaling) for 0.6 s. + for _ in 0..30 { + tick(&mut app, Some(58)); + } + // Then 16 and 17, which both decode to 11, for 0.7 s: arriving, or one last record and silence. + for i in 0..35 { + let record = match mode { + "alternating" => Some(if i % 2 == 0 { 16 } else { 17 }), + "identical" => Some(16), + _ => (i == 0).then_some(16), + }; + tick(&mut app, record); + } + assert_eq!(stage(&app) == Some(Stage::Step(1)), step_done, "{mode}: {:?}", stage(&app)); + } + } + + /// A button held while the reader moves from the daemon to the device: the device's first report still + /// shows it down, and that is a baseline, not a press. Mapped to Esc, nothing is sent at the switch; + /// let go and pressed again, exactly one Esc; and a quick tap between two looks is still a press. + /// In each order the UI can meet it in: a frame between connecting and the first report, the first + /// report before any frame, and a whole disconnect and reconnect that no frame saw. + #[test] + fn a_button_held_across_a_switch_to_the_device_does_not_fire_until_pressed_again() { + for order in ["a frame first", "the report first", "a reconnect no frame saw"] { + held_across_a_switch(order); + } + } + + fn held_across_a_switch(order: &str) { + use solidcraft_space_mouse::{Field, Layout}; + let ids = Some((0x046d, 0xc62b)); + let device = Source::Hid(ids).key(); + let mut layout = Layout::default(); + layout.buttons[0] = Some(Field { report_id: 3, bit_offset: 0, bits: 1, logical_min: 0, logical_max: 1, relative: false }); + let mut app = SolveApp::new(solidcraft_engine::Session::default(), solidcraft_ui_egui::Services::default()); + app.home.open = false; + app.space_mouse.device = Some(device.clone()); + app.preferences + .space_mouse_buttons + .insert(device.clone(), [(1, solidcraft_ui_egui::space_mouse::ButtonJob::Key(solidcraft_ui_egui::space_mouse::ButtonKey::Esc))].into()); + let ctx = Context::default(); + ctx.run_ui(egui::RawInput { focused: true, ..Default::default() }, |_| {}).textures_delta.clear(); + let shared = Arc::new(Mutex::new(Reading::new(Instant::now()))); + let mouse = SpaceMouse { + shared: Arc::clone(&shared), + #[cfg(target_os = "linux")] + drivers: Arc::default(), + last_frame: Instant::now(), + driver_mapping: DriverMapping::object_mode(), + mapping_checked_at: Instant::now(), + wheel: WheelGuard::default(), + started: Instant::now(), + calibration: None, + }; + let escapes = |app: &mut SolveApp, mouse: &SpaceMouse| { + let mut raw = egui::RawInput { focused: true, ..Default::default() }; + solidcraft_ui_egui::space_mouse::buttons_hook(app, &ctx, mouse.buttons(), &mut raw); + ctx.run_ui(raw.clone(), |_| {}).textures_delta.clear(); + raw.events.iter().filter(|e| matches!(e, egui::Event::Key { key: egui::Key::Escape, pressed: true, .. })).count() + }; + let report = |down: bool| publish(&ctx, &shared, layout, &[3, u8::from(down)]); + // The reader has just connected to the device, with the button still held. + connected(&ctx, &shared, Source::Hid(ids)); + match order { + // A frame is drawn before the device has reported anything (the repaint connecting asks for)... + "a frame first" => assert_eq!(escapes(&mut app, &mouse), 0), + // ...or the first report comes first... + "the report first" => {} + // ...or the UI has seen this device with nothing down, then the reader disconnects and connects + // again and the first report shows the button held, all between two frames. + _ => { + report(false); + assert_eq!(escapes(&mut app, &mouse), 0, "a look with nothing down"); + reset(&ctx, &shared); + connected(&ctx, &shared, Source::Hid(ids)); + } + } + // ...and then the first report shows the button still down. + report(true); + assert_eq!(escapes(&mut app, &mouse), 0, "{order}: the switch sends no Esc"); + report(true); + assert_eq!(escapes(&mut app, &mouse), 0, "{order}: and holding is not pressing"); + report(false); + assert_eq!(escapes(&mut app, &mouse), 0, "{order}: letting go is not a press"); + report(true); + assert_eq!(escapes(&mut app, &mouse), 1, "{order}: a fresh press sends one"); + report(true); + assert_eq!(escapes(&mut app, &mouse), 0); + // A tap that is down and up again before the UI looks is still a press. + report(false); + report(true); + report(false); + assert_eq!(escapes(&mut app, &mouse), 1, "{order}: a quick tap between two looks"); + } + + /// A daemon that sends a record every couple of milliseconds, never going quiet, is still left for + /// the device when it returns (Automatic prefers the device). + #[cfg(target_os = "linux")] + #[test] + fn a_busy_daemon_is_left_for_the_device_that_returns() { + use machine::{Fake, IDS, Running}; + let fake = Fake::default(); + *fake.world() = machine::World { device: false, daemon: true, openable: true, ..Default::default() }; + let reader = Running::start(SpaceMouseDriver::Automatic, fake.clone()); + let until = Instant::now() + Duration::from_secs(3); + // The daemon streams without a pause from the start. + while reader.key().as_deref() != Some("spacenavd") { + assert!(Instant::now() < until, "the daemon is never read"); + fake.daemon_sends_a_record(); + std::thread::sleep(Duration::from_millis(2)); + } + fake.world().device = true; + let key = Source::Hid(Some(IDS)).key(); + while reader.key().as_deref() != Some(key.as_str()) { + assert!(Instant::now() < until, "still reading {:?} with the device back", reader.key()); + fake.daemon_sends_a_record(); + std::thread::sleep(Duration::from_millis(2)); + } + } + + /// With the device and the daemon both there and Automatic, the device is read from the start, with no + /// calibration saved for it. + #[cfg(target_os = "linux")] + #[test] + fn automatic_reads_a_device_with_nothing_saved_for_it() { + use machine::{Fake, IDS, Running}; + let fake = Fake::default(); + *fake.world() = machine::World { device: true, daemon: true, openable: true, ..Default::default() }; + let reader = Running::start(SpaceMouseDriver::Automatic, fake.clone()); + reader.reads(&Source::Hid(Some(IDS)).key()); + assert_eq!(fake.world().connects, 0, "the daemon was never connected"); + } + + /// A device that cannot be opened (no permission) gives way to the daemon; once the permission is + /// fixed it is taken within the wait, without the read of the daemon flapping before that, and the + /// saved driver choice is untouched. + #[cfg(target_os = "linux")] + #[test] + fn a_device_that_becomes_readable_is_taken_from_the_daemon_within_the_retry_wait() { + use machine::{Fake, IDS, Running}; + for driver in [SpaceMouseDriver::Direct, SpaceMouseDriver::Automatic] { + let fake = Fake::default(); + *fake.world() = machine::World { device: true, daemon: true, openable: false, ..Default::default() }; + let reader = Running::start(driver, fake.clone()); + reader.reads("spacenavd"); + // Still not readable, over two waits: the daemon read goes on, one connection, a few probes. + std::thread::sleep(Duration::from_millis(700)); + assert_eq!(reader.key().as_deref(), Some("spacenavd"), "{driver:?}"); + assert_eq!(fake.world().connects, 1, "{driver:?}: the daemon read was not restarted for a device that still cannot be read"); + let opens = fake.world().opens; + assert!(opens <= 4, "{driver:?}: {opens} tries in 700 ms with a 300 ms wait"); + // The permission is fixed. + fake.world().openable = true; + reader.reads(&Source::Hid(Some(IDS)).key()); + assert_eq!(reader.drivers.choice(), driver, "the saved choice is not changed"); + } + } + + /// After the section's Restore (which puts the driver back to Automatic) the calibration that applies is + /// the one for the reader that is read now: the device's own when Automatic reads the device (it does + /// whenever it opens, so from Direct or from Spacenavd the device's calibration applies), and none when + /// the device cannot be opened and the daemon is read under its own key. Real reader, stand-in machine, + /// the app's own tick. + #[cfg(target_os = "linux")] + #[test] + fn after_a_restore_the_calibration_that_applies_is_the_one_of_the_reader_being_read() { + use machine::{Fake, IDS, Running}; + let key = Source::Hid(Some(IDS)).key(); + // (driver before, device opens, calibrated, key read after the restore, calibration applies after) + let cases = [ + (SpaceMouseDriver::Direct, true, true, key.as_str(), true), + (SpaceMouseDriver::Direct, true, false, key.as_str(), false), + (SpaceMouseDriver::Spacenavd, true, true, key.as_str(), true), + (SpaceMouseDriver::Spacenavd, true, false, key.as_str(), false), + (SpaceMouseDriver::Direct, false, true, "spacenavd", false), + ]; + for (before, openable, calibrated, expected, applies) in cases { + let fake = Fake::default(); + *fake.world() = machine::World { device: true, daemon: true, openable, ..Default::default() }; + let reader = Running::start(before, fake.clone()); + let mut app = SolveApp::new(solidcraft_engine::Session::default(), solidcraft_ui_egui::Services::default()); + app.home.open = false; + app.preferences.space_mouse_driver = before; + if calibrated { + app.preferences.space_mouse_calibrations.insert(key.clone(), resting_at_100()); + } + let now = Instant::now(); + let mut mouse = SpaceMouse { + shared: Arc::clone(&reader.shared), + drivers: Arc::clone(&reader.drivers), + last_frame: now, + driver_mapping: DriverMapping::object_mode(), + mapping_checked_at: now, + wheel: WheelGuard::default(), + started: now, + calibration: None, + }; + let ctx = Context::default(); + ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear(); + let settle = |mouse: &mut SpaceMouse, app: &mut SolveApp, want: &str| { + let until = Instant::now() + Duration::from_secs(3); + loop { + // A daemon that streams without a pause is not left until it pauses, so it is only fed + // while it is the reader that is awaited. + if want == "spacenavd" { + fake.daemon_sends_a_record(); + } + mouse.tick_at(app, &ctx, Instant::now()); + if app.space_mouse.device.as_deref() == Some(want) { + return; + } + assert!(Instant::now() < until, "{before:?}/{openable}: reading {:?}, not {want}", app.space_mouse.device); + std::thread::sleep(Duration::from_millis(5)); + } + }; + // The reader as the setting has it before the restore. + let first = if before == SpaceMouseDriver::Direct && openable { key.as_str() } else { "spacenavd" }; + settle(&mut mouse, &mut app, first); + solidcraft_ui_egui::prefs::restore_space_mouse_defaults(&mut app); + assert_eq!(app.preferences.space_mouse_driver, SpaceMouseDriver::Automatic); + settle(&mut mouse, &mut app, expected); + assert_eq!(calibration(&app).is_some(), applies, "{before:?}/{openable}/{calibrated}: the calibration that applies"); + if applies { + assert_eq!(app.preferences.space_mouse_calibrations.len(), 1, "nothing was copied between keys"); + } + } + } } diff --git a/apps/solidcraft/src/space_mouse/hidraw.rs b/apps/solidcraft/src/space_mouse/hidraw.rs index 29b5dd8271..24e538efab 100644 --- a/apps/solidcraft/src/space_mouse/hidraw.rs +++ b/apps/solidcraft/src/space_mouse/hidraw.rs @@ -15,6 +15,12 @@ const DEV: &str = "/dev"; /// a read with nothing to read returns at once and the reader can see whether it should stop. pub const O_NONBLOCK: i32 = 0o4000; +/// Opens a hidraw node the way the Direct reader reads it: read-only and non-blocking. +pub fn open(path: &Path) -> std::io::Result { + use std::os::unix::fs::OpenOptionsExt; + std::fs::OpenOptions::new().read(true).custom_flags(O_NONBLOCK).open(path) +} + /// How long the reader waits when the device has nothing to say, which is also the most a report /// can be delayed. const IDLE_POLL: Duration = Duration::from_millis(5); @@ -76,6 +82,44 @@ fn ids(uevent: &str) -> Option<(u16, u16)> { mod tests { use super::*; + /// A real EACCES, as a user without the udev rule gets: a file of the user's own with no permission + /// bits. Skipped for root, which can open anything. + #[test] + fn a_device_without_permission_is_denied_and_the_daemon_takes_over_within_the_backoff() { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + if std::fs::metadata("/proc/self").is_ok_and(|meta| meta.uid() == 0) { + return; + } + let dir = std::env::temp_dir().join(format!("sc-hidraw-denied-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let node = dir.join("hidraw0"); + std::fs::write(&node, [0_u8; 4]).unwrap(); + std::fs::set_permissions(&node, std::fs::Permissions::from_mode(0o000)).unwrap(); + let denied = open(&node); + std::fs::set_permissions(&node, std::fs::Permissions::from_mode(0o600)).unwrap(); + assert!(open(&node).is_ok(), "the same node opens once it is readable"); + std::fs::remove_dir_all(&dir).ok(); + assert_eq!(denied.err().map(|error| error.kind()), Some(std::io::ErrorKind::PermissionDenied)); + // The failure is the choice's `since_failed`, with an injected clock: inside the backoff the + // daemon is read, whichever driver was asked for; after it the device is tried again. + use super::super::{Pick, SpaceMouseDriver, UNREADABLE_RETRY, choose, direct_usable}; + for wanted in [SpaceMouseDriver::Automatic, SpaceMouseDriver::Spacenavd, SpaceMouseDriver::Direct] { + for ago in [Duration::ZERO, Duration::from_secs(29)] { + assert_eq!(choose(wanted, true, direct_usable(true, Some(ago), UNREADABLE_RETRY)), Some(Pick::Spacenavd), "{wanted:?} {ago:?}"); + } + } + assert_eq!(choose(SpaceMouseDriver::Direct, true, direct_usable(true, Some(UNREADABLE_RETRY), UNREADABLE_RETRY)), Some(Pick::Direct)); + // And the daemon's own reader delivers over a fake connection. + let (mut daemon, client) = std::os::unix::net::UnixStream::pair().unwrap(); + let mut record = [0_u8; 32]; + record[4..8].copy_from_slice(&100_i32.to_ne_bytes()); + std::io::Write::write_all(&mut daemon, &record).unwrap(); + drop(daemon); + let mut seen = 0; + super::super::spacenavd::read(client, |_| seen += 1, |_, _| {}, || false, Duration::from_secs(3600)); + assert_eq!(seen, 1); + } + #[test] fn reports_come_through_idle_gaps_until_the_device_ends_or_a_stop() { use crate::space_mouse::fake::Script; diff --git a/apps/solidcraft/src/space_mouse/spacenavd.rs b/apps/solidcraft/src/space_mouse/spacenavd.rs index 7a27eb2402..d549904e23 100644 --- a/apps/solidcraft/src/space_mouse/spacenavd.rs +++ b/apps/solidcraft/src/space_mouse/spacenavd.rs @@ -3,7 +3,8 @@ //! to its clients, so a program that reads the HID device itself bypasses that setup. Over its //! socket the daemon sends fixed 32-byte records: eight native-endian `i32`, the first the kind of //! record (0 for motion, then x y z rx ry rz and the milliseconds since the last record). -//! Other records (button presses, from newer daemons also configuration changes) are ignored. +//! Button records (1 pressed, 2 released, then the button's number from 0) are read too; other +//! records (from newer daemons also configuration changes) are ignored. //! //! The socket is plain file I/O, so no library is needed, and it needs no udev rule: the daemon //! is what has access to the device. @@ -11,6 +12,7 @@ use std::io::Read; use std::os::unix::net::UnixStream; use std::path::Path; +use std::time::{Duration, Instant}; use solidcraft_ui_egui::space_mouse::{Axes, FULL_SCALE}; @@ -54,6 +56,22 @@ fn decode(record: &[u8; RECORD_BYTES]) -> Option { }) } +/// The button a button record is about, from 0, and whether it went down; `None` for any other +/// record, or a button past the 32 a bitmask holds. +fn button(record: &[u8; RECORD_BYTES]) -> Option<(u32, bool)> { + let mut data = [0_i32; 2]; + for (slot, bytes) in data.iter_mut().zip(record.as_chunks::<4>().0) { + *slot = i32::from_ne_bytes(*bytes); + } + let [kind, number] = data; + let number = u32::try_from(number).ok().filter(|&n| n < 32)?; + match kind { + 1 => Some((number, true)), + 2 => Some((number, false)), + _ => None, + } +} + /// A daemon value in our raw units. Out-of-range values stay in `i16`: they are input from another /// process. fn unit(value: impl Into) -> i16 { @@ -61,14 +79,30 @@ fn unit(value: impl Into) -> i16 { i16::try_from(scaled.clamp(i64::from(i16::MIN), i64::from(i16::MAX))).unwrap_or_default() } -/// Hands every motion record to `publish` until the daemon closes the connection or fails, or -/// `stop` says so. A stream with a read timeout (`set_read_timeout`) times out when the daemon is -/// quiet, which is no record and no disconnect: `stop` is asked, and the read goes on, with what -/// of a record had arrived kept. A partial record at the end is dropped. -pub fn read(mut stream: impl Read, mut publish: impl FnMut(Axes), mut stop: impl FnMut() -> bool) { +/// Hands every motion record to `publish` and every button record to `pressed` until the daemon +/// closes the connection or fails, or `stop` says so. A stream with a read timeout +/// (`set_read_timeout`) times out when the daemon is quiet, which is no record and no disconnect: +/// `stop` is asked, and the read goes on, with what of a record had arrived kept. `stop` is also asked +/// at least every `check_every` while records keep arriving, so a daemon that never pauses for that +/// long (a moving cap sends every few milliseconds) does not keep the reader from noticing that the +/// device it prefers is back. A partial record at the end is dropped. +pub fn read( + mut stream: impl Read, + mut publish: impl FnMut(Axes), + mut pressed: impl FnMut(u32, bool), + mut stop: impl FnMut() -> bool, + check_every: Duration, +) { let mut record = [0_u8; RECORD_BYTES]; let mut filled = 0; + let mut asked = Instant::now(); loop { + if asked.elapsed() >= check_every { + asked = Instant::now(); + if stop() { + return; + } + } match stream.read(record.get_mut(filled..).unwrap_or_default()) { Ok(0) => { log::info!("3D mouse: spacenavd connection ended"); @@ -80,11 +114,14 @@ pub fn read(mut stream: impl Read, mut publish: impl FnMut(Axes), mut stop: impl filled = 0; if let Some(axes) = decode(&record) { publish(axes); + } else if let Some((number, down)) = button(&record) { + pressed(number, down); } } } Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {} Err(error) if matches!(error.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => { + asked = Instant::now(); if stop() { return; } @@ -134,6 +171,10 @@ mod tests { fn other_records_and_hostile_values_are_harmless() { // A button record (kind 1, press) and one of an unknown kind are not motion. assert_eq!(decode(&record([1, 0, 1, 0, 0, 0, 0, 0])), None); + // Nor are hostile button numbers a button. + assert_eq!(button(&record([1, -1, 0, 0, 0, 0, 0, 0])), None); + assert_eq!(button(&record([1, i32::MAX, 0, 0, 0, 0, 0, 0])), None); + assert_eq!(button(&record([7, 3, 0, 0, 0, 0, 0, 0])), None); assert_eq!(decode(&record([7, 500, 500, 500, 500, 500, 500, 0])), None); // Nothing overflows on the extremes of `i32`. let extreme = decode(&record([0, i32::MIN, i32::MIN, i32::MAX, i32::MAX, i32::MIN, i32::MIN, -1])); @@ -147,14 +188,17 @@ mod tests { let (mut daemon, client) = UnixStream::pair().expect("a socket pair"); let mut bytes = Vec::new(); bytes.extend(record([0, 100, 0, 0, 0, 0, 0, 8])); - bytes.extend(record([2, 1, 0, 0, 0, 0, 0, 0])); // a button release: skipped + bytes.extend(record([1, 4, 0, 0, 0, 0, 0, 0])); // button 4 down + bytes.extend(record([2, 4, 0, 0, 0, 0, 0, 0])); // and up + bytes.extend(record([1, 40, 0, 0, 0, 0, 0, 0])); // past the 32 a bitmask holds: skipped bytes.extend(record([0, 0, 0, 0, 0, 0, 0, 900])); bytes.extend([1, 2, 3]); // the daemon went away mid-record daemon.write_all(&bytes).expect("written"); drop(daemon); - let mut seen = Vec::new(); - read(client, |axes| seen.push(axes.translation), || false); + let (mut seen, mut buttons) = (Vec::new(), Vec::new()); + read(client, |axes| seen.push(axes.translation), |number, down| buttons.push((number, down)), || false, Duration::from_secs(3600)); assert_eq!(seen, [[70, 0, 0], [0, 0, 0]]); + assert_eq!(buttons, [(4, true), (4, false)]); } #[test] @@ -168,10 +212,12 @@ mod tests { read( script, |axes| seen.push(axes.translation), + |_, _| {}, || { asked += 1; false }, + Duration::from_secs(3600), ); assert_eq!(seen, [[70, 0, 0]]); assert_eq!(asked, 4, "each timeout asks whether to stop"); @@ -184,10 +230,44 @@ mod tests { let mut script = Script::new(); script.data(&bytes).timeout(std::io::ErrorKind::WouldBlock).data(&bytes); let mut seen = 0; - read(script, |_| seen += 1, || true); + read(script, |_| seen += 1, |_, _| {}, || true, Duration::from_secs(3600)); assert_eq!(seen, 1, "the record before the timeout arrived; the one after never was read"); } + /// A daemon that never goes quiet (a moving cap sends every few milliseconds) is still asked whether to + /// stop, at the interval given, so the reader can go back to a device that returned. + #[test] + fn a_busy_daemon_is_asked_whether_to_stop_on_a_timer() { + // Ends by itself after half a second, so a read that never asks fails the assertion below + // instead of hanging the test. + struct Busy(Instant); + impl Read for Busy { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + if self.0.elapsed() > Duration::from_millis(500) { + return Ok(0); + } + std::thread::sleep(Duration::from_millis(2)); + let bytes = record([0, 100, 0, 0, 0, 0, 0, 8]); + let size = bytes.len().min(buf.len()); + buf[..size].copy_from_slice(&bytes[..size]); + Ok(size) + } + } + let (mut seen, mut asked) = (0, 0); + read( + Busy(Instant::now()), + |_| seen += 1, + |_, _| {}, + || { + asked += 1; + asked == 3 + }, + Duration::from_millis(10), + ); + assert_eq!(asked, 3, "asked on the timer although no read ever timed out"); + assert!(seen > 3, "records kept coming meanwhile: {seen}"); + } + #[test] fn no_daemon_means_no_connection() { let missing = std::env::temp_dir().join(format!("sc-no-spnav-{}.sock", std::process::id())); diff --git a/assets/icons/ui/STYLE_GUIDE.md b/assets/icons/ui/STYLE_GUIDE.md index 33c91585af..d40c53c855 100644 --- a/assets/icons/ui/STYLE_GUIDE.md +++ b/assets/icons/ui/STYLE_GUIDE.md @@ -78,7 +78,7 @@ Its projected circle uses the same axes; do not arbitrarily flatten the ellipse. Only use the stable `source` identifiers below in model SVGs. The renderer maps roles to light/dark theme values. `currentColor` is the background line-art role, not a way -to add arbitrary colours. All values and accents come from `design/palette.toml`. +to add arbitrary colours. Default display values and semantic accents come from `design/palette.toml`. Theme files may override those display roles and ramps through `icon_palette`; source identifiers and accent assignments stay unchanged. | Role | Author helper | Light value | Use | | --- | --- | --- | --- | @@ -96,9 +96,9 @@ The source hexes identify paint roles, not the displayed colour. **Never recolou SVG to apply a workspace accent.** Keep these identifiers and every finished shape unchanged; the runtime and review page resolve them through `design/palette.toml`. -That file is the single colour authority: edit `light`/`dark` in the relevant +That file is the default colour authority: edit `light`/`dark` in the relevant `accents` ramp; `active-3` is its primary stroke and `active-1/2` are shaded faces. -Do not edit a role's `source` identifier. `variants` holds colour-vision adjustments. +Do not edit a role's `source` identifier. Theme `icon_palette` overrides affect display colours only; colour-vision variants still take precedence. `variants` holds colour-vision adjustments. A role with `use = "active-3"` follows that stroke automatically. | Meaning | Accent | Assignment | diff --git a/assets/icons/ui/design/aliases.toml b/assets/icons/ui/design/aliases.toml index adb998138c..ed1ec592ea 100644 --- a/assets/icons/ui/design/aliases.toml +++ b/assets/icons/ui/design/aliases.toml @@ -29,6 +29,7 @@ ground = "c_fix" # Same finished drawing, different semantic operation. Colours live only in palette.toml. sketch_break = { master = "break", accent = "sketch" } +sketch_cancel = { master = "close", accent = "sketch" } sketch_dxf = { master = "dxf", accent = "sketch" } sketch_move = { master = "move", accent = "sketch" } sketch_scale = { master = "scale", accent = "sketch" } diff --git a/assets/icons/ui/design/requested_icons.txt b/assets/icons/ui/design/requested_icons.txt index 0270b99123..6efdd64547 100644 --- a/assets/icons/ui/design/requested_icons.txt +++ b/assets/icons/ui/design/requested_icons.txt @@ -104,6 +104,7 @@ flange false flat false fold false folder false +frame_member false gear false graph false ground false @@ -211,6 +212,7 @@ shield false silhouette false sketch false sketch_break false +sketch_cancel false sketch_chamfer false sketch_chamfer_angle false sketch_chamfer_equal false diff --git a/assets/icons/ui/design/svg/model/frame_member.svg b/assets/icons/ui/design/svg/model/frame_member.svg new file mode 100644 index 0000000000..79bc8100fc --- /dev/null +++ b/assets/icons/ui/design/svg/model/frame_member.svg @@ -0,0 +1,22 @@ + + Frame Member + + + + + + + + + + + + + + + + + + + + diff --git a/assets/icons/ui/source/markers.rs b/assets/icons/ui/source/markers.rs index 3560a31351..42c97ae856 100644 --- a/assets/icons/ui/source/markers.rs +++ b/assets/icons/ui/source/markers.rs @@ -1,4 +1,4 @@ -//! Constraint, assembly snap and dimension marker artwork. +//! Constraint, assembly snap, hole placement and dimension marker artwork. use crate::dialogs_assembly::SnapKind; use crate::theme::Tokens; use egui::{Color32, Pos2, Stroke, vec2}; @@ -57,6 +57,39 @@ pub(crate) fn snap(painter: &egui::Painter, c: Pos2, tip: Option, kind: Sn painter.galley(at, g, t.text); } } +/// A place a hole can snap to, before the cursor reaches it: a small hollow mark of its kind +/// (a square for a face's centre, a ring for a round edge's centre). +pub(crate) fn snap_hint(painter: &egui::Painter, c: Pos2, kind: SnapKind, col: Color32) { + let ic = Tokens::get().icons; + for stroke in [Stroke::new(3.5, ic.marker_halo), Stroke::new(1.4, col)] { + match kind { + SnapKind::FaceCenter => { + painter.rect_stroke(egui::Rect::from_center_size(c, vec2(8.0, 8.0)), 1.0, stroke, egui::StrokeKind::Middle); + } + SnapKind::CircleCenter => { + painter.circle_stroke(c, 4.5, stroke); + } + SnapKind::EdgeMid | SnapKind::Vertex => { + let r = 5.0; + let diamond = vec![c + vec2(0.0, -r), c + vec2(r, 0.0), c + vec2(0.0, r), c + vec2(-r, 0.0)]; + painter.add(egui::Shape::convex_polygon(diamond, Color32::TRANSPARENT, stroke)); + } + } + } + painter.circle_filled(c, 1.3, col); +} + +/// A placed hole's drag handle: a ring round the hole's centre with a cross through it, filled +/// while it is hovered or dragged. +pub(crate) fn hole_handle(painter: &egui::Painter, c: Pos2, col: Color32, hot: bool) { + let ic = Tokens::get().icons; + let r = 6.0; + painter.circle_filled(c, r + 3.5, ic.marker_halo); + painter.circle(c, r, if hot { col } else { col.gamma_multiply(0.35) }, Stroke::new(1.6, col)); + let ink = Stroke::new(1.3, if hot { ic.highlight } else { col }); + painter.line_segment([c - vec2(r + 3.0, 0.0), c + vec2(r + 3.0, 0.0)], ink); + painter.line_segment([c - vec2(0.0, r + 3.0), c + vec2(0.0, r + 3.0)], ink); +} pub(crate) fn glyph_icon(name: &str) -> Option<&'static str> { Some(match name { "Coincident" => "c_coincident", diff --git a/assets/locales/cs.json b/assets/locales/cs.json index bae905088b..0c281b5d85 100644 --- a/assets/locales/cs.json +++ b/assets/locales/cs.json @@ -530,6 +530,10 @@ "cmd.file_insert_step": "Vložit STEP", "cmd.file_save_mesh": "Uložit jako síť", "cmd.file_slicers": "Nainstalované slicery", + "cmd.frame_member": "Prvek rámu", + "cmd.frame_profile_insert": "Vložit profil", + "cmd.frame_profile_list": "Knihovna profilů", + "cmd.frame_profile_properties": "Vlastnosti profilu", "cmd.inspect_accessibility": "Analýza přístupnosti", "cmd.inspect_center_of_mass": "Těžiště", "cmd.inspect_clearance": "Vůle", @@ -1124,7 +1128,106 @@ "trace.stopped_by_contact": "Končí ve vzorku {sample}: {a} by prošlo skrz {b}", "trace.stopped_by_loop": "Končí ve vzorku {sample}: smyčku přes {joint} už nelze dále uzavřít", "ui.1_degree_of_freedom": "1 stupeň volnosti", + "ui.3d_mouse_axes_reassigned": "Osy jsou v Předvolbách přeřazené a to platí i po této kalibraci.", + "ui.3d_mouse_button": "Tlačítko {number}", + "ui.3d_mouse_button_nothing": "Nic", + "ui.3d_mouse_button_settings": "Nastavení 3D myši", + "ui.3d_mouse_buttons": "Tlačítka", + "ui.3d_mouse_buttons_hint": "Stiskněte tlačítko na 3D myši, ať ho tu najdete, a vyberte, co má dělat.", + "ui.3d_mouse_buttons_no_device": "Připojte 3D myš a nastavte, co dělají její tlačítka.", + "ui.3d_mouse_buttons_reset": "Obnovit výchozí tlačítka", + "ui.3d_mouse_calibrate": "Kalibrovat…", + "ui.3d_mouse_calibrate_tip": "Pohybujte hlavicí podle pokynů v okně, aby se SolidCraft naučil, kterým směrem a jak daleko se pohybuje každá osa této 3D myši. Uloží se pro tuto 3D myš.", + "ui.3d_mouse_calibrate_title": "Kalibrace 3D myši", + "ui.3d_mouse_calibrate_wait": "Jednou pohněte 3D myší, aby ji SolidCraft uviděl.", + "ui.3d_mouse_calibrated": "Zkalibrováno", + "ui.3d_mouse_calibration": "Kalibrace 3D myši", + "ui.3d_mouse_calibration_done": "Hotovo. Uložte ji a použije se vždy, když bude tato 3D myš připojená.", + "ui.3d_mouse_calibration_no_response": "Jedna osa se skoro nepohnula, takže ji nelze zkalibrovat. Začněte znovu a pokaždé hlavicí pohněte dál.", + "ui.3d_mouse_circles_help": "Kruhy ukazují, co zařízení posílá po kalibraci. Zkušební model ukazuje, co s tím udělá pohled po obrácení, rychlosti a přiřazení os.", + "ui.3d_mouse_dead_zone": "Mrtvá zóna", + "ui.3d_mouse_dead_zone_tip": "Jak daleko se musí hlavice pohnout, než se pohne pohled, jako podíl jejího plného rozsahu. Zvyšte ji, pokud pohled ujíždí, i když je hlavice v klidu.", + "ui.3d_mouse_denied": "SolidCraft vidí 3D myš, ale nesmí ji číst. Pravidlo udev najdete v poznámkách k nastavení 3D myši.", + "ui.3d_mouse_denied_short": "Nalezeno, ale nelze číst", + "ui.3d_mouse_driven_by": "Který kalibrovaný pohyb zařízení ovládá tuto osu. Když vyberete pohyb, který už používá jiná osa, obě se prohodí.", + "ui.3d_mouse_driven_by_label": "ovládá", + "ui.3d_mouse_driver": "Ovladač 3D myši", + "ui.3d_mouse_driver_auto": "Automaticky", + "ui.3d_mouse_driver_direct": "Přímo (hidraw)", + "ui.3d_mouse_driver_fallback": "Zatím se čte přes spacenavd: zařízení samotné se nepodařilo otevřít.", + "ui.3d_mouse_driver_only_direct": "Zařízení se čte přímo; spacenavd neběží", + "ui.3d_mouse_driver_only_spacenavd": "Čte se přes spacenavd, jiná možnost není", + "ui.3d_mouse_forget": "Zapomenout kalibraci", + "ui.3d_mouse_ghost_left_viewport": "Vlevo ve výřezu", + "ui.3d_mouse_ghost_left_window": "Vlevo v okně", + "ui.3d_mouse_ghost_position": "Poloha", + "ui.3d_mouse_ghost_right_viewport": "Vpravo ve výřezu", + "ui.3d_mouse_hint": "Posun a otáčení pohledu současně; náčrty používají jen posun a přiblížení.", + "ui.3d_mouse_kind_cap": "Návrat do středu", + "ui.3d_mouse_kind_question": "Jaký typ 3D myši to je?", + "ui.3d_mouse_kind_trackball": "Trackball", + "ui.3d_mouse_looks_like_cap": "Hodnota zůstává, dokud ji držíte, jako u zařízení typu „Návrat do středu“.", + "ui.3d_mouse_looks_like_trackball": "Hodnota se vrací zpět, jakmile přestanete hýbat, jako u trackballu.", + "ui.3d_mouse_motion_rotation_x": "Rotace X", + "ui.3d_mouse_motion_rotation_y": "Rotace Y", + "ui.3d_mouse_motion_rotation_z": "Rotace Z", + "ui.3d_mouse_motion_translation_x": "Posun X", + "ui.3d_mouse_motion_translation_y": "Posun Y", + "ui.3d_mouse_motion_translation_z": "Posun Z", + "ui.3d_mouse_navigate": "Navigace pomocí 3D myši", + "ui.3d_mouse_none": "3D myš nenalezena", + "ui.3d_mouse_not_calibrated": "Nezkalibrováno", + "ui.3d_mouse_offer": "Kalibrace 3D myši opraví prohozené nebo obrácené osy a nastaví její rychlost a pocit z ovládání.", + "ui.3d_mouse_offer_never": "Už nezobrazovat", + "ui.3d_mouse_offer_swap": "Přepnout na Hidraw (přímý ovladač)", + "ui.3d_mouse_reading": "Hodnoty", + "ui.3d_mouse_reset_axes": "Obnovit přiřazení os", + "ui.3d_mouse_reset_view": "Obnovit pohled", + "ui.3d_mouse_restore": "Obnovit výchozí nastavení 3D myši", + "ui.3d_mouse_restore_ask": "Vrátit nastavení 3D myši na výchozí hodnoty? Kalibrace a přiřazení tlačítek zůstanou.", + "ui.3d_mouse_section": "3D myš", + "ui.3d_mouse_show_in_view": "Zobrazit 3D myš ve 3D pohledu", + "ui.3d_mouse_show_in_view_tip": "Obrázek vaší 3D myši v levém dolním rohu, který se hýbe s ní.", + "ui.3d_mouse_skip": "Moje zařízení to neumí", + "ui.3d_mouse_skip_tip": "Přeskočí tento pohyb v obou směrech a kalibrace ho vynechá.", "ui.3d_mouse_speed": "Rychlost 3D myši", + "ui.3d_mouse_step_lift": "Zvedněte hlavici nahoru, podržte a pusťte.", + "ui.3d_mouse_step_press": "Zatlačte hlavici dolů, podržte a pusťte.", + "ui.3d_mouse_step_pull": "Přitáhněte hlavici k sobě, podržte a pusťte.", + "ui.3d_mouse_step_push": "Odtlačte hlavici od sebe, podržte a pusťte.", + "ui.3d_mouse_step_rest": "Dejte ruku z hlavice a nechte ji v klidu.", + "ui.3d_mouse_step_rest_trackball": "Dejte ruku z kuličky a nechte ji v klidu.", + "ui.3d_mouse_step_ring_clockwise": "Otočte rolovacím kroužkem o pár cvaknutí po směru hodinových ručiček.", + "ui.3d_mouse_step_ring_counterclockwise": "Otočte rolovacím kroužkem o pár cvaknutí proti směru hodinových ručiček.", + "ui.3d_mouse_step_roll_away": "Několikrát kutálejte kuličkou od sebe, pak ji nechte zastavit.", + "ui.3d_mouse_step_roll_left": "Několikrát kutálejte kuličkou doleva, pak ji nechte zastavit.", + "ui.3d_mouse_step_roll_right": "Několikrát kutálejte kuličkou doprava, pak ji nechte zastavit.", + "ui.3d_mouse_step_roll_toward": "Několikrát kutálejte kuličkou k sobě, pak ji nechte zastavit.", + "ui.3d_mouse_step_slide_left": "Posuňte hlavici doleva, podržte a pusťte.", + "ui.3d_mouse_step_slide_right": "Posuňte hlavici doprava, podržte a pusťte.", + "ui.3d_mouse_step_spin_clockwise": "Několikrát roztočte kuličku po směru hodinových ručiček, pak ji nechte zastavit.", + "ui.3d_mouse_step_spin_counterclockwise": "Několikrát roztočte kuličku proti směru hodinových ručiček, pak ji nechte zastavit.", + "ui.3d_mouse_step_tilt_away": "Nakloňte vršek hlavice od sebe (okraj nejblíž vám jde nahoru), podržte a pusťte.", + "ui.3d_mouse_step_tilt_left": "Nakloňte hlavici doleva (levá hrana dolů), podržte a pusťte.", + "ui.3d_mouse_step_tilt_right": "Nakloňte hlavici doprava (pravá hrana dolů), podržte a pusťte.", + "ui.3d_mouse_step_tilt_toward": "Nakloňte vršek hlavice k sobě (okraj nejblíž vám jde dolů), podržte a pusťte.", + "ui.3d_mouse_step_touch": "Položte ruku na hlavici, ale netlačte na ni.", + "ui.3d_mouse_step_twist_clockwise": "Otočte hlavici po směru hodinových ručiček, podržte a pusťte.", + "ui.3d_mouse_step_twist_counterclockwise": "Otočte hlavici proti směru hodinových ručiček, podržte a pusťte.", + "ui.3d_mouse_switch_cap": "Kalibrovat jako návrat do středu", + "ui.3d_mouse_switch_trackball": "Kalibrovat jako trackball", + "ui.3d_mouse_view_axis": "Osa pohledu", + "ui.3d_mouse_view_off": "Vypnuto", + "ui.3d_mouse_view_off_tip": "Profil ovladače 3D myši tuto osu vypíná, takže její pohyb s pohledem nehýbe. Když tu vyberete pohyb jiné osy, prohodí se s ní a s pohledem přestane hýbat.", + "ui.3d_mouse_view_orbit_sideways": "Orbit vlevo / vpravo", + "ui.3d_mouse_view_orbit_up_down": "Orbit nahoru / dolů", + "ui.3d_mouse_view_pan_sideways": "Posun vlevo / vpravo", + "ui.3d_mouse_view_pan_up_down": "Posun nahoru / dolů", + "ui.3d_mouse_view_roll": "Natočení", + "ui.3d_mouse_view_unmapped": "Osa {n}", + "ui.3d_mouse_view_zoom": "Zoom", + "ui.3d_mouse_waiting": "Čeká na pohyb", + "ui.3d_mouse_wiggle": "Pokud máte nějakou připojenou, zahýbejte s ní.", "ui.3d_sketch": "3D náčrt", "ui.activate": "Aktivovat", "ui.active_component": "Aktivní komponenta", @@ -1354,6 +1457,7 @@ "ui.blocked": "Blokováno", "ui.bodies": "Tělesa", "ui.bodies_all_if_none": "Tělesa (všechna, pokud žádné)", + "ui.bottom": "Zespodu", "ui.box": "Kvádr", "ui.break": "Přerušit", "ui.browser_bodies_one": "{count} těleso", @@ -1797,59 +1901,6 @@ "ui.smooth_wheel_zoom": "Plynulé přiblížení kolečkem", "ui.smooth_wheel_zoom_tip": "Vypnutím se každý pohyb kolečka projeví okamžitě, bez dobíhání", "ui.snap": "Přichytávání", - "ui.space_mouse_axes_reassigned": "Osy jsou v Předvolbách přeřazené a to platí i po této kalibraci.", - "ui.space_mouse_calibrate": "Kalibrovat…", - "ui.space_mouse_calibrate_tip": "Pohybujte hlavicí podle pokynů v okně, aby se SolidCraft naučil, kterým směrem a jak daleko se pohybuje každá osa této 3D myši. Uloží se pro tuto 3D myš.", - "ui.space_mouse_calibrate_title": "Kalibrace 3D myši", - "ui.space_mouse_calibrated": "Zkalibrováno", - "ui.space_mouse_calibration": "Kalibrace 3D myši", - "ui.space_mouse_calibration_done": "Hotovo. Uložte ji a použije se vždy, když bude tato 3D myš připojená.", - "ui.space_mouse_calibration_no_response": "Jedna osa se skoro nepohnula, takže ji nelze zkalibrovat. Začněte znovu a pokaždé hlavicí pohněte dál.", - "ui.space_mouse_dead_zone": "Mrtvá zóna", - "ui.space_mouse_dead_zone_tip": "Jak daleko se musí hlavice pohnout, než se pohne pohled, jako podíl jejího plného rozsahu. Zvyšte ji, pokud pohled ujíždí, i když je hlavice v klidu.", - "ui.space_mouse_driven_by": "Který kalibrovaný pohyb zařízení ovládá tuto osu. Když vyberete pohyb, který už používá jiná osa, obě se prohodí.", - "ui.space_mouse_driven_by_label": "ovládá", - "ui.space_mouse_driver": "Ovladač 3D myši", - "ui.space_mouse_driver_auto": "Automaticky", - "ui.space_mouse_driver_direct": "Přímo (hidraw)", - "ui.space_mouse_driver_only_direct": "Zařízení se čte přímo; spacenavd neběží", - "ui.space_mouse_driver_only_spacenavd": "Čte se přes spacenavd, jiná možnost není", - "ui.space_mouse_forget": "Zapomenout kalibraci", - "ui.space_mouse_hint": "Posun a otáčení pohledu současně; náčrty používají jen posun a přiblížení.", - "ui.space_mouse_motion_rotation_x": "Rotace X", - "ui.space_mouse_motion_rotation_y": "Rotace Y", - "ui.space_mouse_motion_rotation_z": "Rotace Z", - "ui.space_mouse_motion_translation_x": "Posun X", - "ui.space_mouse_motion_translation_y": "Posun Y", - "ui.space_mouse_motion_translation_z": "Posun Z", - "ui.space_mouse_navigate": "Navigace pomocí SpaceNavigator / SpaceMouse", - "ui.space_mouse_none": "3D myš nenalezena", - "ui.space_mouse_not_calibrated": "Nezkalibrováno", - "ui.space_mouse_reading": "Hodnoty", - "ui.space_mouse_reset_axes": "Obnovit přiřazení os", - "ui.space_mouse_step_lift": "Zvedněte hlavici nahoru, podržte a pusťte.", - "ui.space_mouse_step_press": "Zatlačte hlavici dolů, podržte a pusťte.", - "ui.space_mouse_step_pull": "Přitáhněte hlavici k sobě, podržte a pusťte.", - "ui.space_mouse_step_push": "Odtlačte hlavici od sebe, podržte a pusťte.", - "ui.space_mouse_step_rest": "Dejte ruku z hlavice a nechte ji v klidu.", - "ui.space_mouse_step_slide_left": "Posuňte hlavici doleva, podržte a pusťte.", - "ui.space_mouse_step_slide_right": "Posuňte hlavici doprava, podržte a pusťte.", - "ui.space_mouse_step_tilt_back": "Nakloňte hlavici dozadu (přední hrana nahoru), podržte a pusťte.", - "ui.space_mouse_step_tilt_forward": "Nakloňte hlavici dopředu (přední hrana dolů), podržte a pusťte.", - "ui.space_mouse_step_tilt_left": "Nakloňte hlavici doleva (levá hrana dolů), podržte a pusťte.", - "ui.space_mouse_step_tilt_right": "Nakloňte hlavici doprava (pravá hrana dolů), podržte a pusťte.", - "ui.space_mouse_step_twist_clockwise": "Otočte hlavici po směru hodinových ručiček, podržte a pusťte.", - "ui.space_mouse_step_twist_counterclockwise": "Otočte hlavici proti směru hodinových ručiček, podržte a pusťte.", - "ui.space_mouse_view_axis": "Osa pohledu", - "ui.space_mouse_view_off": "Vypnuto", - "ui.space_mouse_view_off_tip": "Profil ovladače 3D myši tuto osu vypíná, takže její pohyb s pohledem nehýbe. Když tu vyberete pohyb jiné osy, prohodí se s ní a s pohledem přestane hýbat.", - "ui.space_mouse_view_orbit_sideways": "Orbit vlevo / vpravo", - "ui.space_mouse_view_orbit_up_down": "Orbit nahoru / dolů", - "ui.space_mouse_view_pan_sideways": "Posun vlevo / vpravo", - "ui.space_mouse_view_pan_up_down": "Posun nahoru / dolů", - "ui.space_mouse_view_roll": "Natočení", - "ui.space_mouse_view_unmapped": "Osa {n}", - "ui.space_mouse_view_zoom": "Zoom", "ui.spacing": "Rozteč", "ui.spacing_2": "Rozteč 2", "ui.sphere": "Koule", diff --git a/assets/locales/da.json b/assets/locales/da.json index eb3fd8e10c..8d4f9c6e39 100644 --- a/assets/locales/da.json +++ b/assets/locales/da.json @@ -530,6 +530,10 @@ "cmd.file_insert_step": "Indsæt STEP", "cmd.file_save_mesh": "Gem som mesh", "cmd.file_slicers": "Installerede slicere", + "cmd.frame_member": "Rammeelement", + "cmd.frame_profile_insert": "Indsæt profil", + "cmd.frame_profile_list": "Profilbibliotek", + "cmd.frame_profile_properties": "Profilegenskaber", "cmd.inspect_accessibility": "Tilgængelighedsanalyse", "cmd.inspect_center_of_mass": "Massecentrum", "cmd.inspect_clearance": "Frigangskontrol", @@ -1121,7 +1125,106 @@ "trace.stopped_by_contact": "Slutter ved prøve {sample}: {a} ville gå gennem {b}", "trace.stopped_by_loop": "Slutter ved prøve {sample}: Løkken gennem {joint} kan ikke lukkes yderligere", "ui.1_degree_of_freedom": "1 frihedsgrad", + "ui.3d_mouse_axes_reassigned": "Akserne er omtildelt i Indstillinger, og det gælder stadig oven på denne kalibrering.", + "ui.3d_mouse_button": "Knap {number}", + "ui.3d_mouse_button_nothing": "Ingenting", + "ui.3d_mouse_button_settings": "Indstillinger for 3D-mus", + "ui.3d_mouse_buttons": "Knapper", + "ui.3d_mouse_buttons_hint": "Tryk på en knap på din 3D-mus for at finde den her, og vælg så, hvad den gør.", + "ui.3d_mouse_buttons_no_device": "Tilslut en 3D-mus for at vælge, hvad dens knapper gør.", + "ui.3d_mouse_buttons_reset": "Gendan standardknapper", + "ui.3d_mouse_calibrate": "Kalibrér…", + "ui.3d_mouse_calibrate_tip": "Bevæg knoppen som vist i vinduet, så SolidCraft kan registrere retningen og bevægelsesområdet for hver akse. Indstillingerne gemmes til denne 3D-mus.", + "ui.3d_mouse_calibrate_title": "Kalibrér 3D-mus", + "ui.3d_mouse_calibrate_wait": "Bevæg 3D-musen én gang, så SolidCraft kan se den.", + "ui.3d_mouse_calibrated": "Kalibreret", + "ui.3d_mouse_calibration": "Kalibrering af 3D-mus", + "ui.3d_mouse_calibration_done": "Færdig. Gem kalibreringen, så den bruges, hver gang denne 3D-mus tilsluttes.", + "ui.3d_mouse_calibration_no_response": "En af akserne bevægede sig for lidt til at kunne kalibreres. Start forfra, og bevæg knoppen længere hver gang.", + "ui.3d_mouse_circles_help": "Cirklerne viser, hvad enheden sender efter kalibrering. Testmodellen viser, hvad visningen gør med det efter omvending, hastighed og akseplacering.", + "ui.3d_mouse_dead_zone": "Dødzone", + "ui.3d_mouse_dead_zone_tip": "Hvor langt knoppen skal bevæges, før visningen reagerer, angivet som en andel af dens fulde bevægelse. Øg værdien, hvis visningen bevæger sig, når du ikke rører knoppen.", + "ui.3d_mouse_denied": "SolidCraft kan se en 3D-mus, men må ikke læse den. Se opsætningsnoterne til 3D-mus for udev-reglen.", + "ui.3d_mouse_denied_short": "Fundet, men kan ikke læses", + "ui.3d_mouse_driven_by": "Hvilken kalibreret bevægelse af enheden der styrer denne akse. Vælger du en bevægelse, som en anden akse allerede bruger, bytter de to plads.", + "ui.3d_mouse_driven_by_label": "styres af", + "ui.3d_mouse_driver": "3D-musedriver", + "ui.3d_mouse_driver_auto": "Automatisk", + "ui.3d_mouse_driver_direct": "Direkte (hidraw)", + "ui.3d_mouse_driver_fallback": "Læser indtil videre gennem spacenavd: selve enheden kunne ikke åbnes.", + "ui.3d_mouse_driver_only_direct": "Læser enheden direkte; spacenavd kører ikke", + "ui.3d_mouse_driver_only_spacenavd": "Læser gennem spacenavd, den eneste mulighed", + "ui.3d_mouse_forget": "Slet kalibrering", + "ui.3d_mouse_ghost_left_viewport": "Venstre visningsområde", + "ui.3d_mouse_ghost_left_window": "Venstre vindue", + "ui.3d_mouse_ghost_position": "Placering", + "ui.3d_mouse_ghost_right_viewport": "Højre visningsområde", + "ui.3d_mouse_hint": "Flyt og rotér visningen samtidigt; skitser bruger kun panorering og zoom.", + "ui.3d_mouse_kind_cap": "Vend tilbage til midten", + "ui.3d_mouse_kind_question": "Hvilken slags 3D-mus er det?", + "ui.3d_mouse_kind_trackball": "Trackball", + "ui.3d_mouse_looks_like_cap": "Værdien bliver, mens du holder, som på en enhed af typen „Vend tilbage til midten“.", + "ui.3d_mouse_looks_like_trackball": "Værdien falder tilbage, så snart du stopper, som på en trackball.", + "ui.3d_mouse_motion_rotation_x": "Rotation X", + "ui.3d_mouse_motion_rotation_y": "Rotation Y", + "ui.3d_mouse_motion_rotation_z": "Rotation Z", + "ui.3d_mouse_motion_translation_x": "Forskydning X", + "ui.3d_mouse_motion_translation_y": "Forskydning Y", + "ui.3d_mouse_motion_translation_z": "Forskydning Z", + "ui.3d_mouse_navigate": "Navigér med 3D-musen", + "ui.3d_mouse_none": "Ingen 3D-mus fundet", + "ui.3d_mouse_not_calibrated": "Ikke kalibreret", + "ui.3d_mouse_offer": "Kalibrering af din 3D-mus retter ombyttede eller vendte akser og indstiller dens hastighed og fornemmelse.", + "ui.3d_mouse_offer_never": "Vis ikke igen", + "ui.3d_mouse_offer_swap": "Skift til Hidraw (direkte driver)", + "ui.3d_mouse_reading": "Aflæser", + "ui.3d_mouse_reset_axes": "Nulstil aksetildeling", + "ui.3d_mouse_reset_view": "Nulstil visning", + "ui.3d_mouse_restore": "Gendan 3D-musens standardværdier", + "ui.3d_mouse_restore_ask": "Sæt 3D-musens indstillinger tilbage til standard? Din kalibrering og knaptildeling bliver.", + "ui.3d_mouse_section": "3D-mus", + "ui.3d_mouse_show_in_view": "Vis 3D-musen i 3D-visningen", + "ui.3d_mouse_show_in_view_tip": "Et billede af din 3D-mus i nederste venstre hjørne, der bevæger sig, når du bevæger den.", + "ui.3d_mouse_skip": "Min enhed kan ikke det", + "ui.3d_mouse_skip_tip": "Springer bevægelsen over begge veje, og kalibreringen udelader den.", "ui.3d_mouse_speed": "3D-musens hastighed", + "ui.3d_mouse_step_lift": "Løft knoppen op, hold den, og slip.", + "ui.3d_mouse_step_press": "Tryk knoppen ned, hold den, og slip.", + "ui.3d_mouse_step_pull": "Træk knoppen mod dig, hold den, og slip.", + "ui.3d_mouse_step_push": "Skub knoppen væk fra dig, hold den, og slip.", + "ui.3d_mouse_step_rest": "Fjern hånden fra knoppen, og lad den stå stille.", + "ui.3d_mouse_step_rest_trackball": "Fjern hånden fra kuglen, og lad den ligge stille.", + "ui.3d_mouse_step_ring_clockwise": "Drej rullehjulet et par klik med uret.", + "ui.3d_mouse_step_ring_counterclockwise": "Drej rullehjulet et par klik mod uret.", + "ui.3d_mouse_step_roll_away": "Rul kuglen væk fra dig et par gange, og lad den stoppe.", + "ui.3d_mouse_step_roll_left": "Rul kuglen mod venstre et par gange, og lad den stoppe.", + "ui.3d_mouse_step_roll_right": "Rul kuglen mod højre et par gange, og lad den stoppe.", + "ui.3d_mouse_step_roll_toward": "Rul kuglen ind mod dig et par gange, og lad den stoppe.", + "ui.3d_mouse_step_slide_left": "Skub knoppen mod venstre, hold den, og slip.", + "ui.3d_mouse_step_slide_right": "Skub knoppen mod højre, hold den, og slip.", + "ui.3d_mouse_step_spin_clockwise": "Drej kuglen rundt med uret et par gange, og lad den stoppe.", + "ui.3d_mouse_step_spin_counterclockwise": "Drej kuglen rundt mod uret et par gange, og lad den stoppe.", + "ui.3d_mouse_step_tilt_away": "Vip toppen af knoppen væk fra dig (kanten nærmest dig går op), hold den, og slip.", + "ui.3d_mouse_step_tilt_left": "Vip knoppen mod venstre (venstre kant ned), hold den, og slip.", + "ui.3d_mouse_step_tilt_right": "Vip knoppen mod højre (højre kant ned), hold den, og slip.", + "ui.3d_mouse_step_tilt_toward": "Vip toppen af knoppen ind mod dig (kanten nærmest dig går ned), hold den, og slip.", + "ui.3d_mouse_step_touch": "Hvil hånden på knoppen uden at skubbe til den.", + "ui.3d_mouse_step_twist_clockwise": "Drej knoppen med uret, hold den, og slip.", + "ui.3d_mouse_step_twist_counterclockwise": "Drej knoppen mod uret, hold den, og slip.", + "ui.3d_mouse_switch_cap": "Kalibrer som vend tilbage til midten", + "ui.3d_mouse_switch_trackball": "Kalibrér som trackball", + "ui.3d_mouse_view_axis": "Visningsakse", + "ui.3d_mouse_view_off": "Fra", + "ui.3d_mouse_view_off_tip": "3D-musedriverens profil slår denne akse fra, så dens bevægelse flytter ikke visningen. Vælger du en anden akses bevægelse her, bytter de to plads, og den bevægelse flytter ikke længere visningen.", + "ui.3d_mouse_view_orbit_sideways": "Orbit venstre / højre", + "ui.3d_mouse_view_orbit_up_down": "Orbit op / ned", + "ui.3d_mouse_view_pan_sideways": "Panorér venstre / højre", + "ui.3d_mouse_view_pan_up_down": "Panorér op / ned", + "ui.3d_mouse_view_roll": "Rul", + "ui.3d_mouse_view_unmapped": "Akse {n}", + "ui.3d_mouse_view_zoom": "Zoom", + "ui.3d_mouse_waiting": "Venter på bevægelse", + "ui.3d_mouse_wiggle": "Hvis du har en tilsluttet, så bevæg den lidt.", "ui.3d_sketch": "3D-skitse", "ui.activate": "Aktivér", "ui.active_component": "Aktiv komponent", @@ -1351,6 +1454,7 @@ "ui.blocked": "Blokeret", "ui.bodies": "Emner", "ui.bodies_all_if_none": "Emner (alle, hvis ingen er valgt)", + "ui.bottom": "Nedenfra", "ui.box": "Boks", "ui.break": "Bryd", "ui.browser_bodies_one": "{count} emne", @@ -1794,34 +1898,6 @@ "ui.smooth_wheel_zoom": "Jævn zoom med musehjul", "ui.smooth_wheel_zoom_tip": "Slå fra for at anvende hver hjulbevægelse straks uden efterfølgende udjævning", "ui.snap": "Fastgør til geometri", - "ui.space_mouse_calibrate": "Kalibrér…", - "ui.space_mouse_calibrate_tip": "Bevæg knoppen som vist i vinduet, så SolidCraft kan registrere retningen og bevægelsesområdet for hver akse. Indstillingerne gemmes til denne 3D-mus.", - "ui.space_mouse_calibrate_title": "Kalibrér 3D-mus", - "ui.space_mouse_calibrated": "Kalibreret", - "ui.space_mouse_calibration": "Kalibrering af 3D-mus", - "ui.space_mouse_calibration_done": "Færdig. Gem indstillingerne, så de bruges, hver gang denne 3D-mus tilsluttes.", - "ui.space_mouse_calibration_no_response": "En af akserne bevægede sig for lidt til at kunne kalibreres. Start forfra, og bevæg knoppen længere hver gang.", - "ui.space_mouse_dead_zone": "Dødzone", - "ui.space_mouse_dead_zone_tip": "Hvor langt knoppen skal bevæges, før visningen reagerer, angivet som en andel af dens fulde bevægelse. Øg værdien, hvis visningen bevæger sig, når du ikke rører knoppen.", - "ui.space_mouse_forget": "Nulstil kalibrering", - "ui.space_mouse_hint": "Flyt og rotér visningen samtidigt; skitser bruger kun panorering og zoom.", - "ui.space_mouse_navigate": "Navigér med SpaceNavigator / SpaceMouse", - "ui.space_mouse_none": "Ingen 3D-mus fundet", - "ui.space_mouse_not_calibrated": "Ikke kalibreret", - "ui.space_mouse_reading": "Aflæser", - "ui.space_mouse_step_lift": "Løft knoppen op, hold den dér, og slip.", - "ui.space_mouse_step_press": "Tryk knoppen ned, hold den dér, og slip.", - "ui.space_mouse_step_pull": "Træk knoppen mod dig, hold den dér, og slip.", - "ui.space_mouse_step_push": "Skub knoppen væk fra dig, hold den dér, og slip.", - "ui.space_mouse_step_rest": "Fjern hånden fra knoppen, og lad den stå stille.", - "ui.space_mouse_step_slide_left": "Skub knoppen mod venstre, hold den dér, og slip.", - "ui.space_mouse_step_slide_right": "Skub knoppen mod højre, hold den dér, og slip.", - "ui.space_mouse_step_tilt_back": "Vip knoppen bagud (forkanten op), hold den dér, og slip.", - "ui.space_mouse_step_tilt_forward": "Vip knoppen fremad (forkanten ned), hold den dér, og slip.", - "ui.space_mouse_step_tilt_left": "Vip knoppen mod venstre (venstre kant ned), hold den dér, og slip.", - "ui.space_mouse_step_tilt_right": "Vip knoppen mod højre (højre kant ned), hold den dér, og slip.", - "ui.space_mouse_step_twist_clockwise": "Drej knoppen med uret, hold den dér, og slip.", - "ui.space_mouse_step_twist_counterclockwise": "Drej knoppen mod uret, hold den dér, og slip.", "ui.spacing": "Afstand", "ui.spacing_2": "Afstand 2", "ui.sphere": "Kugle", diff --git a/assets/locales/de.json b/assets/locales/de.json index 8ab6ba1def..35d3b03614 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -641,6 +641,10 @@ "cmd.file_insert_step": "STEP einfügen", "cmd.file_save_mesh": "Als Netz speichern", "cmd.file_slicers": "Installierte Slicer", + "cmd.frame_member": "Rahmenelement", + "cmd.frame_profile_insert": "Profil einfügen", + "cmd.frame_profile_list": "Profilbibliothek", + "cmd.frame_profile_properties": "Profileigenschaften", "cmd.inspect_accessibility": "Zugänglichkeitsanalyse", "cmd.inspect_center_of_mass": "Massenschwerpunkt", "cmd.inspect_clearance": "Freiraum", @@ -1187,6 +1191,8 @@ "settings.msaa_applies_on_restart": "Die Kantenglättung wird beim nächsten Start von SolidCraft übernommen", "settings.msaa_reset": "{setting}: {requested}× wird von diesem Grafikgerät nicht unterstützt; jetzt {reset}× (unterstützt: {supported}).", "settings.msaa_unsupported": "Von diesem Grafikgerät nicht unterstützt", + "settings.reference_geometry_through_bodies": "Referenzgeometrie durch Körper hindurch anzeigen", + "settings.reference_geometry_through_bodies_help": "Konturen inaktiver Skizzen und Bezugsebenen über schattierten Körpern zeichnen", "settings.reset_intro": "Diese Einstellungen funktionieren auf diesem Computer nicht und würden den Start von SolidCraft verhindern. Sie wurden daher geändert:", "settings.reset_title": "Einstellungen wurden geändert", "settings.supersampling_note": "Zeichnet die Ansicht in höherer Auflösung und rechnet sie herunter: glattere Kanten, Linien und Raster auf Bildschirmen unter 4K. Kostet GPU-Zeit: etwa 2,25-mal so viele Pixel bei 1,5× und 4-mal bei 2×.", @@ -1242,7 +1248,106 @@ "trace.stopped_by_contact": "Endet bei Probe {sample}: {a} würde {b} durchdringen", "trace.stopped_by_loop": "Endet bei Probe {sample}: die Schleife über {joint} lässt sich nicht weiter schließen", "ui.1_degree_of_freedom": "1 Freiheitsgrad", + "ui.3d_mouse_axes_reassigned": "Die Achsen sind in den Einstellungen neu zugeordnet, und das gilt auch nach dieser Kalibrierung.", + "ui.3d_mouse_button": "Taste {number}", + "ui.3d_mouse_button_nothing": "Nichts", + "ui.3d_mouse_button_settings": "3D-Maus-Einstellungen", + "ui.3d_mouse_buttons": "Tasten", + "ui.3d_mouse_buttons_hint": "Drücken Sie eine Taste an Ihrer 3D-Maus, um sie hier zu finden, und wählen Sie dann, was sie tut.", + "ui.3d_mouse_buttons_no_device": "Schließen Sie eine 3D-Maus an, um ihre Tasten zu belegen.", + "ui.3d_mouse_buttons_reset": "Standardbelegung wiederherstellen", + "ui.3d_mouse_calibrate": "Kalibrieren…", + "ui.3d_mouse_calibrate_tip": "Bewegen Sie die Kappe so, wie das Fenster es verlangt. So lernt SolidCraft, in welche Richtung und wie weit jede Achse dieser 3D-Maus geht. Wird für diese 3D-Maus gespeichert.", + "ui.3d_mouse_calibrate_title": "3D-Maus kalibrieren", + "ui.3d_mouse_calibrate_wait": "Bewegen Sie die 3D-Maus einmal, damit SolidCraft sie erkennt.", + "ui.3d_mouse_calibrated": "Kalibriert", + "ui.3d_mouse_calibration": "3D-Maus-Kalibrierung", + "ui.3d_mouse_calibration_done": "Fertig. Speichern Sie die Kalibrierung, damit sie immer gilt, wenn diese 3D-Maus angeschlossen ist.", + "ui.3d_mouse_calibration_no_response": "Eine Achse hat sich kaum bewegt und lässt sich nicht kalibrieren. Beginnen Sie von vorn und bewegen Sie die Kappe jedes Mal weiter.", + "ui.3d_mouse_circles_help": "Die Kreise zeigen, was das Gerät nach der Kalibrierung sendet. Das Testmodell zeigt, was die Ansicht daraus macht, nach Umkehren, Geschwindigkeit und Achsenzuordnung.", + "ui.3d_mouse_dead_zone": "Totzone", + "ui.3d_mouse_dead_zone_tip": "Wie weit sich die Kappe bewegen muss, bevor sich die Ansicht bewegt, als Anteil ihres vollen Wegs. Erhöhen Sie sie, wenn die Ansicht wandert, obwohl die Kappe ruht.", + "ui.3d_mouse_denied": "SolidCraft sieht eine 3D-Maus, darf sie aber nicht lesen. Die udev-Regel steht in den Einrichtungshinweisen zur 3D-Maus.", + "ui.3d_mouse_denied_short": "Gefunden, aber nicht lesbar", + "ui.3d_mouse_driven_by": "Welche kalibrierte Bewegung des Geräts diese Achse steuert. Wählen Sie eine, die schon eine andere Achse nutzt, werden die beiden getauscht.", + "ui.3d_mouse_driven_by_label": "gesteuert von", + "ui.3d_mouse_driver": "3D-Maus-Treiber", + "ui.3d_mouse_driver_auto": "Automatisch", + "ui.3d_mouse_driver_direct": "Direkt (hidraw)", + "ui.3d_mouse_driver_fallback": "Wird vorerst über spacenavd gelesen: das Gerät selbst ließ sich nicht öffnen.", + "ui.3d_mouse_driver_only_direct": "Das Gerät wird direkt gelesen; spacenavd läuft nicht", + "ui.3d_mouse_driver_only_spacenavd": "Lesen über spacenavd, die einzige verfügbare Möglichkeit", + "ui.3d_mouse_forget": "Kalibrierung verwerfen", + "ui.3d_mouse_ghost_left_viewport": "Ansicht links", + "ui.3d_mouse_ghost_left_window": "Fenster links", + "ui.3d_mouse_ghost_position": "Position", + "ui.3d_mouse_ghost_right_viewport": "Ansicht rechts", + "ui.3d_mouse_hint": "Ansicht gleichzeitig verschieben und drehen; Skizzen verwenden nur Schwenken und Zoomen.", + "ui.3d_mouse_kind_cap": "Zurück zur Mitte", + "ui.3d_mouse_kind_question": "Was für eine 3D-Maus ist es?", + "ui.3d_mouse_kind_trackball": "Trackball", + "ui.3d_mouse_looks_like_cap": "Der Wert bleibt stehen, solange Sie halten, wie bei einem Gerät vom Typ „Zurück zur Mitte“.", + "ui.3d_mouse_looks_like_trackball": "Der Wert fällt zurück, sobald Sie aufhören, wie bei einem Trackball.", + "ui.3d_mouse_motion_rotation_x": "Rotation X", + "ui.3d_mouse_motion_rotation_y": "Rotation Y", + "ui.3d_mouse_motion_rotation_z": "Rotation Z", + "ui.3d_mouse_motion_translation_x": "Translation X", + "ui.3d_mouse_motion_translation_y": "Translation Y", + "ui.3d_mouse_motion_translation_z": "Translation Z", + "ui.3d_mouse_navigate": "Mit der 3D-Maus navigieren", + "ui.3d_mouse_none": "Keine 3D-Maus gefunden", + "ui.3d_mouse_not_calibrated": "Nicht kalibriert", + "ui.3d_mouse_offer": "Die Kalibrierung Ihrer 3D-Maus behebt vertauschte oder umgekehrte Achsen und stellt Geschwindigkeit und Gefühl ein.", + "ui.3d_mouse_offer_never": "Nicht mehr anzeigen", + "ui.3d_mouse_offer_swap": "Zu Hidraw wechseln (direkter Treiber)", + "ui.3d_mouse_reading": "Werte", + "ui.3d_mouse_reset_axes": "Achsenzuordnung zurücksetzen", + "ui.3d_mouse_reset_view": "Ansicht zurücksetzen", + "ui.3d_mouse_restore": "3D-Maus-Standardwerte wiederherstellen", + "ui.3d_mouse_restore_ask": "3D-Maus-Einstellungen auf die Standardwerte zurücksetzen? Kalibrierung und Tastenbelegung bleiben.", + "ui.3d_mouse_section": "3D-Maus", + "ui.3d_mouse_show_in_view": "3D-Maus in der 3D-Ansicht anzeigen", + "ui.3d_mouse_show_in_view_tip": "Ein Bild Ihrer 3D-Maus unten links, das sich mit ihr bewegt.", + "ui.3d_mouse_skip": "Mein Gerät kann das nicht", + "ui.3d_mouse_skip_tip": "Überspringt diese Bewegung in beide Richtungen; die Kalibrierung lässt sie aus.", "ui.3d_mouse_speed": "Geschwindigkeit der 3D-Maus", + "ui.3d_mouse_step_lift": "Kappe nach oben ziehen, halten, dann loslassen.", + "ui.3d_mouse_step_press": "Kappe nach unten drücken, halten, dann loslassen.", + "ui.3d_mouse_step_pull": "Kappe zu sich heranziehen, halten, dann loslassen.", + "ui.3d_mouse_step_push": "Kappe von sich wegschieben, halten, dann loslassen.", + "ui.3d_mouse_step_rest": "Nehmen Sie die Hand von der Kappe und lassen Sie sie ruhen.", + "ui.3d_mouse_step_rest_trackball": "Nehmen Sie die Hand von der Kugel und lassen Sie sie ruhen.", + "ui.3d_mouse_step_ring_clockwise": "Drehen Sie den Scrollring ein paar Rasten im Uhrzeigersinn.", + "ui.3d_mouse_step_ring_counterclockwise": "Drehen Sie den Scrollring ein paar Rasten gegen den Uhrzeigersinn.", + "ui.3d_mouse_step_roll_away": "Rollen Sie die Kugel ein paarmal von sich weg und lassen Sie sie dann auslaufen.", + "ui.3d_mouse_step_roll_left": "Rollen Sie die Kugel ein paarmal nach links und lassen Sie sie dann auslaufen.", + "ui.3d_mouse_step_roll_right": "Rollen Sie die Kugel ein paarmal nach rechts und lassen Sie sie dann auslaufen.", + "ui.3d_mouse_step_roll_toward": "Rollen Sie die Kugel ein paarmal zu sich hin und lassen Sie sie dann auslaufen.", + "ui.3d_mouse_step_slide_left": "Kappe nach links schieben, halten, dann loslassen.", + "ui.3d_mouse_step_slide_right": "Kappe nach rechts schieben, halten, dann loslassen.", + "ui.3d_mouse_step_spin_clockwise": "Drehen Sie die Kugel ein paarmal im Uhrzeigersinn und lassen Sie sie dann auslaufen.", + "ui.3d_mouse_step_spin_counterclockwise": "Drehen Sie die Kugel ein paarmal gegen den Uhrzeigersinn und lassen Sie sie dann auslaufen.", + "ui.3d_mouse_step_tilt_away": "Oberseite der Kappe von sich weg kippen (die Ihnen nächste Kante geht nach oben), halten, dann loslassen.", + "ui.3d_mouse_step_tilt_left": "Kappe nach links kippen (linke Kante runter), halten, dann loslassen.", + "ui.3d_mouse_step_tilt_right": "Kappe nach rechts kippen (rechte Kante runter), halten, dann loslassen.", + "ui.3d_mouse_step_tilt_toward": "Oberseite der Kappe zu sich kippen (die Ihnen nächste Kante geht nach unten), halten, dann loslassen.", + "ui.3d_mouse_step_touch": "Legen Sie die Hand auf die Kappe, ohne zu drücken.", + "ui.3d_mouse_step_twist_clockwise": "Kappe im Uhrzeigersinn drehen, halten, dann loslassen.", + "ui.3d_mouse_step_twist_counterclockwise": "Kappe gegen den Uhrzeigersinn drehen, halten, dann loslassen.", + "ui.3d_mouse_switch_cap": "Als Zurück zur Mitte kalibrieren", + "ui.3d_mouse_switch_trackball": "Als Trackball kalibrieren", + "ui.3d_mouse_view_axis": "Ansichtsachse", + "ui.3d_mouse_view_off": "Aus", + "ui.3d_mouse_view_off_tip": "Das Profil des 3D-Maus-Treibers schaltet diese Achse ab, daher bewegt ihre Bewegung die Ansicht nicht. Wählen Sie hier die Bewegung einer anderen Achse, werden die beiden getauscht und sie bewegt die Ansicht nicht mehr.", + "ui.3d_mouse_view_orbit_sideways": "Orbit links / rechts", + "ui.3d_mouse_view_orbit_up_down": "Orbit hoch / runter", + "ui.3d_mouse_view_pan_sideways": "Schwenken links / rechts", + "ui.3d_mouse_view_pan_up_down": "Schwenken hoch / runter", + "ui.3d_mouse_view_roll": "Rollen", + "ui.3d_mouse_view_unmapped": "Achse {n}", + "ui.3d_mouse_view_zoom": "Zoom", + "ui.3d_mouse_waiting": "Warte auf Bewegung", + "ui.3d_mouse_wiggle": "Falls eine angeschlossen ist, bewegen Sie sie kurz.", "ui.3d_sketch": "3D-Skizze", "ui.activate": "Aktivieren", "ui.active_component": "Aktive Komponente", @@ -1472,6 +1577,7 @@ "ui.blocked": "Verdeckt", "ui.bodies": "Körper", "ui.bodies_all_if_none": "Körper (alle, wenn keine)", + "ui.bottom": "Unten", "ui.box": "Block", "ui.break": "Unterbrechen", "ui.browser_bodies_one": "{count} Körper", @@ -1894,6 +2000,12 @@ "ui.sent_to_slicer": "An {slicer} gesendet", "ui.sheet_gap": "Spalt", "ui.shell": "Hülle", + "ui.shortcut_capture_add_key": "Eine normale Taste hinzufügen. Escape bricht ab.", + "ui.shortcut_capture_hint": "Eine Belegung anklicken, dann Modifikatortasten und eine Taste drücken. Weitere Modifikatoren innerhalb einer Sekunde hinzufügen; zum Bestätigen alle Tasten loslassen. Escape bricht ab.", + "ui.shortcut_capture_press": "Modifikatortasten und eine Taste drücken. Escape bricht ab.", + "ui.shortcut_capture_release": "Zum Bestätigen alle Tasten loslassen. Escape bricht ab.", + "ui.shortcut_capture_settling": "Tastenkürzel wird bestätigt… Escape bricht ab.", + "ui.shortcut_multiple_keys": "Modifikatortasten und nur eine weitere Taste verwenden. Zum Wiederholen die Belegung anklicken.", "ui.show": "Einblenden", "ui.showAll": "Alle einblenden", "ui.showAllBodies": "Alle Volumenkörper einblenden", @@ -1923,59 +2035,6 @@ "ui.smooth_wheel_zoom": "Sanftes Zoomen mit dem Rad", "ui.smooth_wheel_zoom_tip": "Ausschalten, um jedes Radereignis sofort anzuwenden, ohne Nachlauf", "ui.snap": "Einrasten", - "ui.space_mouse_axes_reassigned": "Die Achsen sind in den Einstellungen neu zugeordnet, und das gilt auch nach dieser Kalibrierung.", - "ui.space_mouse_calibrate": "Kalibrieren…", - "ui.space_mouse_calibrate_tip": "Bewegen Sie die Kappe so, wie das Fenster es verlangt. So lernt SolidCraft, in welche Richtung und wie weit jede Achse dieser 3D-Maus geht. Wird für diese 3D-Maus gespeichert.", - "ui.space_mouse_calibrate_title": "3D-Maus kalibrieren", - "ui.space_mouse_calibrated": "Kalibriert", - "ui.space_mouse_calibration": "3D-Maus-Kalibrierung", - "ui.space_mouse_calibration_done": "Fertig. Speichern Sie die Kalibrierung, damit sie immer gilt, wenn diese 3D-Maus angeschlossen ist.", - "ui.space_mouse_calibration_no_response": "Eine Achse hat sich kaum bewegt und lässt sich nicht kalibrieren. Beginnen Sie von vorn und bewegen Sie die Kappe jedes Mal weiter.", - "ui.space_mouse_dead_zone": "Totzone", - "ui.space_mouse_dead_zone_tip": "Wie weit sich die Kappe bewegen muss, bevor sich die Ansicht bewegt, als Anteil ihres vollen Wegs. Erhöhen Sie sie, wenn die Ansicht wandert, obwohl die Kappe ruht.", - "ui.space_mouse_driven_by": "Welche kalibrierte Bewegung des Geräts diese Achse steuert. Wählen Sie eine, die schon eine andere Achse nutzt, werden die beiden getauscht.", - "ui.space_mouse_driven_by_label": "gesteuert von", - "ui.space_mouse_driver": "3D-Maus-Treiber", - "ui.space_mouse_driver_auto": "Automatisch", - "ui.space_mouse_driver_direct": "Direkt (hidraw)", - "ui.space_mouse_driver_only_direct": "Das Gerät wird direkt gelesen; spacenavd läuft nicht", - "ui.space_mouse_driver_only_spacenavd": "Lesen über spacenavd, die einzige verfügbare Möglichkeit", - "ui.space_mouse_forget": "Kalibrierung vergessen", - "ui.space_mouse_hint": "Ansicht gleichzeitig verschieben und drehen; Skizzen verwenden nur Schwenken und Zoomen.", - "ui.space_mouse_motion_rotation_x": "Rotation X", - "ui.space_mouse_motion_rotation_y": "Rotation Y", - "ui.space_mouse_motion_rotation_z": "Rotation Z", - "ui.space_mouse_motion_translation_x": "Translation X", - "ui.space_mouse_motion_translation_y": "Translation Y", - "ui.space_mouse_motion_translation_z": "Translation Z", - "ui.space_mouse_navigate": "Mit SpaceNavigator / SpaceMouse navigieren", - "ui.space_mouse_none": "Keine 3D-Maus gefunden", - "ui.space_mouse_not_calibrated": "Nicht kalibriert", - "ui.space_mouse_reading": "Werte", - "ui.space_mouse_reset_axes": "Achsenzuordnung zurücksetzen", - "ui.space_mouse_step_lift": "Kappe nach oben ziehen, halten, dann loslassen.", - "ui.space_mouse_step_press": "Kappe nach unten drücken, halten, dann loslassen.", - "ui.space_mouse_step_pull": "Kappe zu sich heranziehen, halten, dann loslassen.", - "ui.space_mouse_step_push": "Kappe von sich wegschieben, halten, dann loslassen.", - "ui.space_mouse_step_rest": "Nehmen Sie die Hand von der Kappe und lassen Sie sie ruhen.", - "ui.space_mouse_step_slide_left": "Kappe nach links schieben, halten, dann loslassen.", - "ui.space_mouse_step_slide_right": "Kappe nach rechts schieben, halten, dann loslassen.", - "ui.space_mouse_step_tilt_back": "Kappe nach hinten kippen (Vorderkante hoch), halten, dann loslassen.", - "ui.space_mouse_step_tilt_forward": "Kappe nach vorn kippen (Vorderkante runter), halten, dann loslassen.", - "ui.space_mouse_step_tilt_left": "Kappe nach links kippen (linke Kante runter), halten, dann loslassen.", - "ui.space_mouse_step_tilt_right": "Kappe nach rechts kippen (rechte Kante runter), halten, dann loslassen.", - "ui.space_mouse_step_twist_clockwise": "Kappe im Uhrzeigersinn drehen, halten, dann loslassen.", - "ui.space_mouse_step_twist_counterclockwise": "Kappe gegen den Uhrzeigersinn drehen, halten, dann loslassen.", - "ui.space_mouse_view_axis": "Ansichtsachse", - "ui.space_mouse_view_off": "Aus", - "ui.space_mouse_view_off_tip": "Das Profil des 3D-Maus-Treibers schaltet diese Achse ab, daher bewegt ihre Bewegung die Ansicht nicht. Wählen Sie hier die Bewegung einer anderen Achse, werden die beiden getauscht und sie bewegt die Ansicht nicht mehr.", - "ui.space_mouse_view_orbit_sideways": "Orbit links / rechts", - "ui.space_mouse_view_orbit_up_down": "Orbit hoch / runter", - "ui.space_mouse_view_pan_sideways": "Schwenken links / rechts", - "ui.space_mouse_view_pan_up_down": "Schwenken hoch / runter", - "ui.space_mouse_view_roll": "Rollen", - "ui.space_mouse_view_unmapped": "Achse {n}", - "ui.space_mouse_view_zoom": "Zoom", "ui.spacing": "Abstand", "ui.spacing_2": "Abstand 2", "ui.sphere": "Sphäre", @@ -2017,7 +2076,7 @@ "ui.top": "Oben", "ui.torus": "Torus", "ui.total_angle": "Gesamtwinkel", - "ui.trackpad_pan_tip": "Trackpad-Gesten schwenken (aus: sie zoomen wie das Rad); Mausrad zoomt, Daumenrad schwenkt seitlich, Pinch zoomt immer", + "ui.trackpad_pan_tip": "Scrollen mit zwei Fingern verschiebt die Ansicht; bei Fusion dreht Umschalt + zwei Finger die Ansicht. Aus: Scrollen zoomt. Mausrad und Pinch-Geste zoomen; das Daumenrad verschiebt seitlich.", "ui.trim": "Kürzen", "ui.twist": "Verdrehung", "ui.two_finger_scroll_pans": "Zwei-Finger-Scrollen schwenkt", diff --git a/assets/locales/en-GB.json b/assets/locales/en-GB.json index d22d4aab72..e4ab3a2d6d 100644 --- a/assets/locales/en-GB.json +++ b/assets/locales/en-GB.json @@ -16,6 +16,7 @@ "pattern.alignment_hint": "Counts include the original. Centre balances copies; Flip chooses the extra side for even totals.", "render.opaque_preview_help": "Opaque PBR and simple colours; blended transparency. Refraction, HDRI and final rendering are not available yet.", "sketch_text.align_center": "Centre", + "ui.3d_mouse_step_pull": "Pull the cap towards you, hold it, then let go.", "ui.center": "Centre", "ui.centerline": "Centreline", "ui.customizeToolbar": "Customise Toolbar…", @@ -24,7 +25,6 @@ "ui.layout_compact": "Compact", "ui.layout_floating": "Floating", "ui.ribbon_labels": "Show text under ribbon icons", - "ui.space_mouse_step_pull": "Pull the cap towards you, hold it, then let go.", "ui.workspace_layout": "Workspace layout", "ui.zoom_to_cursor_tip": "The wheel zooms towards the point under the cursor (off: towards the view's centre)", "update.prefs_flatpak": "Flatpak updates this copy ({version}): run {command} or use your software centre. SolidCraft doesn't check." diff --git a/assets/locales/en.json b/assets/locales/en.json index 2f9e78d52a..f6271b8df6 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -5,7 +5,9 @@ "2. Attach your screenshot: copy and paste it, or save and upload the PNG.": "2. Attach your screenshot: copy and paste it, or save and upload the PNG.", "2D": "2D", "3. Click Submit new issue on GitHub to send your report.": "3. Click Submit new issue on GitHub to send your report.", + "A Single hole snaps to the middle of its face and to the centres of its round edges": "A Single hole snaps to the middle of its face and to the centres of its round edges", "A new configuration, a copy of the active one": "A new configuration, a copy of the active one", + "A placed Single hole has a handle to drag it, and picking its face again moves the hole there instead of taking it out": "A placed Single hole has a handle to drag it, and picking its face again moves the hole there instead of taking it out", "ARRANGE": "ARRANGE", "About SolidCraft": "About SolidCraft", "Acceleration": "Acceleration", @@ -25,11 +27,13 @@ "Advanced": "Advanced", "Against": "Against", "Alignment": "Alignment", + "All Lines": "All Lines", "All Sketch Points": "All Sketch Points", "All components": "All components", "All open designs": "All open designs", "Allowed expressions, separated by semicolons": "Allowed expressions, separated by semicolons", "Also deletes": "Also deletes", + "Anchor": "Anchor", "Angle 2": "Angle 2", "Animate": "Animate", "App/system information and command settings are included. Review private details before sharing.": "App/system information and command settings are included. Review private details before sharing.", @@ -47,6 +51,7 @@ "BLEND SURFACE": "BLEND SURFACE", "BOUNDARY SURFACE": "BOUNDARY SURFACE", "BROWSER": "BROWSER", + "Bar": "Bar", "Bend Edges": "Bend Edges", "Bend Line Position": "Bend Line Position", "Bend Position": "Bend Position", @@ -59,6 +64,9 @@ "Body (last if none)": "Body (last if none)", "Body to split": "Body to split", "Body, or faces to take out": "Body, or faces to take out", + "Bottom Left": "Bottom Left", + "Bottom Middle": "Bottom Middle", + "Bottom Right": "Bottom Right", "Boundary": "Boundary", "Bounding box": "Bounding box", "CHECK": "CHECK", @@ -78,12 +86,15 @@ "Center Y": "Center Y", "Center of mass": "Center of mass", "Centre to centre": "Centre to centre", + "Centroid": "Centroid", "Chamfer Type": "Chamfer Type", + "Channel": "Channel", "Check collisions": "Check collisions", "Checked": "Checked", "Choose Variable radius or Spline in Type.": "Choose Variable radius or Spline in Type.", "Choose a new plane. Sketch geometry and dimensions stay unchanged in its two-dimensional coordinates.": "Choose a new plane. Sketch geometry and dimensions stay unchanged in its two-dimensional coordinates.", "Choose…": "Choose…", + "Circle center": "Circle center", "Circular": "Circular", "Circumference": "Circumference", "Close draft": "Close draft", @@ -113,10 +124,12 @@ "Copy screenshot": "Copy screenshot", "Corner Relief": "Corner Relief", "Corner Relief Shape": "Corner Relief Shape", + "Corner radius": "Corner radius", "Counterbore depth": "Counterbore depth", "Counterbore Ø": "Counterbore Ø", "Countersink angle": "Countersink angle", "Countersink Ø": "Countersink Ø", + "Create Hole": "Create Hole", "Create Issue — review draft": "Create Issue — review draft", "Create Issue…": "Create Issue…", "Cross curves": "Cross curves", @@ -152,6 +165,7 @@ "Don't ask again": "Don't ask again", "Double-click to rename": "Double-click to rename", "Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing", + "Drag to move the hole; click to remove it": "Drag to move the hole; click to remove it", "Drive Joint": "Drive Joint", "Duration": "Duration", "Edges": "Edges", @@ -185,6 +199,7 @@ "Face": "Face", "Face Appearance": "Face Appearance", "Face attachment lost": "Face attachment lost", + "Face center": "Face center", "Faces": "Faces", "Faces / edges / vertices": "Faces / edges / vertices", "Faces to split": "Faces to split", @@ -200,6 +215,7 @@ "First plane": "First plane", "Fit and G2 corners require Constant radius.": "Fit and G2 corners require Constant radius.", "Fixed Face": "Fixed Face", + "Flange thickness": "Flange thickness", "Flat Pattern": "Flat Pattern", "Flat size": "Flat size", "Follow camera (Look At sketches)": "Follow camera (Look At sketches)", @@ -222,6 +238,7 @@ "Holes (edges on them)": "Holes (edges on them)", "Home view": "Home view", "Horiz./Vert.": "Horiz./Vert.", + "I-Beam": "I-Beam", "IMAGES": "IMAGES", "Import…": "Import…", "In This Design": "In This Design", @@ -242,10 +259,12 @@ "Keep of the other surface": "Keep of the other surface", "Key a joint's value at this step": "Key a joint's value at this step", "Keys": "Keys", + "L-Angle": "L-Angle", "LIBRARY": "LIBRARY", "LINKS": "LINKS", "Later": "Later", "Leave it": "Leave it", + "Left Middle": "Left Middle", "Legacy face attachment. Rebinding to a certified face creates an exact attachment.": "Legacy face attachment. Rebinding to a certified face creates an exact attachment.", "Length": "Length", "Length units": "Length units", @@ -255,6 +274,7 @@ "Limits": "Limits", "Line Spacing": "Line Spacing", "Line Type": "Line Type", + "Lines": "Lines", "Listing the installed fonts…": "Listing the installed fonts…", "Load library": "Load library", "Local library": "Local library", @@ -288,6 +308,7 @@ "Move Type": "Move Type", "Move down": "Move down", "Move earlier": "Move earlier", + "Move holes by clicking or dragging": "Move holes by clicking or dragging", "Move later": "Move later", "Move up": "Move up", "Name": "Name", @@ -319,6 +340,7 @@ "Orbit (right drag)": "Orbit (right drag)", "Other side": "Other side", "Other surface (mutual trim)": "Other surface (mutual trim)", + "Outer radius": "Outer radius", "Outside": "Outside", "Overall length": "Overall length", "Param. Graph": "Param. Graph", @@ -361,6 +383,7 @@ "Recover": "Recover", "Recover unsaved design?": "Recover unsaved design?", "Rectangular": "Rectangular", + "Rectangular Tube": "Rectangular Tube", "Reference face": "Reference face", "Remove Tools": "Remove Tools", "Remove condition": "Remove condition", @@ -398,11 +421,15 @@ "Rib Offset": "Rib Offset", "Rib Thickness": "Rib Thickness", "Ribs": "Ribs", + "Right Middle": "Right Middle", "Rim Edge": "Rim Edge", "Ring of tools on the right button": "Ring of tools on the right button", "Roll Back Here": "Roll Back Here", "Roll History Marker Here": "Roll History Marker Here", + "Root radius": "Root radius", "Round": "Round", + "Round Bar": "Round Bar", + "Round Tube": "Round Tube", "Rule": "Rule", "Rule name": "Rule name", "Rules run from top to bottom. Matched drives change effective values.": "Rules run from top to bottom. Matched drives change effective values.", @@ -421,6 +448,7 @@ "Search this library": "Search this library", "Second plane": "Second plane", "Section": "Section", + "Section (optional)": "Section (optional)", "Sections (one sketch each)": "Sections (one sketch each)", "Select Other": "Select Other", "Select Other: hold the left button still on the model (or right-click › Select Other…) to list everything under the cursor; click again on the same spot to select the next thing there. Double-click the middle button to go Home (Preferences › Navigation).": "Select Other: hold the left button still on the model (or right-click › Select Other…) to list everything under the cursor; click again on the same spot to select the next thing there. Double-click the middle button to go Home (Preferences › Navigation).", @@ -445,6 +473,7 @@ "Sketch plane has no body-face attachment.": "Sketch plane has no body-face attachment.", "Sketch points": "Sketch points", "Slot width": "Slot width", + "Snap to face and edge centres": "Snap to face and edge centres", "SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft closed before these designs were saved. Their last autosave can be opened.", "Specialise": "Specialise", "Speed": "Speed", @@ -492,11 +521,15 @@ "To": "To", "To Offset": "To Offset", "To edges": "To edges", + "Toe radius": "Toe radius", "Tolerance": "Tolerance", "Too close": "Too close", "Tool Bodies": "Tool Bodies", "Tools": "Tools", "Tools, clockwise from the top": "Tools, clockwise from the top", + "Top Left": "Top Left", + "Top Middle": "Top Middle", + "Top Right": "Top Right", "Trimming tool (plane, face or curves)": "Trimming tool (plane, face or curves)", "Turn off section analysis before exporting the view.": "Turn off section analysis before exporting the view.", "Type": "Type", @@ -521,6 +554,7 @@ "Wall": "Wall", "Wall Location": "Wall Location", "Wall Thickness": "Wall Thickness", + "Web thickness": "Web thickness", "What did you do just before the problem? A short list is enough": "What did you do just before the problem? A short list is enough", "What did you expect to see?": "What did you expect to see?", "What happened?": "What happened?", @@ -542,6 +576,8 @@ "Z angle": "Z angle", "Z distance": "Z distance", "Zebra": "Zebra", + "ai.prefs.allow_control": "Let AI agents connect (control channel / MCP)", + "ai.prefs.allow_control_note": "Opens a control channel on this computer only (127.0.0.1, port 27878 or the next free one). A program that connects must also show a secret token that only your user account can read, made fresh at every start. Takes effect the next time SolidCraft starts. Off: nothing listens unless you start SolidCraft with --control.", "ai.prefs.availability": "Assistant controls", "ai.prefs.background_commands": "Run agent commands in the background", "ai.prefs.follow_camera": "Turn the camera to sketches", @@ -689,6 +725,10 @@ "cmd.file_insert_step": "Insert STEP", "cmd.file_save_mesh": "Save As Mesh", "cmd.file_slicers": "Installed Slicers", + "cmd.frame_member": "Frame Member", + "cmd.frame_profile_insert": "Insert Profile", + "cmd.frame_profile_list": "Profile Library", + "cmd.frame_profile_properties": "Profile Properties", "cmd.inspect_accessibility": "Accessibility Analysis", "cmd.inspect_center_of_mass": "Center of Mass", "cmd.inspect_clearance": "Clearance", @@ -747,6 +787,7 @@ "cmd.motion_trace_edit": "Edit Motion Trace", "cmd.motion_trace_keep": "Keep Motion Trace", "cmd.occurrence_copy": "Paste (Instance)", + "cmd.occurrence_drag": "Drag Occurrence", "cmd.occurrence_ground": "Ground", "cmd.occurrence_move": "Move Occurrence", "cmd.occurrence_properties": "Occurrence Physical Properties", @@ -804,6 +845,7 @@ "cmd.sketch_auto_constrain_from_datum": "AutoConstrain from datum", "cmd.sketch_auto_project": "Auto-Project Edges on Reference", "cmd.sketch_blend_curve": "Blend Curve", + "cmd.sketch_cancel": "Cancel Sketch", "cmd.sketch_chain": "Chain", "cmd.sketch_chamfer_distance_angle": "Distance and Angle Chamfer", "cmd.sketch_chamfer_equal_distance": "Equal Distance Chamfer", @@ -1008,6 +1050,7 @@ "file_load.stopping": "Waiting for the previous operation to stop...", "file_load.waiting": "Waiting for file operation", "fits the face, e.g. M8": "fits the face, e.g. M8", + "from the profile": "from the profile", "from the rule": "from the rule", "help.apache": "Apache licence 2.0", "help.attributions": "Attributions (ATTRIBUTION.md)", @@ -1363,6 +1406,7 @@ "search.suggest": "Did you mean {label}?", "search.try": "Type a command name, a feature in this design, or a parameter. To run a command with values, type its id and a JSON object: {example}", "search.valid": "Valid", + "section.off_hint": "No plane picked: OK turns the section off.", "settings.display_quality_note": "Only the display. Designs and exports are not affected.", "settings.frame_rate_note": "Shows frames per second in the view's bottom-right corner.", "settings.measure_axis_distances": "Show X/Y/Z distances", @@ -1370,6 +1414,8 @@ "settings.msaa_applies_on_restart": "Anti-aliasing applies the next time SolidCraft starts", "settings.msaa_reset": "{setting}: {requested}× isn't supported by this graphics device; it is now {reset}× (supported: {supported}).", "settings.msaa_unsupported": "Not supported by this graphics device", + "settings.reference_geometry_through_bodies": "Show reference geometry through bodies", + "settings.reference_geometry_through_bodies_help": "Draw inactive sketch contours and datum planes over shaded bodies", "settings.reset_intro": "These settings don't work on this computer and would keep SolidCraft from starting, so they were changed:", "settings.reset_title": "Settings were changed", "settings.supersampling_note": "Draws the view at a higher resolution and filters it down: smoother edges, lines and grid on screens below 4K. It costs GPU time: about 2.25× the pixels at 1.5× and 4× at 2×.", @@ -1429,7 +1475,106 @@ "trace.stopped_by_contact": "Ends at sample {sample}: {a} would pass through {b}", "trace.stopped_by_loop": "Ends at sample {sample}: the loop through {joint} can't close any further", "ui.1_degree_of_freedom": "1 degree of freedom", + "ui.3d_mouse_axes_reassigned": "Axes are reassigned in Preferences, and that still applies on top of this calibration.", + "ui.3d_mouse_button": "Button {number}", + "ui.3d_mouse_button_nothing": "Nothing", + "ui.3d_mouse_button_settings": "3D mouse settings", + "ui.3d_mouse_buttons": "Buttons", + "ui.3d_mouse_buttons_hint": "Press a button on your 3D mouse to find it here, then pick what it does.", + "ui.3d_mouse_buttons_no_device": "Connect a 3D mouse to set what its buttons do.", + "ui.3d_mouse_buttons_reset": "Restore default buttons", + "ui.3d_mouse_calibrate": "Calibrate…", + "ui.3d_mouse_calibrate_tip": "Move the cap as the window asks, so SolidCraft learns which way each axis of this 3D mouse goes and how far. Saved for this 3D mouse.", + "ui.3d_mouse_calibrate_title": "Calibrate 3D mouse", + "ui.3d_mouse_calibrate_wait": "Move the 3D mouse once so SolidCraft can see it.", + "ui.3d_mouse_calibrated": "Calibrated", + "ui.3d_mouse_calibration": "Calibration", + "ui.3d_mouse_calibration_done": "Done. Save it to use it whenever this 3D mouse is connected.", + "ui.3d_mouse_calibration_no_response": "One axis barely moved, so it can't be calibrated. Start over and move the cap further each time.", + "ui.3d_mouse_circles_help": "The circles show what the device sends, after calibration. The test model shows what the view does with it, after invert, speed and the axis assignment.", + "ui.3d_mouse_dead_zone": "Dead zone", + "ui.3d_mouse_dead_zone_tip": "How far the cap has to move before the view does, as a share of its full travel. Raise it if the view drifts while the cap is at rest.", + "ui.3d_mouse_denied": "SolidCraft can see a 3D mouse but isn't allowed to read it. See the 3D mouse setup notes for the udev rule.", + "ui.3d_mouse_denied_short": "Found, but not readable", + "ui.3d_mouse_driven_by": "Which calibrated motion of the device drives this axis. Picking a motion another axis already uses swaps the two.", + "ui.3d_mouse_driven_by_label": "driven by", + "ui.3d_mouse_driver": "Driver", + "ui.3d_mouse_driver_auto": "Automatic", + "ui.3d_mouse_driver_direct": "Direct (hidraw)", + "ui.3d_mouse_driver_fallback": "Reading through spacenavd for now: the device itself could not be opened.", + "ui.3d_mouse_driver_only_direct": "Reading the device directly; spacenavd is not running", + "ui.3d_mouse_driver_only_spacenavd": "Reading through spacenavd, the only way available", + "ui.3d_mouse_forget": "Forget calibration", + "ui.3d_mouse_ghost_left_viewport": "Left viewport", + "ui.3d_mouse_ghost_left_window": "Left window", + "ui.3d_mouse_ghost_position": "Position", + "ui.3d_mouse_ghost_right_viewport": "Right viewport", + "ui.3d_mouse_hint": "Move and rotate the view together; sketches use pan and zoom only.", + "ui.3d_mouse_kind_cap": "Return to Center", + "ui.3d_mouse_kind_question": "What kind of 3D mouse is it?", + "ui.3d_mouse_kind_trackball": "Trackball", + "ui.3d_mouse_looks_like_cap": "The reading stays put while you hold it, the way a Return to Center device does.", + "ui.3d_mouse_looks_like_trackball": "The reading keeps dropping back as soon as you stop moving, the way a trackball's does.", + "ui.3d_mouse_motion_rotation_x": "Rotation X", + "ui.3d_mouse_motion_rotation_y": "Rotation Y", + "ui.3d_mouse_motion_rotation_z": "Rotation Z", + "ui.3d_mouse_motion_translation_x": "Translation X", + "ui.3d_mouse_motion_translation_y": "Translation Y", + "ui.3d_mouse_motion_translation_z": "Translation Z", + "ui.3d_mouse_navigate": "Navigate with the 3D mouse", + "ui.3d_mouse_none": "No 3D mouse found", + "ui.3d_mouse_not_calibrated": "Not calibrated", + "ui.3d_mouse_offer": "Calibrating your 3D mouse fixes swapped or reversed axes and sets its speed and feel.", + "ui.3d_mouse_offer_never": "Never show again", + "ui.3d_mouse_offer_swap": "Swap to Hidraw (Direct Driver)", + "ui.3d_mouse_reading": "Reading", + "ui.3d_mouse_reset_axes": "Reset axis assignment", + "ui.3d_mouse_reset_view": "Reset view", + "ui.3d_mouse_restore": "Restore defaults", + "ui.3d_mouse_restore_ask": "Set the 3D mouse settings back to their defaults? Your calibration and button mapping stay.", + "ui.3d_mouse_section": "3D mouse", + "ui.3d_mouse_show_in_view": "Show the 3D mouse in the 3D view", + "ui.3d_mouse_show_in_view_tip": "A picture of your 3D mouse in the lower left corner, moving as you move it.", + "ui.3d_mouse_skip": "My device can't do this", + "ui.3d_mouse_skip_tip": "Skips this motion both ways, and the calibration leaves it out.", "ui.3d_mouse_speed": "3D mouse speed", + "ui.3d_mouse_step_lift": "Lift the cap up, hold it, then let go.", + "ui.3d_mouse_step_press": "Press the cap down, hold it, then let go.", + "ui.3d_mouse_step_pull": "Pull the cap toward you, hold it, then let go.", + "ui.3d_mouse_step_push": "Push the cap away from you, hold it, then let go.", + "ui.3d_mouse_step_rest": "Take your hand off the cap and leave it still.", + "ui.3d_mouse_step_rest_trackball": "Take your hand off the ball and leave it still.", + "ui.3d_mouse_step_ring_clockwise": "Turn the scroll ring clockwise a few clicks.", + "ui.3d_mouse_step_ring_counterclockwise": "Turn the scroll ring anticlockwise a few clicks.", + "ui.3d_mouse_step_roll_away": "Roll the ball away from you a few times, then let it stop.", + "ui.3d_mouse_step_roll_left": "Roll the ball left a few times, then let it stop.", + "ui.3d_mouse_step_roll_right": "Roll the ball right a few times, then let it stop.", + "ui.3d_mouse_step_roll_toward": "Roll the ball toward you a few times, then let it stop.", + "ui.3d_mouse_step_slide_left": "Slide the cap left, hold it, then let go.", + "ui.3d_mouse_step_slide_right": "Slide the cap right, hold it, then let go.", + "ui.3d_mouse_step_spin_clockwise": "Spin the ball clockwise a few times, then let it stop.", + "ui.3d_mouse_step_spin_counterclockwise": "Spin the ball anticlockwise a few times, then let it stop.", + "ui.3d_mouse_step_tilt_away": "Tilt the top of the cap away from you (the edge nearest you goes up), hold it, then let go.", + "ui.3d_mouse_step_tilt_left": "Tilt the cap left (left edge down), hold it, then let go.", + "ui.3d_mouse_step_tilt_right": "Tilt the cap right (right edge down), hold it, then let go.", + "ui.3d_mouse_step_tilt_toward": "Tilt the top of the cap toward you (the edge nearest you goes down), hold it, then let go.", + "ui.3d_mouse_step_touch": "Rest your hand on the cap without pushing it.", + "ui.3d_mouse_step_twist_clockwise": "Twist the cap clockwise, hold it, then let go.", + "ui.3d_mouse_step_twist_counterclockwise": "Twist the cap anticlockwise, hold it, then let go.", + "ui.3d_mouse_switch_cap": "Calibrate as Return to Center", + "ui.3d_mouse_switch_trackball": "Calibrate as a trackball", + "ui.3d_mouse_view_axis": "View axis", + "ui.3d_mouse_view_off": "Off", + "ui.3d_mouse_view_off_tip": "The 3D mouse driver's profile turns this axis off, so its motion doesn't move the view. Picking another axis's motion here swaps the two, and that motion stops moving the view.", + "ui.3d_mouse_view_orbit_sideways": "Orbit Y", + "ui.3d_mouse_view_orbit_up_down": "Orbit X", + "ui.3d_mouse_view_pan_sideways": "Pan X", + "ui.3d_mouse_view_pan_up_down": "Pan Y", + "ui.3d_mouse_view_roll": "Roll", + "ui.3d_mouse_view_unmapped": "Axis {n}", + "ui.3d_mouse_view_zoom": "Zoom", + "ui.3d_mouse_waiting": "Waiting for Movement", + "ui.3d_mouse_wiggle": "If you have one plugged in, wiggle it.", "ui.3d_sketch": "3D Sketch", "ui.activate": "Activate", "ui.active_component": "Active component", @@ -1667,6 +1812,7 @@ "ui.blocked": "Blocked", "ui.bodies": "Bodies", "ui.bodies_all_if_none": "Bodies (all if none)", + "ui.bottom": "Bottom", "ui.box": "Box", "ui.break": "Break", "ui.browser_bodies_one": "{count} body", @@ -1792,6 +1938,8 @@ "ui.extract_design_changed": "The design changed while choosing a file. Start Extract to File again.", "ui.extract_done": "The component is now its own design, linked here.", "ui.extrude": "Extrude", + "ui.extrude_auto_to_object": "Extrude to a picked target", + "ui.extrude_auto_to_object_tip": "In Extrude, with a profile picked, picking a face, vertex or parallel plane off its plane switches the extent to To Object", "ui.factor": "Factor", "ui.feature_history": "Feature history", "ui.feature_options": "Feature Options", @@ -2047,6 +2195,9 @@ "ui.rectangle": "Rectangle", "ui.rectangular_pattern": "Rectangular Pattern", "ui.redo": "Redo", + "ui.reference_1": "Reference 1", + "ui.reference_2": "Reference 2", + "ui.reference_distance": "Reference distance", "ui.relink_design_changed": "The assembly changed while choosing a source. Start Relink Source again.", "ui.relink_done": "Source relinked for all instances.", "ui.relink_not_linked": "This component is not linked to a source design.", @@ -2096,6 +2247,12 @@ "ui.sent_to_slicer": "Sent to {slicer}", "ui.sheet_gap": "Gap", "ui.shell": "Shell", + "ui.shortcut_capture_add_key": "Add one ordinary key. Escape cancels.", + "ui.shortcut_capture_hint": "Click a binding, then press modifiers and one key. Add modifiers within one second; release all keys to confirm. Escape cancels.", + "ui.shortcut_capture_press": "Press modifiers and one key. Escape cancels.", + "ui.shortcut_capture_release": "Release all keys to confirm. Escape cancels.", + "ui.shortcut_capture_settling": "Confirming shortcut… Escape cancels.", + "ui.shortcut_multiple_keys": "Use modifiers and only one other key. Click the binding to try again.", "ui.show": "Show", "ui.showAll": "Show All", "ui.showAllBodies": "Show All Bodies", @@ -2125,59 +2282,6 @@ "ui.smooth_wheel_zoom": "Smooth wheel zoom", "ui.smooth_wheel_zoom_tip": "Turn off to apply each wheel event immediately, without a smoothing tail", "ui.snap": "Snap", - "ui.space_mouse_axes_reassigned": "Axes are reassigned in Preferences, and that still applies on top of this calibration.", - "ui.space_mouse_calibrate": "Calibrate…", - "ui.space_mouse_calibrate_tip": "Move the cap as the window asks, so SolidCraft learns which way each axis of this 3D mouse goes and how far. Saved for this 3D mouse.", - "ui.space_mouse_calibrate_title": "Calibrate 3D mouse", - "ui.space_mouse_calibrated": "Calibrated", - "ui.space_mouse_calibration": "3D mouse calibration", - "ui.space_mouse_calibration_done": "Done. Save it to use it whenever this 3D mouse is connected.", - "ui.space_mouse_calibration_no_response": "One axis barely moved, so it can't be calibrated. Start over and move the cap further each time.", - "ui.space_mouse_dead_zone": "Dead zone", - "ui.space_mouse_dead_zone_tip": "How far the cap has to move before the view does, as a share of its full travel. Raise it if the view drifts while the cap is at rest.", - "ui.space_mouse_driven_by": "Which calibrated motion of the device drives this axis. Picking a motion another axis already uses swaps the two.", - "ui.space_mouse_driven_by_label": "driven by", - "ui.space_mouse_driver": "3D mouse driver", - "ui.space_mouse_driver_auto": "Automatic", - "ui.space_mouse_driver_direct": "Direct (hidraw)", - "ui.space_mouse_driver_only_direct": "Reading the device directly; spacenavd is not running", - "ui.space_mouse_driver_only_spacenavd": "Reading through spacenavd, the only way available", - "ui.space_mouse_forget": "Forget calibration", - "ui.space_mouse_hint": "Move and rotate the view together; sketches use pan and zoom only.", - "ui.space_mouse_motion_rotation_x": "Rotation X", - "ui.space_mouse_motion_rotation_y": "Rotation Y", - "ui.space_mouse_motion_rotation_z": "Rotation Z", - "ui.space_mouse_motion_translation_x": "Translation X", - "ui.space_mouse_motion_translation_y": "Translation Y", - "ui.space_mouse_motion_translation_z": "Translation Z", - "ui.space_mouse_navigate": "Navigate with SpaceNavigator / SpaceMouse", - "ui.space_mouse_none": "No 3D mouse found", - "ui.space_mouse_not_calibrated": "Not calibrated", - "ui.space_mouse_reading": "Reading", - "ui.space_mouse_reset_axes": "Reset axis assignment", - "ui.space_mouse_step_lift": "Lift the cap up, hold it, then let go.", - "ui.space_mouse_step_press": "Press the cap down, hold it, then let go.", - "ui.space_mouse_step_pull": "Pull the cap toward you, hold it, then let go.", - "ui.space_mouse_step_push": "Push the cap away from you, hold it, then let go.", - "ui.space_mouse_step_rest": "Take your hand off the cap and leave it still.", - "ui.space_mouse_step_slide_left": "Slide the cap left, hold it, then let go.", - "ui.space_mouse_step_slide_right": "Slide the cap right, hold it, then let go.", - "ui.space_mouse_step_tilt_back": "Tilt the cap back (front edge up), hold it, then let go.", - "ui.space_mouse_step_tilt_forward": "Tilt the cap forward (front edge down), hold it, then let go.", - "ui.space_mouse_step_tilt_left": "Tilt the cap left (left edge down), hold it, then let go.", - "ui.space_mouse_step_tilt_right": "Tilt the cap right (right edge down), hold it, then let go.", - "ui.space_mouse_step_twist_clockwise": "Twist the cap clockwise, hold it, then let go.", - "ui.space_mouse_step_twist_counterclockwise": "Twist the cap anticlockwise, hold it, then let go.", - "ui.space_mouse_view_axis": "View axis", - "ui.space_mouse_view_off": "Off", - "ui.space_mouse_view_off_tip": "The 3D mouse driver's profile turns this axis off, so its motion doesn't move the view. Picking another axis's motion here swaps the two, and that motion stops moving the view.", - "ui.space_mouse_view_orbit_sideways": "Orbit left / right", - "ui.space_mouse_view_orbit_up_down": "Orbit up / down", - "ui.space_mouse_view_pan_sideways": "Pan left / right", - "ui.space_mouse_view_pan_up_down": "Pan up / down", - "ui.space_mouse_view_roll": "Roll", - "ui.space_mouse_view_unmapped": "Axis {n}", - "ui.space_mouse_view_zoom": "Zoom", "ui.spacing": "Spacing", "ui.spacing_2": "Spacing 2", "ui.sphere": "Sphere", @@ -2219,7 +2323,7 @@ "ui.top": "Top", "ui.torus": "Torus", "ui.total_angle": "Total angle", - "ui.trackpad_pan_tip": "Trackpad gestures pan (off: they zoom like the wheel); mouse wheel zooms, thumb wheel pans sideways, pinch always zooms", + "ui.trackpad_pan_tip": "Two-finger scroll pans; in Fusion, Shift + two fingers orbits. Off: scroll zooms. Mouse wheel and pinch zoom; thumb wheel pans sideways.", "ui.trim": "Trim", "ui.twist": "Twist", "ui.two_finger_scroll_pans": "Two-finger scroll pans", diff --git a/assets/locales/es.json b/assets/locales/es.json index 5efd56a26d..c89b188e73 100644 --- a/assets/locales/es.json +++ b/assets/locales/es.json @@ -39,7 +39,7 @@ "Assign Rule": "Asignar regla", "Attachment": "Vinculación", "BLEND SURFACE": "SUPERFICIE DE TRANSICIÓN", - "BOUNDARY SURFACE": "SUPERFICIE LÍMITE", + "BOUNDARY SURFACE": "SUPERFICIE DE CONTORNO", "BROWSER": "EXPLORADOR", "Bend Line Position": "Posición de la línea de plegado", "Bend Position": "Posición del pliegue", @@ -162,7 +162,7 @@ "From Bodies": "Desde cuerpos", "From edges": "Aristas de origen", "Full Length": "Longitud total", - "Gap": "Holgura", + "Gap": "Separación", "Grid": "Cuadrícula", "HISTORY": "HISTORIAL", "Height": "Altura", @@ -530,6 +530,10 @@ "cmd.file_insert_step": "Insertar STEP", "cmd.file_save_mesh": "Guardar como malla", "cmd.file_slicers": "Laminadores instalados", + "cmd.frame_member": "Miembro estructural", + "cmd.frame_profile_insert": "Insertar perfil", + "cmd.frame_profile_list": "Biblioteca de perfiles", + "cmd.frame_profile_properties": "Propiedades del perfil", "cmd.inspect_accessibility": "Análisis de accesibilidad", "cmd.inspect_center_of_mass": "Centro de masas", "cmd.inspect_clearance": "Holgura", @@ -680,7 +684,7 @@ "cmd.sketch_line3d": "Línea 3D", "cmd.sketch_line_midpoint": "Línea por punto medio", "cmd.sketch_loose_ends": "Extremos sueltos", - "cmd.sketch_loose_ends_tie": "Unir extremos sueltos", + "cmd.sketch_loose_ends_tie": "Vincular extremos sueltos", "cmd.sketch_move": "Mover/copiar entidades de boceto", "cmd.sketch_move3d": "Mover punto 3D", "cmd.sketch_move_point": "Arrastrar punto de boceto", @@ -856,6 +860,12 @@ "help.licences": "Licencias", "help.mit": "Licencia MIT", "help.notice": "NOTICE", + "help.solid.chamfer": "Corta las aristas seleccionadas con un chaflán plano de la distancia que indiques. Úsalo para romper aristas vivas o para facilitar la entrada de un pasador o un tornillo.", + "help.solid.extrude": "Empuja un perfil de boceto hacia el 3D a lo largo de una distancia, añadiendo o quitando material. Úsalo para convertir un contorno plano en un bloque, un saliente o una cavidad.", + "help.solid.fillet": "Redondea las aristas seleccionadas con una transición circular suave del radio que indiques. Úsalo para suavizar aristas vivas por resistencia, comodidad o aspecto.", + "help.solid.hole": "Perfora un agujero redondo del diámetro que indiques en un punto elegido de una cara. Úsalo para holguras de tornillos, pasadores y otras aberturas redondas sencillas.", + "help.solid.revolve": "Gira un perfil de boceto alrededor de un eje para crear un sólido de revolución. Úsalo para ejes, pomos, cuencos y cualquier otra pieza de revolución.", + "help.solid.shell": "Vacía un sólido y deja paredes del grosor que indiques; además, puede abrir las caras que elijas. Úsalo para cajas, tapas y otras piezas de paredes finas.", "help.third_party": "Licencias de terceros", "home.back": "Volver a {name}", "home.crashes": "Informes de fallos", @@ -1066,6 +1076,8 @@ "settings.msaa_applies_on_restart": "El suavizado de bordes se aplicará la próxima vez que se inicie SolidCraft", "settings.msaa_reset": "{setting}: este dispositivo gráfico no admite {requested}×; se ha establecido {reset}× (admitidos: {supported}).", "settings.msaa_unsupported": "No compatible con este dispositivo gráfico", + "settings.reference_geometry_through_bodies": "Mostrar geometría de referencia a través de los cuerpos", + "settings.reference_geometry_through_bodies_help": "Dibujar los contornos de bocetos inactivos y los planos de referencia sobre los cuerpos sombreados", "settings.reset_intro": "Estos ajustes no funcionan en este ordenador e impedirían iniciar SolidCraft, por lo que se han cambiado:", "settings.reset_title": "Se han cambiado los ajustes", "settings.supersampling_note": "Dibuja la vista a mayor resolución y después la reduce: bordes, líneas y cuadrícula más suaves en pantallas inferiores a 4K. Consume recursos de GPU: unos 2,25× píxeles a 1,5× y 4× a 2×.", @@ -1079,7 +1091,7 @@ "sketch.draw_curves": "Dibujar como {linetype}", "sketch.make_selection": "Convertir la selección a {linetype}", "sketch.open_ends": "Abiertos {count}", - "sketch.untied_ends": "Sin unir {count}", + "sketch.untied_ends": "Sin vincular {count}", "sketch_panel.attached": "Vinculado a {body} / {path}", "sketch_panel.author": "Ubicación de autoría: {path}", "sketch_panel.dimension_hint": "Introduzca un valor o una expresión y pulse Aceptar.", @@ -1121,7 +1133,106 @@ "trace.stopped_by_contact": "Termina en la muestra {sample}: {a} atravesaría {b}", "trace.stopped_by_loop": "Termina en la muestra {sample}: el bucle que pasa por {joint} no puede cerrarse más", "ui.1_degree_of_freedom": "1 grado de libertad", + "ui.3d_mouse_axes_reassigned": "Los ejes están reasignados en Preferencias, y eso sigue aplicándose sobre esta calibración.", + "ui.3d_mouse_button": "Botón {number}", + "ui.3d_mouse_button_nothing": "Nada", + "ui.3d_mouse_button_settings": "Ajustes del ratón 3D", + "ui.3d_mouse_buttons": "Botones", + "ui.3d_mouse_buttons_hint": "Pulsa un botón de tu ratón 3D para encontrarlo aquí y elige qué hace.", + "ui.3d_mouse_buttons_no_device": "Conecta un ratón 3D para elegir qué hacen sus botones.", + "ui.3d_mouse_buttons_reset": "Restaurar los botones predeterminados", + "ui.3d_mouse_calibrate": "Calibrar…", + "ui.3d_mouse_calibrate_tip": "Mueve el capuchón como pide la ventana para que SolidCraft aprenda hacia dónde y hasta dónde va cada eje de este ratón 3D. Se guarda para este ratón 3D.", + "ui.3d_mouse_calibrate_title": "Calibrar ratón 3D", + "ui.3d_mouse_calibrate_wait": "Mueve el ratón 3D una vez para que SolidCraft lo vea.", + "ui.3d_mouse_calibrated": "Calibrado", + "ui.3d_mouse_calibration": "Calibración del ratón 3D", + "ui.3d_mouse_calibration_done": "Listo. Guárdala para usarla siempre que este ratón 3D esté conectado.", + "ui.3d_mouse_calibration_no_response": "Un eje apenas se movió, así que no se puede calibrar. Vuelve a empezar y mueve el capuchón más lejos cada vez.", + "ui.3d_mouse_circles_help": "Los círculos muestran lo que envía el dispositivo tras la calibración. El modelo de prueba muestra lo que hace la vista con ello, tras la inversión, la velocidad y la asignación de ejes.", + "ui.3d_mouse_dead_zone": "Zona muerta", + "ui.3d_mouse_dead_zone_tip": "Cuánto tiene que moverse el capuchón antes de que se mueva la vista, como parte de su recorrido completo. Auméntala si la vista se desplaza con el capuchón en reposo.", + "ui.3d_mouse_denied": "SolidCraft ve un ratón 3D, pero no tiene permiso para leerlo. La regla udev está en las notas de configuración del ratón 3D.", + "ui.3d_mouse_denied_short": "Encontrado, pero no se puede leer", + "ui.3d_mouse_driven_by": "Qué movimiento calibrado del dispositivo controla este eje. Si eliges uno que ya usa otro eje, se intercambian.", + "ui.3d_mouse_driven_by_label": "controlado por", + "ui.3d_mouse_driver": "Controlador del ratón 3D", + "ui.3d_mouse_driver_auto": "Automático", + "ui.3d_mouse_driver_direct": "Directo (hidraw)", + "ui.3d_mouse_driver_fallback": "Leyendo por ahora a través de spacenavd: no se pudo abrir el dispositivo.", + "ui.3d_mouse_driver_only_direct": "Lectura directa del dispositivo; spacenavd no se está ejecutando", + "ui.3d_mouse_driver_only_spacenavd": "Lectura mediante spacenavd, la única opción disponible", + "ui.3d_mouse_forget": "Olvidar calibración", + "ui.3d_mouse_ghost_left_viewport": "Vista izquierda", + "ui.3d_mouse_ghost_left_window": "Ventana izquierda", + "ui.3d_mouse_ghost_position": "Posición", + "ui.3d_mouse_ghost_right_viewport": "Vista derecha", + "ui.3d_mouse_hint": "Desplaza y gira la vista a la vez; los bocetos solo permiten desplazamiento y zoom.", + "ui.3d_mouse_kind_cap": "Retorno al centro", + "ui.3d_mouse_kind_question": "¿Qué tipo de ratón 3D es?", + "ui.3d_mouse_kind_trackball": "Trackball", + "ui.3d_mouse_looks_like_cap": "La lectura se mantiene mientras sujetas, como en un dispositivo de tipo «Retorno al centro».", + "ui.3d_mouse_looks_like_trackball": "La lectura vuelve atrás en cuanto dejas de moverte, como en un trackball.", + "ui.3d_mouse_motion_rotation_x": "Rotación X", + "ui.3d_mouse_motion_rotation_y": "Rotación Y", + "ui.3d_mouse_motion_rotation_z": "Rotación Z", + "ui.3d_mouse_motion_translation_x": "Traslación X", + "ui.3d_mouse_motion_translation_y": "Traslación Y", + "ui.3d_mouse_motion_translation_z": "Traslación Z", + "ui.3d_mouse_navigate": "Navegar con el ratón 3D", + "ui.3d_mouse_none": "No se encontró ningún ratón 3D", + "ui.3d_mouse_not_calibrated": "Sin calibrar", + "ui.3d_mouse_offer": "Calibrar tu ratón 3D corrige los ejes intercambiados o invertidos y ajusta su velocidad y sensación.", + "ui.3d_mouse_offer_never": "No volver a mostrar", + "ui.3d_mouse_offer_swap": "Cambiar a Hidraw (controlador directo)", + "ui.3d_mouse_reading": "Lectura", + "ui.3d_mouse_reset_axes": "Restablecer asignación de ejes", + "ui.3d_mouse_reset_view": "Restablecer vista", + "ui.3d_mouse_restore": "Restablecer valores del ratón 3D", + "ui.3d_mouse_restore_ask": "¿Volver a los valores predeterminados de la configuración del ratón 3D? La calibración y la asignación de botones se mantienen.", + "ui.3d_mouse_section": "Ratón 3D", + "ui.3d_mouse_show_in_view": "Mostrar el ratón 3D en la vista 3D", + "ui.3d_mouse_show_in_view_tip": "Una imagen de tu ratón 3D en la esquina inferior izquierda que se mueve cuando lo mueves.", + "ui.3d_mouse_skip": "Mi dispositivo no puede hacer esto", + "ui.3d_mouse_skip_tip": "Omite este movimiento en ambos sentidos; la calibración lo deja fuera.", "ui.3d_mouse_speed": "Velocidad del ratón 3D", + "ui.3d_mouse_step_lift": "Levanta el capuchón, mantenlo y suéltalo.", + "ui.3d_mouse_step_press": "Presiona el capuchón hacia abajo, mantenlo y suéltalo.", + "ui.3d_mouse_step_pull": "Tira del capuchón hacia ti, mantenlo y suéltalo.", + "ui.3d_mouse_step_push": "Empuja el capuchón lejos de ti, mantenlo y suéltalo.", + "ui.3d_mouse_step_rest": "Suelta el capuchón y déjalo quieto.", + "ui.3d_mouse_step_rest_trackball": "Suelta la bola y déjala quieta.", + "ui.3d_mouse_step_ring_clockwise": "Gira el anillo de desplazamiento unos clics en sentido horario.", + "ui.3d_mouse_step_ring_counterclockwise": "Gira el anillo de desplazamiento unos clics en sentido antihorario.", + "ui.3d_mouse_step_roll_away": "Haz rodar la bola alejándola de ti unas cuantas veces y deja que se pare.", + "ui.3d_mouse_step_roll_left": "Haz rodar la bola a la izquierda unas cuantas veces y deja que se pare.", + "ui.3d_mouse_step_roll_right": "Haz rodar la bola a la derecha unas cuantas veces y deja que se pare.", + "ui.3d_mouse_step_roll_toward": "Haz rodar la bola hacia ti unas cuantas veces y deja que se pare.", + "ui.3d_mouse_step_slide_left": "Desliza el capuchón a la izquierda, mantenlo y suéltalo.", + "ui.3d_mouse_step_slide_right": "Desliza el capuchón a la derecha, mantenlo y suéltalo.", + "ui.3d_mouse_step_spin_clockwise": "Gira la bola en sentido horario unas cuantas veces y deja que se pare.", + "ui.3d_mouse_step_spin_counterclockwise": "Gira la bola en sentido antihorario unas cuantas veces y deja que se pare.", + "ui.3d_mouse_step_tilt_away": "Inclina la parte de arriba del capuchón alejándola de ti (el borde más cercano sube), mantenlo y suéltalo.", + "ui.3d_mouse_step_tilt_left": "Inclina el capuchón a la izquierda (borde izquierdo abajo), mantenlo y suéltalo.", + "ui.3d_mouse_step_tilt_right": "Inclina el capuchón a la derecha (borde derecho abajo), mantenlo y suéltalo.", + "ui.3d_mouse_step_tilt_toward": "Inclina la parte de arriba del capuchón hacia ti (el borde más cercano baja), mantenlo y suéltalo.", + "ui.3d_mouse_step_touch": "Apoya la mano en el capuchón sin empujarlo.", + "ui.3d_mouse_step_twist_clockwise": "Gira el capuchón en sentido horario, mantenlo y suéltalo.", + "ui.3d_mouse_step_twist_counterclockwise": "Gira el capuchón en sentido antihorario, mantenlo y suéltalo.", + "ui.3d_mouse_switch_cap": "Calibrar como retorno al centro", + "ui.3d_mouse_switch_trackball": "Calibrar como trackball", + "ui.3d_mouse_view_axis": "Eje de la vista", + "ui.3d_mouse_view_off": "Desactivado", + "ui.3d_mouse_view_off_tip": "El perfil del controlador del ratón 3D desactiva este eje, así que su movimiento no mueve la vista. Si eliges aquí el movimiento de otro eje, se intercambian y ese movimiento deja de mover la vista.", + "ui.3d_mouse_view_orbit_sideways": "Orbitar izquierda / derecha", + "ui.3d_mouse_view_orbit_up_down": "Orbitar arriba / abajo", + "ui.3d_mouse_view_pan_sideways": "Desplazar izquierda / derecha", + "ui.3d_mouse_view_pan_up_down": "Desplazar arriba / abajo", + "ui.3d_mouse_view_roll": "Girar sobre la vista", + "ui.3d_mouse_view_unmapped": "Eje {n}", + "ui.3d_mouse_view_zoom": "Zoom", + "ui.3d_mouse_waiting": "Esperando movimiento", + "ui.3d_mouse_wiggle": "Si tienes uno conectado, muévelo un poco.", "ui.3d_sketch": "Boceto 3D", "ui.activate": "Activar", "ui.active_component": "Componente activo", @@ -1251,6 +1362,8 @@ "ui.agent_chat_waiting": "Esperando tu respuesta", "ui.agent_cursor": "Cursor del agente", "ui.agent_cursor_speed": "Velocidad del cursor del agente", + "ui.agent_off_thread": "Mantener la ventana activa mientras los agentes ejecutan comandos", + "ui.agent_off_thread_note": "Los comandos lentos de un agente (desde el panel de IA o un cliente MCP) se calculan al margen de la ventana: puedes seguir girando e inspeccionando el visor y detener uno desde el botón del visor. Desactivado: cada comando se ejecuta en el hilo de la ventana y la ventana espera a que termine.", "ui.ai_add": "Añadir", "ui.ai_add_agent": "Añadir agente…", "ui.ai_add_agent_title": "Añadir agente", @@ -1324,8 +1437,8 @@ "ui.ai_tool_running": "en ejecución", "ui.ai_tool_unfold": "Haz clic para ver el resultado", "ui.ai_tool_waiting": "en espera", - "ui.ai_toolbar_button": "Mostrar botón en la barra de herramientas", - "ui.ai_toolbar_button_note": "El botón IA situado al extremo derecho de la barra. Ctrl+Mayús+A y el menú Ver también abren el panel.", + "ui.ai_toolbar_button": "Mostrar el botón Asistente en la barra de la aplicación", + "ui.ai_toolbar_button_note": "El botón Asistente de la barra de la aplicación. Ctrl+Mayús+A y el menú Ver también abren el panel.", "ui.ai_undo_tip": "Deshacer todos los cambios del agente en este mensaje, en un solo paso", "ui.ai_url_hint": "https://example.com/v1", "ui.all": "Todo", @@ -1351,6 +1464,7 @@ "ui.blocked": "Bloqueado", "ui.bodies": "Cuerpos", "ui.bodies_all_if_none": "Cuerpos (todos si no hay ninguno)", + "ui.bottom": "Inferior", "ui.box": "Caja", "ui.break": "Romper", "ui.browser_bodies_one": "{count} cuerpo", @@ -1794,59 +1908,6 @@ "ui.smooth_wheel_zoom": "Zoom suave con la rueda", "ui.smooth_wheel_zoom_tip": "Desactívalo para aplicar cada movimiento de la rueda inmediatamente, sin suavizado posterior", "ui.snap": "Referencia a objetos", - "ui.space_mouse_axes_reassigned": "Los ejes están reasignados en Preferencias, y eso sigue aplicándose sobre esta calibración.", - "ui.space_mouse_calibrate": "Calibrar…", - "ui.space_mouse_calibrate_tip": "Mueve el capuchón como pide la ventana para que SolidCraft aprenda hacia dónde y hasta dónde va cada eje de este ratón 3D. Se guarda para este ratón 3D.", - "ui.space_mouse_calibrate_title": "Calibrar ratón 3D", - "ui.space_mouse_calibrated": "Calibrado", - "ui.space_mouse_calibration": "Calibración del ratón 3D", - "ui.space_mouse_calibration_done": "Listo. Guárdala para usarla siempre que este ratón 3D esté conectado.", - "ui.space_mouse_calibration_no_response": "Un eje apenas se movió, así que no se puede calibrar. Vuelve a empezar y mueve el capuchón más lejos cada vez.", - "ui.space_mouse_dead_zone": "Zona muerta", - "ui.space_mouse_dead_zone_tip": "Cuánto tiene que moverse el capuchón antes de que se mueva la vista, como parte de su recorrido completo. Auméntala si la vista se desplaza con el capuchón en reposo.", - "ui.space_mouse_driven_by": "Qué movimiento calibrado del dispositivo controla este eje. Si eliges uno que ya usa otro eje, se intercambian.", - "ui.space_mouse_driven_by_label": "controlado por", - "ui.space_mouse_driver": "Controlador del ratón 3D", - "ui.space_mouse_driver_auto": "Automático", - "ui.space_mouse_driver_direct": "Directo (hidraw)", - "ui.space_mouse_driver_only_direct": "Lectura directa del dispositivo; spacenavd no se está ejecutando", - "ui.space_mouse_driver_only_spacenavd": "Lectura mediante spacenavd, la única opción disponible", - "ui.space_mouse_forget": "Olvidar calibración", - "ui.space_mouse_hint": "Desplaza y gira la vista a la vez; los bocetos solo permiten desplazamiento y zoom.", - "ui.space_mouse_motion_rotation_x": "Rotación X", - "ui.space_mouse_motion_rotation_y": "Rotación Y", - "ui.space_mouse_motion_rotation_z": "Rotación Z", - "ui.space_mouse_motion_translation_x": "Traslación X", - "ui.space_mouse_motion_translation_y": "Traslación Y", - "ui.space_mouse_motion_translation_z": "Traslación Z", - "ui.space_mouse_navigate": "Navegar con SpaceNavigator / SpaceMouse", - "ui.space_mouse_none": "No se encontró ningún ratón 3D", - "ui.space_mouse_not_calibrated": "Sin calibrar", - "ui.space_mouse_reading": "Lectura", - "ui.space_mouse_reset_axes": "Restablecer asignación de ejes", - "ui.space_mouse_step_lift": "Levanta el capuchón, mantenlo y suéltalo.", - "ui.space_mouse_step_press": "Presiona el capuchón hacia abajo, mantenlo y suéltalo.", - "ui.space_mouse_step_pull": "Tira del capuchón hacia ti, mantenlo y suéltalo.", - "ui.space_mouse_step_push": "Empuja el capuchón lejos de ti, mantenlo y suéltalo.", - "ui.space_mouse_step_rest": "Suelta el capuchón y déjalo quieto.", - "ui.space_mouse_step_slide_left": "Desliza el capuchón a la izquierda, mantenlo y suéltalo.", - "ui.space_mouse_step_slide_right": "Desliza el capuchón a la derecha, mantenlo y suéltalo.", - "ui.space_mouse_step_tilt_back": "Inclina el capuchón hacia atrás (borde delantero arriba), mantenlo y suéltalo.", - "ui.space_mouse_step_tilt_forward": "Inclina el capuchón hacia delante (borde delantero abajo), mantenlo y suéltalo.", - "ui.space_mouse_step_tilt_left": "Inclina el capuchón a la izquierda (borde izquierdo abajo), mantenlo y suéltalo.", - "ui.space_mouse_step_tilt_right": "Inclina el capuchón a la derecha (borde derecho abajo), mantenlo y suéltalo.", - "ui.space_mouse_step_twist_clockwise": "Gira el capuchón en sentido horario, mantenlo y suéltalo.", - "ui.space_mouse_step_twist_counterclockwise": "Gira el capuchón en sentido antihorario, mantenlo y suéltalo.", - "ui.space_mouse_view_axis": "Eje de la vista", - "ui.space_mouse_view_off": "Desactivado", - "ui.space_mouse_view_off_tip": "El perfil del controlador del ratón 3D desactiva este eje, así que su movimiento no mueve la vista. Si eliges aquí el movimiento de otro eje, se intercambian y ese movimiento deja de mover la vista.", - "ui.space_mouse_view_orbit_sideways": "Orbitar izquierda / derecha", - "ui.space_mouse_view_orbit_up_down": "Orbitar arriba / abajo", - "ui.space_mouse_view_pan_sideways": "Desplazar izquierda / derecha", - "ui.space_mouse_view_pan_up_down": "Desplazar arriba / abajo", - "ui.space_mouse_view_roll": "Girar sobre la vista", - "ui.space_mouse_view_unmapped": "Eje {n}", - "ui.space_mouse_view_zoom": "Zoom", "ui.spacing": "Espaciado", "ui.spacing_2": "Espaciado 2", "ui.sphere": "Esfera", @@ -1877,8 +1938,8 @@ "ui.theme": "Tema", "ui.thickness": "Espesor", "ui.thread": "Rosca", - "ui.tie_ends": "Unir", - "ui.tie_ends_tip": "Restringir todos los extremos sin unir", + "ui.tie_ends": "Vincular", + "ui.tie_ends_tip": "Restringe todos los extremos sin vincular", "ui.timeline_bottom": "Línea de tiempo inferior", "ui.timeline_left_note": "La disposición izquierda muestra la barra de retroceso encima del explorador de piezas y escenas.", "ui.timeline_left_sidebar": "Barra lateral izquierda", diff --git a/assets/locales/fr.json b/assets/locales/fr.json index adb61a4f63..00ce84da7e 100644 --- a/assets/locales/fr.json +++ b/assets/locales/fr.json @@ -530,6 +530,10 @@ "cmd.file_insert_step": "Insérer un STEP", "cmd.file_save_mesh": "Enregistrer comme maillage", "cmd.file_slicers": "Slicers installés", + "cmd.frame_member": "Élément de structure", + "cmd.frame_profile_insert": "Insérer un profilé", + "cmd.frame_profile_list": "Bibliothèque de profilés", + "cmd.frame_profile_properties": "Propriétés du profilé", "cmd.inspect_accessibility": "Analyse d'accessibilité", "cmd.inspect_center_of_mass": "Centre de gravité", "cmd.inspect_clearance": "Jeu", @@ -1072,6 +1076,8 @@ "settings.msaa_applies_on_restart": "L'anticrénelage s'appliquera au prochain démarrage de SolidCraft", "settings.msaa_reset": "{setting} : {requested}× n'est pas pris en charge par ce périphérique graphique ; la valeur est désormais {reset}× (pris en charge : {supported}).", "settings.msaa_unsupported": "Non pris en charge par ce périphérique graphique", + "settings.reference_geometry_through_bodies": "Afficher la géométrie de référence à travers les corps", + "settings.reference_geometry_through_bodies_help": "Afficher les contours des esquisses inactives et les plans de référence par-dessus les corps ombrés", "settings.reset_intro": "Ces réglages ne fonctionnent pas sur cet ordinateur et empêcheraient SolidCraft de démarrer ; ils ont donc été modifiés :", "settings.reset_title": "Des réglages ont été modifiés", "settings.supersampling_note": "Dessine la vue à une résolution plus élevée puis la réduit : arêtes, lignes et grille plus lisses sur les écrans de définition inférieure à la 4K. Cela coûte du temps GPU : environ 2,25× plus de pixels à 1,5× et 4× plus à 2×.", @@ -1127,7 +1133,106 @@ "trace.stopped_by_contact": "Se termine à l'échantillon {sample} : {a} traverserait {b}", "trace.stopped_by_loop": "Se termine à l'échantillon {sample} : la boucle passant par {joint} ne peut plus se fermer", "ui.1_degree_of_freedom": "1 degré de liberté", + "ui.3d_mouse_axes_reassigned": "Les axes sont réattribués dans les Préférences, et cela s'applique toujours en plus de ce calibrage.", + "ui.3d_mouse_button": "Bouton {number}", + "ui.3d_mouse_button_nothing": "Rien", + "ui.3d_mouse_button_settings": "Réglages de la souris 3D", + "ui.3d_mouse_buttons": "Boutons", + "ui.3d_mouse_buttons_hint": "Appuyez sur un bouton de votre souris 3D pour le trouver ici, puis choisissez ce qu'il fait.", + "ui.3d_mouse_buttons_no_device": "Branchez une souris 3D pour choisir ce que font ses boutons.", + "ui.3d_mouse_buttons_reset": "Rétablir les boutons par défaut", + "ui.3d_mouse_calibrate": "Calibrer…", + "ui.3d_mouse_calibrate_tip": "Déplacez le capuchon comme la fenêtre le demande, pour que SolidCraft apprenne dans quel sens et jusqu'où va chaque axe de cette souris 3D. Enregistré pour cette souris 3D.", + "ui.3d_mouse_calibrate_title": "Calibrer la souris 3D", + "ui.3d_mouse_calibrate_wait": "Bougez la souris 3D une fois pour que SolidCraft la voie.", + "ui.3d_mouse_calibrated": "Calibrée", + "ui.3d_mouse_calibration": "Calibrage de la souris 3D", + "ui.3d_mouse_calibration_done": "Terminé. Enregistrez-le pour l'utiliser chaque fois que cette souris 3D est connectée.", + "ui.3d_mouse_calibration_no_response": "Un axe a à peine bougé, il ne peut donc pas être calibré. Recommencez en déplaçant le capuchon plus loin à chaque fois.", + "ui.3d_mouse_circles_help": "Les cercles montrent ce que l'appareil envoie après calibrage. Le modèle d'essai montre ce que la vue en fait, après inversion, vitesse et affectation des axes.", + "ui.3d_mouse_dead_zone": "Zone morte", + "ui.3d_mouse_dead_zone_tip": "De combien le capuchon doit bouger avant que la vue ne bouge, en part de sa course complète. Augmentez-la si la vue dérive alors que le capuchon est au repos.", + "ui.3d_mouse_denied": "SolidCraft voit une souris 3D mais n'a pas le droit de la lire. La règle udev est dans les notes d'installation de la souris 3D.", + "ui.3d_mouse_denied_short": "Trouvée, mais illisible", + "ui.3d_mouse_driven_by": "Quel mouvement calibré de l'appareil pilote cet axe. Choisir un mouvement déjà utilisé par un autre axe les échange.", + "ui.3d_mouse_driven_by_label": "piloté par", + "ui.3d_mouse_driver": "Pilote de la souris 3D", + "ui.3d_mouse_driver_auto": "Automatique", + "ui.3d_mouse_driver_direct": "Direct (hidraw)", + "ui.3d_mouse_driver_fallback": "Lecture via spacenavd pour l'instant : l'appareil lui-même n'a pas pu être ouvert.", + "ui.3d_mouse_driver_only_direct": "Lecture directe du périphérique ; spacenavd n'est pas lancé", + "ui.3d_mouse_driver_only_spacenavd": "Lecture via spacenavd, seule possibilité disponible", + "ui.3d_mouse_forget": "Oublier le calibrage", + "ui.3d_mouse_ghost_left_viewport": "Vue à gauche", + "ui.3d_mouse_ghost_left_window": "Fenêtre à gauche", + "ui.3d_mouse_ghost_position": "Position", + "ui.3d_mouse_ghost_right_viewport": "Vue à droite", + "ui.3d_mouse_hint": "Déplace et fait pivoter la vue en même temps ; dans les esquisses, seuls le panoramique et le zoom sont utilisés.", + "ui.3d_mouse_kind_cap": "Retour au centre", + "ui.3d_mouse_kind_question": "Quel type de souris 3D est-ce ?", + "ui.3d_mouse_kind_trackball": "Trackball", + "ui.3d_mouse_looks_like_cap": "La valeur reste tant que vous maintenez, comme sur un appareil de type « Retour au centre ».", + "ui.3d_mouse_looks_like_trackball": "La valeur retombe dès que vous arrêtez de bouger, comme sur un trackball.", + "ui.3d_mouse_motion_rotation_x": "Rotation X", + "ui.3d_mouse_motion_rotation_y": "Rotation Y", + "ui.3d_mouse_motion_rotation_z": "Rotation Z", + "ui.3d_mouse_motion_translation_x": "Translation X", + "ui.3d_mouse_motion_translation_y": "Translation Y", + "ui.3d_mouse_motion_translation_z": "Translation Z", + "ui.3d_mouse_navigate": "Naviguer avec la souris 3D", + "ui.3d_mouse_none": "Aucune souris 3D trouvée", + "ui.3d_mouse_not_calibrated": "Non calibrée", + "ui.3d_mouse_offer": "Calibrer votre souris 3D corrige les axes échangés ou inversés et règle sa vitesse et sa sensation.", + "ui.3d_mouse_offer_never": "Ne plus afficher", + "ui.3d_mouse_offer_swap": "Passer à Hidraw (pilote direct)", + "ui.3d_mouse_reading": "Lecture", + "ui.3d_mouse_reset_axes": "Réinitialiser l'attribution des axes", + "ui.3d_mouse_reset_view": "Réinitialiser la vue", + "ui.3d_mouse_restore": "Rétablir les réglages de la souris 3D", + "ui.3d_mouse_restore_ask": "Remettre les réglages de la souris 3D par défaut ? Votre calibrage et l'assignation des boutons sont conservés.", + "ui.3d_mouse_section": "Souris 3D", + "ui.3d_mouse_show_in_view": "Afficher la souris 3D dans la vue 3D", + "ui.3d_mouse_show_in_view_tip": "Une image de votre souris 3D dans le coin inférieur gauche, qui bouge quand vous la bougez.", + "ui.3d_mouse_skip": "Mon appareil ne peut pas le faire", + "ui.3d_mouse_skip_tip": "Ignore ce mouvement dans les deux sens ; la calibration le laisse de côté.", "ui.3d_mouse_speed": "Vitesse de la souris 3D", + "ui.3d_mouse_step_lift": "Soulevez le capuchon, maintenez, puis relâchez.", + "ui.3d_mouse_step_press": "Appuyez sur le capuchon, maintenez, puis relâchez.", + "ui.3d_mouse_step_pull": "Tirez le capuchon vers vous, maintenez, puis relâchez.", + "ui.3d_mouse_step_push": "Poussez le capuchon loin de vous, maintenez, puis relâchez.", + "ui.3d_mouse_step_rest": "Lâchez le capuchon et laissez-le immobile.", + "ui.3d_mouse_step_rest_trackball": "Lâchez la boule et laissez-la immobile.", + "ui.3d_mouse_step_ring_clockwise": "Tournez l'anneau de défilement de quelques crans dans le sens horaire.", + "ui.3d_mouse_step_ring_counterclockwise": "Tournez l'anneau de défilement de quelques crans dans le sens antihoraire.", + "ui.3d_mouse_step_roll_away": "Faites rouler la boule loin de vous plusieurs fois, puis laissez-la s'arrêter.", + "ui.3d_mouse_step_roll_left": "Faites rouler la boule vers la gauche plusieurs fois, puis laissez-la s'arrêter.", + "ui.3d_mouse_step_roll_right": "Faites rouler la boule vers la droite plusieurs fois, puis laissez-la s'arrêter.", + "ui.3d_mouse_step_roll_toward": "Faites rouler la boule vers vous plusieurs fois, puis laissez-la s'arrêter.", + "ui.3d_mouse_step_slide_left": "Glissez le capuchon vers la gauche, maintenez, puis relâchez.", + "ui.3d_mouse_step_slide_right": "Glissez le capuchon vers la droite, maintenez, puis relâchez.", + "ui.3d_mouse_step_spin_clockwise": "Faites tourner la boule dans le sens horaire plusieurs fois, puis laissez-la s'arrêter.", + "ui.3d_mouse_step_spin_counterclockwise": "Faites tourner la boule dans le sens antihoraire plusieurs fois, puis laissez-la s'arrêter.", + "ui.3d_mouse_step_tilt_away": "Inclinez le haut du capuchon loin de vous (le bord le plus proche monte), maintenez, puis relâchez.", + "ui.3d_mouse_step_tilt_left": "Inclinez le capuchon vers la gauche (bord gauche vers le bas), maintenez, puis relâchez.", + "ui.3d_mouse_step_tilt_right": "Inclinez le capuchon vers la droite (bord droit vers le bas), maintenez, puis relâchez.", + "ui.3d_mouse_step_tilt_toward": "Inclinez le haut du capuchon vers vous (le bord le plus proche descend), maintenez, puis relâchez.", + "ui.3d_mouse_step_touch": "Posez la main sur le capuchon sans le pousser.", + "ui.3d_mouse_step_twist_clockwise": "Tournez le capuchon dans le sens horaire, maintenez, puis relâchez.", + "ui.3d_mouse_step_twist_counterclockwise": "Tournez le capuchon dans le sens antihoraire, maintenez, puis relâchez.", + "ui.3d_mouse_switch_cap": "Calibrer comme retour au centre", + "ui.3d_mouse_switch_trackball": "Calibrer comme un trackball", + "ui.3d_mouse_view_axis": "Axe de la vue", + "ui.3d_mouse_view_off": "Désactivé", + "ui.3d_mouse_view_off_tip": "Le profil du pilote de la souris 3D désactive cet axe : son mouvement ne déplace pas la vue. Choisir ici le mouvement d'un autre axe les échange, et ce mouvement cesse de déplacer la vue.", + "ui.3d_mouse_view_orbit_sideways": "Orbite gauche / droite", + "ui.3d_mouse_view_orbit_up_down": "Orbite haut / bas", + "ui.3d_mouse_view_pan_sideways": "Panoramique gauche / droite", + "ui.3d_mouse_view_pan_up_down": "Panoramique haut / bas", + "ui.3d_mouse_view_roll": "Roulis", + "ui.3d_mouse_view_unmapped": "Axe {n}", + "ui.3d_mouse_view_zoom": "Zoom", + "ui.3d_mouse_waiting": "En attente de mouvement", + "ui.3d_mouse_wiggle": "Si vous en avez une de branchée, remuez-la.", "ui.3d_sketch": "Esquisse 3D", "ui.activate": "Activer", "ui.active_component": "Composant actif", @@ -1357,6 +1462,7 @@ "ui.blocked": "Inaccessible", "ui.bodies": "Corps", "ui.bodies_all_if_none": "Corps (tous si aucun)", + "ui.bottom": "Dessous", "ui.box": "Boîte", "ui.break": "Fractionner", "ui.browser_bodies_one": "{count} corps", @@ -1800,59 +1906,6 @@ "ui.smooth_wheel_zoom": "Zoom fluide à la molette", "ui.smooth_wheel_zoom_tip": "Désactiver pour appliquer chaque cran de molette immédiatement, sans lissage", "ui.snap": "Accrochage", - "ui.space_mouse_axes_reassigned": "Les axes sont réattribués dans les Préférences, et cela s'applique toujours en plus de ce calibrage.", - "ui.space_mouse_calibrate": "Calibrer…", - "ui.space_mouse_calibrate_tip": "Déplacez le capuchon comme la fenêtre le demande, pour que SolidCraft apprenne dans quel sens et jusqu'où va chaque axe de cette souris 3D. Enregistré pour cette souris 3D.", - "ui.space_mouse_calibrate_title": "Calibrer la souris 3D", - "ui.space_mouse_calibrated": "Calibrée", - "ui.space_mouse_calibration": "Calibrage de la souris 3D", - "ui.space_mouse_calibration_done": "Terminé. Enregistrez-le pour l'utiliser chaque fois que cette souris 3D est connectée.", - "ui.space_mouse_calibration_no_response": "Un axe a à peine bougé, il ne peut donc pas être calibré. Recommencez en déplaçant le capuchon plus loin à chaque fois.", - "ui.space_mouse_dead_zone": "Zone morte", - "ui.space_mouse_dead_zone_tip": "De combien le capuchon doit bouger avant que la vue ne bouge, en part de sa course complète. Augmentez-la si la vue dérive alors que le capuchon est au repos.", - "ui.space_mouse_driven_by": "Quel mouvement calibré de l'appareil pilote cet axe. Choisir un mouvement déjà utilisé par un autre axe les échange.", - "ui.space_mouse_driven_by_label": "piloté par", - "ui.space_mouse_driver": "Pilote de la souris 3D", - "ui.space_mouse_driver_auto": "Automatique", - "ui.space_mouse_driver_direct": "Direct (hidraw)", - "ui.space_mouse_driver_only_direct": "Lecture directe du périphérique ; spacenavd n'est pas lancé", - "ui.space_mouse_driver_only_spacenavd": "Lecture via spacenavd, seule possibilité disponible", - "ui.space_mouse_forget": "Oublier le calibrage", - "ui.space_mouse_hint": "Déplace et fait pivoter la vue en même temps ; dans les esquisses, seuls le panoramique et le zoom sont utilisés.", - "ui.space_mouse_motion_rotation_x": "Rotation X", - "ui.space_mouse_motion_rotation_y": "Rotation Y", - "ui.space_mouse_motion_rotation_z": "Rotation Z", - "ui.space_mouse_motion_translation_x": "Translation X", - "ui.space_mouse_motion_translation_y": "Translation Y", - "ui.space_mouse_motion_translation_z": "Translation Z", - "ui.space_mouse_navigate": "Naviguer avec SpaceNavigator / SpaceMouse", - "ui.space_mouse_none": "Aucune souris 3D trouvée", - "ui.space_mouse_not_calibrated": "Non calibrée", - "ui.space_mouse_reading": "Lecture", - "ui.space_mouse_reset_axes": "Réinitialiser l'attribution des axes", - "ui.space_mouse_step_lift": "Soulevez le capuchon, maintenez, puis relâchez.", - "ui.space_mouse_step_press": "Appuyez sur le capuchon, maintenez, puis relâchez.", - "ui.space_mouse_step_pull": "Tirez le capuchon vers vous, maintenez, puis relâchez.", - "ui.space_mouse_step_push": "Poussez le capuchon loin de vous, maintenez, puis relâchez.", - "ui.space_mouse_step_rest": "Lâchez le capuchon et laissez-le immobile.", - "ui.space_mouse_step_slide_left": "Glissez le capuchon vers la gauche, maintenez, puis relâchez.", - "ui.space_mouse_step_slide_right": "Glissez le capuchon vers la droite, maintenez, puis relâchez.", - "ui.space_mouse_step_tilt_back": "Inclinez le capuchon vers l'arrière (bord avant vers le haut), maintenez, puis relâchez.", - "ui.space_mouse_step_tilt_forward": "Inclinez le capuchon vers l'avant (bord avant vers le bas), maintenez, puis relâchez.", - "ui.space_mouse_step_tilt_left": "Inclinez le capuchon vers la gauche (bord gauche vers le bas), maintenez, puis relâchez.", - "ui.space_mouse_step_tilt_right": "Inclinez le capuchon vers la droite (bord droit vers le bas), maintenez, puis relâchez.", - "ui.space_mouse_step_twist_clockwise": "Tournez le capuchon dans le sens horaire, maintenez, puis relâchez.", - "ui.space_mouse_step_twist_counterclockwise": "Tournez le capuchon dans le sens antihoraire, maintenez, puis relâchez.", - "ui.space_mouse_view_axis": "Axe de la vue", - "ui.space_mouse_view_off": "Désactivé", - "ui.space_mouse_view_off_tip": "Le profil du pilote de la souris 3D désactive cet axe : son mouvement ne déplace pas la vue. Choisir ici le mouvement d'un autre axe les échange, et ce mouvement cesse de déplacer la vue.", - "ui.space_mouse_view_orbit_sideways": "Orbite gauche / droite", - "ui.space_mouse_view_orbit_up_down": "Orbite haut / bas", - "ui.space_mouse_view_pan_sideways": "Panoramique gauche / droite", - "ui.space_mouse_view_pan_up_down": "Panoramique haut / bas", - "ui.space_mouse_view_roll": "Roulis", - "ui.space_mouse_view_unmapped": "Axe {n}", - "ui.space_mouse_view_zoom": "Zoom", "ui.spacing": "Espacement", "ui.spacing_2": "Espacement 2", "ui.sphere": "Sphère", diff --git a/assets/locales/gl.json b/assets/locales/gl.json index f4eeceb1df..a9c32bfb7f 100644 --- a/assets/locales/gl.json +++ b/assets/locales/gl.json @@ -530,6 +530,10 @@ "cmd.file_insert_step": "Inserir STEP", "cmd.file_save_mesh": "Gardar como malla", "cmd.file_slicers": "Laminadores instalados", + "cmd.frame_member": "Membro estrutural", + "cmd.frame_profile_insert": "Inserir perfil", + "cmd.frame_profile_list": "Biblioteca de perfís", + "cmd.frame_profile_properties": "Propiedades do perfil", "cmd.inspect_accessibility": "Análise de accesibilidade", "cmd.inspect_center_of_mass": "Centro de masas", "cmd.inspect_clearance": "Folga", @@ -1121,7 +1125,106 @@ "trace.stopped_by_contact": "Remata na mostra {sample}: {a} atravesaría {b}", "trace.stopped_by_loop": "Remata na mostra {sample}: o bucle a través de {joint} non pode pecharse máis", "ui.1_degree_of_freedom": "1 grao de liberdade", + "ui.3d_mouse_axes_reassigned": "Os eixes están reasignados en Preferencias, e iso segue aplicándose sobre esta calibración.", + "ui.3d_mouse_button": "Botón {number}", + "ui.3d_mouse_button_nothing": "Nada", + "ui.3d_mouse_button_settings": "Axustes do rato 3D", + "ui.3d_mouse_buttons": "Botóns", + "ui.3d_mouse_buttons_hint": "Preme un botón do teu rato 3D para atopalo aquí e escolle que fai.", + "ui.3d_mouse_buttons_no_device": "Conecta un rato 3D para escoller que fan os seus botóns.", + "ui.3d_mouse_buttons_reset": "Restaurar os botóns predeterminados", + "ui.3d_mouse_calibrate": "Calibrar…", + "ui.3d_mouse_calibrate_tip": "Move o capuchón como pide a xanela para que SolidCraft aprenda cara a onde e ata onde vai cada eixe deste rato 3D. Gárdase para este rato 3D.", + "ui.3d_mouse_calibrate_title": "Calibrar o rato 3D", + "ui.3d_mouse_calibrate_wait": "Move o rato 3D unha vez para que SolidCraft o vexa.", + "ui.3d_mouse_calibrated": "Calibrado", + "ui.3d_mouse_calibration": "Calibración do rato 3D", + "ui.3d_mouse_calibration_done": "Feito. Gárdaa para usala sempre que este rato 3D estea conectado.", + "ui.3d_mouse_calibration_no_response": "Un eixe case non se moveu, así que non se pode calibrar. Volve comezar e move o capuchón máis lonxe cada vez.", + "ui.3d_mouse_circles_help": "Os círculos mostran o que envía o dispositivo tras a calibración. O modelo de proba mostra o que fai a vista con iso, tras a inversión, a velocidade e a asignación de eixes.", + "ui.3d_mouse_dead_zone": "Zona morta", + "ui.3d_mouse_dead_zone_tip": "Canto ten que moverse o capuchón antes de que se mova a vista, como parte do seu percorrido completo. Auméntaa se a vista se despraza co capuchón en repouso.", + "ui.3d_mouse_denied": "SolidCraft ve un rato 3D, pero non ten permiso para lelo. A regra udev está nas notas de configuración do rato 3D.", + "ui.3d_mouse_denied_short": "Atopado, pero non se pode ler", + "ui.3d_mouse_driven_by": "Que movemento calibrado do dispositivo controla este eixe. Se escolles un que xa usa outro eixe, intercámbianse.", + "ui.3d_mouse_driven_by_label": "controlado por", + "ui.3d_mouse_driver": "Controlador do rato 3D", + "ui.3d_mouse_driver_auto": "Automático", + "ui.3d_mouse_driver_direct": "Directo (hidraw)", + "ui.3d_mouse_driver_fallback": "Lendo polo de agora a través de spacenavd: non se puido abrir o dispositivo.", + "ui.3d_mouse_driver_only_direct": "Lectura directa do dispositivo; spacenavd non está en execución", + "ui.3d_mouse_driver_only_spacenavd": "Lectura mediante spacenavd, a única opción dispoñible", + "ui.3d_mouse_forget": "Esquecer a calibración", + "ui.3d_mouse_ghost_left_viewport": "Vista esquerda", + "ui.3d_mouse_ghost_left_window": "Xanela esquerda", + "ui.3d_mouse_ghost_position": "Posición", + "ui.3d_mouse_ghost_right_viewport": "Vista dereita", + "ui.3d_mouse_hint": "Move e rota a vista á vez; os bosquexos só usan desprazamento e zoom.", + "ui.3d_mouse_kind_cap": "Retorno ao centro", + "ui.3d_mouse_kind_question": "Que tipo de rato 3D é?", + "ui.3d_mouse_kind_trackball": "Trackball", + "ui.3d_mouse_looks_like_cap": "A lectura mantense mentres a suxeitas, como nun dispositivo de tipo «Retorno ao centro».", + "ui.3d_mouse_looks_like_trackball": "A lectura volve atrás en canto deixas de moverte, como nun trackball.", + "ui.3d_mouse_motion_rotation_x": "Rotación X", + "ui.3d_mouse_motion_rotation_y": "Rotación Y", + "ui.3d_mouse_motion_rotation_z": "Rotación Z", + "ui.3d_mouse_motion_translation_x": "Translación X", + "ui.3d_mouse_motion_translation_y": "Translación Y", + "ui.3d_mouse_motion_translation_z": "Translación Z", + "ui.3d_mouse_navigate": "Navegar co rato 3D", + "ui.3d_mouse_none": "Non se atopou ningún rato 3D", + "ui.3d_mouse_not_calibrated": "Sen calibrar", + "ui.3d_mouse_offer": "Calibrar o teu rato 3D corrixe os eixes trocados ou invertidos e axusta a súa velocidade e sensación.", + "ui.3d_mouse_offer_never": "Non mostrar de novo", + "ui.3d_mouse_offer_swap": "Cambiar a Hidraw (controlador directo)", + "ui.3d_mouse_reading": "Lectura", + "ui.3d_mouse_reset_axes": "Restablecer asignación de eixes", + "ui.3d_mouse_reset_view": "Restablecer vista", + "ui.3d_mouse_restore": "Restaurar valores do rato 3D", + "ui.3d_mouse_restore_ask": "Volver aos valores predeterminados da configuración do rato 3D? A calibración e a asignación de botóns consérvanse.", + "ui.3d_mouse_section": "Rato 3D", + "ui.3d_mouse_show_in_view": "Mostrar o rato 3D na vista 3D", + "ui.3d_mouse_show_in_view_tip": "Unha imaxe do teu rato 3D na esquina inferior esquerda que se move cando o moves.", + "ui.3d_mouse_skip": "O meu dispositivo non fai isto", + "ui.3d_mouse_skip_tip": "Omite este movemento nos dous sentidos; a calibración déixao fóra.", "ui.3d_mouse_speed": "Velocidade do rato 3D", + "ui.3d_mouse_step_lift": "Levanta o capuchón, mantén a posición e despois sóltao.", + "ui.3d_mouse_step_press": "Preme o capuchón cara abaixo, mantén a posición e despois sóltao.", + "ui.3d_mouse_step_pull": "Tira do capuchón cara a ti, mantén a posición e despois sóltao.", + "ui.3d_mouse_step_push": "Empurra o capuchón lonxe de ti, mantén a posición e despois sóltao.", + "ui.3d_mouse_step_rest": "Solta o capuchón e déixao quieto.", + "ui.3d_mouse_step_rest_trackball": "Solta a bóla e déixaa quieta.", + "ui.3d_mouse_step_ring_clockwise": "Xira o anel de desprazamento uns clics no sentido horario.", + "ui.3d_mouse_step_ring_counterclockwise": "Xira o anel de desprazamento uns clics no sentido antihorario.", + "ui.3d_mouse_step_roll_away": "Fai rodar a bóla lonxe de ti unhas cantas veces e déixaa parar.", + "ui.3d_mouse_step_roll_left": "Fai rodar a bóla á esquerda unhas cantas veces e déixaa parar.", + "ui.3d_mouse_step_roll_right": "Fai rodar a bóla á dereita unhas cantas veces e déixaa parar.", + "ui.3d_mouse_step_roll_toward": "Fai rodar a bóla cara a ti unhas cantas veces e déixaa parar.", + "ui.3d_mouse_step_slide_left": "Despraza o capuchón á esquerda, mantén a posición e despois sóltao.", + "ui.3d_mouse_step_slide_right": "Despraza o capuchón á dereita, mantén a posición e despois sóltao.", + "ui.3d_mouse_step_spin_clockwise": "Xira a bóla no sentido horario unhas cantas veces e déixaa parar.", + "ui.3d_mouse_step_spin_counterclockwise": "Xira a bóla no sentido antihorario unhas cantas veces e déixaa parar.", + "ui.3d_mouse_step_tilt_away": "Inclina a parte de arriba do capuchón lonxe de ti (o bordo máis próximo sobe), mantén a posición e despois sóltao.", + "ui.3d_mouse_step_tilt_left": "Inclina o capuchón á esquerda (bordo esquerdo abaixo), mantén a posición e despois sóltao.", + "ui.3d_mouse_step_tilt_right": "Inclina o capuchón á dereita (bordo dereito abaixo), mantén a posición e despois sóltao.", + "ui.3d_mouse_step_tilt_toward": "Inclina a parte de arriba do capuchón cara a ti (o bordo máis próximo baixa), mantén a posición e despois sóltao.", + "ui.3d_mouse_step_touch": "Apoia a man no capuchón sen empurralo.", + "ui.3d_mouse_step_twist_clockwise": "Xira o capuchón no sentido horario, mantén a posición e despois sóltao.", + "ui.3d_mouse_step_twist_counterclockwise": "Xira o capuchón no sentido antihorario, mantén a posición e despois sóltao.", + "ui.3d_mouse_switch_cap": "Calibrar como retorno ao centro", + "ui.3d_mouse_switch_trackball": "Calibrar como trackball", + "ui.3d_mouse_view_axis": "Eixe da vista", + "ui.3d_mouse_view_off": "Desactivado", + "ui.3d_mouse_view_off_tip": "O perfil do controlador do rato 3D desactiva este eixe, así que o seu movemento non move a vista. Se escolles aquí o movemento doutro eixe, intercámbianse e ese movemento deixa de mover a vista.", + "ui.3d_mouse_view_orbit_sideways": "Orbitar á esquerda / dereita", + "ui.3d_mouse_view_orbit_up_down": "Orbitar arriba / abaixo", + "ui.3d_mouse_view_pan_sideways": "Desprazar á esquerda / dereita", + "ui.3d_mouse_view_pan_up_down": "Desprazar arriba / abaixo", + "ui.3d_mouse_view_roll": "Xirar sobre a vista", + "ui.3d_mouse_view_unmapped": "Eixe {n}", + "ui.3d_mouse_view_zoom": "Zoom", + "ui.3d_mouse_waiting": "Agardando movemento", + "ui.3d_mouse_wiggle": "Se tes un conectado, móveo un pouco.", "ui.3d_sketch": "Bosquexo 3D", "ui.activate": "Activar", "ui.active_component": "Compoñente activo", @@ -1351,6 +1454,7 @@ "ui.blocked": "Bloqueado", "ui.bodies": "Corpos", "ui.bodies_all_if_none": "Corpos (todos se non hai ningún)", + "ui.bottom": "Inferior", "ui.box": "Caixa", "ui.break": "Romper", "ui.browser_bodies_one": "{count} corpo", @@ -1794,59 +1898,6 @@ "ui.smooth_wheel_zoom": "Zoom suave coa roda", "ui.smooth_wheel_zoom_tip": "Desactívao para aplicar cada movemento da roda de inmediato, sen suavizado", "ui.snap": "Axuste", - "ui.space_mouse_axes_reassigned": "Os eixes están reasignados en Preferencias, e iso segue aplicándose sobre esta calibración.", - "ui.space_mouse_calibrate": "Calibrar…", - "ui.space_mouse_calibrate_tip": "Move o capuchón como pide a xanela para que SolidCraft aprenda cara a onde e ata onde vai cada eixe deste rato 3D. Gárdase para este rato 3D.", - "ui.space_mouse_calibrate_title": "Calibrar o rato 3D", - "ui.space_mouse_calibrated": "Calibrado", - "ui.space_mouse_calibration": "Calibración do rato 3D", - "ui.space_mouse_calibration_done": "Feito. Gárdaa para usala sempre que este rato 3D estea conectado.", - "ui.space_mouse_calibration_no_response": "Un eixe case non se moveu, así que non se pode calibrar. Volve comezar e move o capuchón máis lonxe cada vez.", - "ui.space_mouse_dead_zone": "Zona morta", - "ui.space_mouse_dead_zone_tip": "Canto ten que moverse o capuchón antes de que se mova a vista, como parte do seu percorrido completo. Auméntaa se a vista se despraza co capuchón en repouso.", - "ui.space_mouse_driven_by": "Que movemento calibrado do dispositivo controla este eixe. Se escolles un que xa usa outro eixe, intercámbianse.", - "ui.space_mouse_driven_by_label": "controlado por", - "ui.space_mouse_driver": "Controlador do rato 3D", - "ui.space_mouse_driver_auto": "Automático", - "ui.space_mouse_driver_direct": "Directo (hidraw)", - "ui.space_mouse_driver_only_direct": "Lectura directa do dispositivo; spacenavd non está en execución", - "ui.space_mouse_driver_only_spacenavd": "Lectura mediante spacenavd, a única opción dispoñible", - "ui.space_mouse_forget": "Esquecer a calibración", - "ui.space_mouse_hint": "Move e rota a vista á vez; os bosquexos só usan desprazamento e zoom.", - "ui.space_mouse_motion_rotation_x": "Rotación X", - "ui.space_mouse_motion_rotation_y": "Rotación Y", - "ui.space_mouse_motion_rotation_z": "Rotación Z", - "ui.space_mouse_motion_translation_x": "Translación X", - "ui.space_mouse_motion_translation_y": "Translación Y", - "ui.space_mouse_motion_translation_z": "Translación Z", - "ui.space_mouse_navigate": "Navegar con SpaceNavigator / SpaceMouse", - "ui.space_mouse_none": "Non se atopou ningún rato 3D", - "ui.space_mouse_not_calibrated": "Sen calibrar", - "ui.space_mouse_reading": "Lectura", - "ui.space_mouse_reset_axes": "Restablecer asignación de eixes", - "ui.space_mouse_step_lift": "Levanta o capuchón, mantéño e sóltao.", - "ui.space_mouse_step_press": "Preme o capuchón cara abaixo, mantéño e sóltao.", - "ui.space_mouse_step_pull": "Tira do capuchón cara a ti, mantéño e sóltao.", - "ui.space_mouse_step_push": "Empurra o capuchón lonxe de ti, mantéño e sóltao.", - "ui.space_mouse_step_rest": "Solta o capuchón e déixao quieto.", - "ui.space_mouse_step_slide_left": "Despraza o capuchón á esquerda, mantéño e sóltao.", - "ui.space_mouse_step_slide_right": "Despraza o capuchón á dereita, mantéño e sóltao.", - "ui.space_mouse_step_tilt_back": "Inclina o capuchón cara atrás (bordo dianteiro arriba), mantéño e sóltao.", - "ui.space_mouse_step_tilt_forward": "Inclina o capuchón cara adiante (bordo dianteiro abaixo), mantéño e sóltao.", - "ui.space_mouse_step_tilt_left": "Inclina o capuchón á esquerda (bordo esquerdo abaixo), mantéño e sóltao.", - "ui.space_mouse_step_tilt_right": "Inclina o capuchón á dereita (bordo dereito abaixo), mantéño e sóltao.", - "ui.space_mouse_step_twist_clockwise": "Xira o capuchón no sentido horario, mantéño e sóltao.", - "ui.space_mouse_step_twist_counterclockwise": "Xira o capuchón no sentido antihorario, mantéño e sóltao.", - "ui.space_mouse_view_axis": "Eixe da vista", - "ui.space_mouse_view_off": "Desactivado", - "ui.space_mouse_view_off_tip": "O perfil do controlador do rato 3D desactiva este eixe, así que o seu movemento non move a vista. Se escolles aquí o movemento doutro eixe, intercámbianse e ese movemento deixa de mover a vista.", - "ui.space_mouse_view_orbit_sideways": "Orbitar á esquerda / dereita", - "ui.space_mouse_view_orbit_up_down": "Orbitar arriba / abaixo", - "ui.space_mouse_view_pan_sideways": "Desprazar á esquerda / dereita", - "ui.space_mouse_view_pan_up_down": "Desprazar arriba / abaixo", - "ui.space_mouse_view_roll": "Xirar sobre a vista", - "ui.space_mouse_view_unmapped": "Eixe {n}", - "ui.space_mouse_view_zoom": "Zoom", "ui.spacing": "Espazado", "ui.spacing_2": "Espazado 2", "ui.sphere": "Esfera", diff --git a/assets/locales/hu.json b/assets/locales/hu.json index c89d9d0bb8..b17debb8d2 100644 --- a/assets/locales/hu.json +++ b/assets/locales/hu.json @@ -529,6 +529,10 @@ "cmd.file_insert_step": "STEP beszúrása", "cmd.file_save_mesh": "Mentés hálóként", "cmd.file_slicers": "Telepített szeletelők", + "cmd.frame_member": "Szerkezeti elem", + "cmd.frame_profile_insert": "Profil beszúrása", + "cmd.frame_profile_list": "Profilkönyvtár", + "cmd.frame_profile_properties": "Profil tulajdonságai", "cmd.inspect_accessibility": "Hozzáférhetőség-elemzés", "cmd.inspect_center_of_mass": "Tömegközéppont", "cmd.inspect_clearance": "Hézag", @@ -1120,7 +1124,50 @@ "trace.stopped_by_contact": "Vége a(z) {sample}. mintánál: {a} áthatolna ezen: {b}", "trace.stopped_by_loop": "Vége a(z) {sample}. mintánál: a(z) {joint} csuklón átmenő hurok nem zárható tovább", "ui.1_degree_of_freedom": "1 szabadságfok", + "ui.3d_mouse_calibrate": "Kalibrálás…", + "ui.3d_mouse_calibrate_tip": "Mozgassa a kupakot az ablak kérése szerint, hogy a SolidCraft megtanulja, merre és mennyire mozdul a 3D-egér mindegyik tengelye. Ehhez a 3D-egérhez mentve.", + "ui.3d_mouse_calibrate_title": "3D-egér kalibrálása", + "ui.3d_mouse_calibrate_wait": "Mozdítsa meg egyszer a 3D-egeret, hogy a SolidCraft lássa.", + "ui.3d_mouse_calibrated": "Kalibrálva", + "ui.3d_mouse_calibration": "3D-egér kalibrálása", + "ui.3d_mouse_calibration_done": "Kész. Mentse, hogy a 3D-egér csatlakoztatásakor mindig használatos legyen.", + "ui.3d_mouse_calibration_no_response": "Az egyik tengely alig mozdult, így nem kalibrálható. Kezdje elölről, és minden alkalommal mozgassa a kupakot messzebb.", + "ui.3d_mouse_circles_help": "A körök azt mutatják, mit küld az eszköz kalibrálás után. A tesztmodell azt mutatja, mit tesz ezzel a nézet a megfordítás, sebesség és tengelykiosztás után.", + "ui.3d_mouse_dead_zone": "Holtsáv", + "ui.3d_mouse_dead_zone_tip": "Mennyit kell mozdítani a kupakot, mielőtt a nézet mozdulna, a teljes elmozdulás arányában. Növelje, ha a nézet sodródik, miközben a kupak nyugalomban van.", + "ui.3d_mouse_denied": "A SolidCraft látja a 3D-egeret, de nem olvashatja. Az udev-szabályt a 3D-egér beállítási jegyzetei tartalmazzák.", + "ui.3d_mouse_denied_short": "Megvan, de nem olvasható", + "ui.3d_mouse_driver_fallback": "Egyelőre a spacenavd-n át olvassa: magát az eszközt nem sikerült megnyitni.", + "ui.3d_mouse_forget": "Kalibrálás elfelejtése", + "ui.3d_mouse_ghost_left_viewport": "Bal nézet", + "ui.3d_mouse_ghost_left_window": "Bal ablak", + "ui.3d_mouse_ghost_position": "Helyzet", + "ui.3d_mouse_ghost_right_viewport": "Jobb nézet", + "ui.3d_mouse_hint": "A nézetet együtt mozgatja és forgatja; a vázlatok csak pásztázást és nagyítást használnak.", + "ui.3d_mouse_navigate": "Navigálás a 3D-egérrel", + "ui.3d_mouse_none": "Nem található 3D-egér", + "ui.3d_mouse_not_calibrated": "Nincs kalibrálva", + "ui.3d_mouse_offer": "A 3D-egér kalibrálása kijavítja a felcserélt vagy megfordított tengelyeket, és beállítja a sebességét és az érzetét.", + "ui.3d_mouse_offer_never": "Ne jelenjen meg többé", + "ui.3d_mouse_offer_swap": "Váltás Hidrawra (közvetlen illesztőprogram)", + "ui.3d_mouse_reading": "Beolvasás", + "ui.3d_mouse_reset_view": "Nézet alaphelyzetbe állítása", + "ui.3d_mouse_restore": "3D-egér alapértékeinek visszaállítása", + "ui.3d_mouse_restore_ask": "Visszaállítja a 3D-egér beállításait az alapértékekre? A kalibrálás és a gombkiosztás megmarad.", "ui.3d_mouse_speed": "3D-egér sebessége", + "ui.3d_mouse_step_lift": "Emelje fel a kupakot, tartsa, majd engedje el.", + "ui.3d_mouse_step_press": "Nyomja le a kupakot, tartsa, majd engedje el.", + "ui.3d_mouse_step_pull": "Húzza a kupakot maga felé, tartsa, majd engedje el.", + "ui.3d_mouse_step_push": "Tolja a kupakot magától el, tartsa, majd engedje el.", + "ui.3d_mouse_step_rest": "Vegye le a kezét a kupakról, és hagyja nyugodtan.", + "ui.3d_mouse_step_slide_left": "Csúsztassa a kupakot balra, tartsa, majd engedje el.", + "ui.3d_mouse_step_slide_right": "Csúsztassa a kupakot jobbra, tartsa, majd engedje el.", + "ui.3d_mouse_step_tilt_left": "Billentse balra a kupakot (a bal él lefelé), tartsa, majd engedje el.", + "ui.3d_mouse_step_tilt_right": "Billentse jobbra a kupakot (a jobb él lefelé), tartsa, majd engedje el.", + "ui.3d_mouse_step_twist_clockwise": "Csavarja el a kupakot az óramutató járásával megegyezően, tartsa, majd engedje el.", + "ui.3d_mouse_step_twist_counterclockwise": "Csavarja el a kupakot az óramutató járásával ellentétesen, tartsa, majd engedje el.", + "ui.3d_mouse_waiting": "Mozgásra vár", + "ui.3d_mouse_wiggle": "Ha van csatlakoztatva egy, mozgassa meg egy kicsit.", "ui.3d_sketch": "3D vázlat", "ui.activate": "Aktiválás", "ui.active_component": "Aktív komponens", @@ -1793,34 +1840,6 @@ "ui.smooth_wheel_zoom": "Sima görgőnagyítás", "ui.smooth_wheel_zoom_tip": "Kapcsolja ki az egyes görgőesemények azonnali alkalmazásához, kifutás nélkül", "ui.snap": "Illesztés", - "ui.space_mouse_calibrate": "Kalibrálás…", - "ui.space_mouse_calibrate_tip": "Mozgassa a kupakot az ablak kérése szerint, hogy a SolidCraft megtanulja, merre és mennyire mozdul a 3D-egér mindegyik tengelye. Ehhez a 3D-egérhez mentve.", - "ui.space_mouse_calibrate_title": "3D-egér kalibrálása", - "ui.space_mouse_calibrated": "Kalibrálva", - "ui.space_mouse_calibration": "3D-egér kalibrálása", - "ui.space_mouse_calibration_done": "Kész. Mentse, hogy a 3D-egér csatlakoztatásakor mindig használatos legyen.", - "ui.space_mouse_calibration_no_response": "Az egyik tengely alig mozdult, így nem kalibrálható. Kezdje elölről, és minden alkalommal mozgassa a kupakot messzebb.", - "ui.space_mouse_dead_zone": "Holtsáv", - "ui.space_mouse_dead_zone_tip": "Mennyit kell mozdítani a kupakot, mielőtt a nézet mozdulna, a teljes elmozdulás arányában. Növelje, ha a nézet sodródik, miközben a kupak nyugalomban van.", - "ui.space_mouse_forget": "Kalibrálás elfelejtése", - "ui.space_mouse_hint": "A nézetet együtt mozgatja és forgatja; a vázlatok csak pásztázást és nagyítást használnak.", - "ui.space_mouse_navigate": "Navigálás SpaceNavigator / SpaceMouse eszközzel", - "ui.space_mouse_none": "Nem található 3D-egér", - "ui.space_mouse_not_calibrated": "Nincs kalibrálva", - "ui.space_mouse_reading": "Beolvasás", - "ui.space_mouse_step_lift": "Emelje fel a kupakot, tartsa, majd engedje el.", - "ui.space_mouse_step_press": "Nyomja le a kupakot, tartsa, majd engedje el.", - "ui.space_mouse_step_pull": "Húzza a kupakot maga felé, tartsa, majd engedje el.", - "ui.space_mouse_step_push": "Tolja a kupakot magától el, tartsa, majd engedje el.", - "ui.space_mouse_step_rest": "Vegye le a kezét a kupakról, és hagyja nyugodtan.", - "ui.space_mouse_step_slide_left": "Csúsztassa a kupakot balra, tartsa, majd engedje el.", - "ui.space_mouse_step_slide_right": "Csúsztassa a kupakot jobbra, tartsa, majd engedje el.", - "ui.space_mouse_step_tilt_back": "Billentse hátra a kupakot (az elülső él felfelé), tartsa, majd engedje el.", - "ui.space_mouse_step_tilt_forward": "Billentse előre a kupakot (az elülső él lefelé), tartsa, majd engedje el.", - "ui.space_mouse_step_tilt_left": "Billentse balra a kupakot (a bal él lefelé), tartsa, majd engedje el.", - "ui.space_mouse_step_tilt_right": "Billentse jobbra a kupakot (a jobb él lefelé), tartsa, majd engedje el.", - "ui.space_mouse_step_twist_clockwise": "Csavarja el a kupakot az óramutató járásával megegyezően, tartsa, majd engedje el.", - "ui.space_mouse_step_twist_counterclockwise": "Csavarja el a kupakot az óramutató járásával ellentétesen, tartsa, majd engedje el.", "ui.spacing": "Térköz", "ui.spacing_2": "2. térköz", "ui.sphere": "Gömb", diff --git a/assets/locales/id.json b/assets/locales/id.json index 0773a42a70..95c0f23093 100644 --- a/assets/locales/id.json +++ b/assets/locales/id.json @@ -529,6 +529,10 @@ "cmd.file_insert_step": "Sisipkan STEP", "cmd.file_save_mesh": "Simpan Sebagai Mesh", "cmd.file_slicers": "Slicer Terpasang", + "cmd.frame_member": "Batang Rangka", + "cmd.frame_profile_insert": "Sisipkan Profil", + "cmd.frame_profile_list": "Pustaka Profil", + "cmd.frame_profile_properties": "Properti Profil", "cmd.inspect_accessibility": "Analisis Aksesibilitas", "cmd.inspect_center_of_mass": "Pusat Massa", "cmd.inspect_clearance": "Kelonggaran", @@ -1120,7 +1124,50 @@ "trace.stopped_by_contact": "Berakhir di sampel {sample}: {a} akan menembus {b}", "trace.stopped_by_loop": "Berakhir di sampel {sample}: loop melalui {joint} tidak dapat menutup lebih jauh", "ui.1_degree_of_freedom": "1 derajat kebebasan", + "ui.3d_mouse_calibrate": "Kalibrasi…", + "ui.3d_mouse_calibrate_tip": "Gerakkan cap sesuai permintaan jendela, agar SolidCraft mempelajari arah dan jarak setiap sumbu mouse 3D ini. Disimpan untuk mouse 3D ini.", + "ui.3d_mouse_calibrate_title": "Kalibrasi mouse 3D", + "ui.3d_mouse_calibrate_wait": "Gerakkan mouse 3D sekali agar SolidCraft dapat melihatnya.", + "ui.3d_mouse_calibrated": "Terkalibrasi", + "ui.3d_mouse_calibration": "Kalibrasi mouse 3D", + "ui.3d_mouse_calibration_done": "Selesai. Simpan untuk dipakai setiap kali mouse 3D ini terhubung.", + "ui.3d_mouse_calibration_no_response": "Satu sumbu hampir tidak bergerak, sehingga tidak dapat dikalibrasi. Mulai ulang dan gerakkan cap lebih jauh setiap kali.", + "ui.3d_mouse_circles_help": "Lingkaran menunjukkan apa yang dikirim perangkat setelah kalibrasi. Model uji menunjukkan apa yang dilakukan tampilan dengannya, setelah pembalikan, kecepatan, dan penetapan sumbu.", + "ui.3d_mouse_dead_zone": "Zona mati", + "ui.3d_mouse_dead_zone_tip": "Seberapa jauh cap harus bergerak sebelum tampilan bergerak, sebagai bagian dari total jangkauannya. Naikkan jika tampilan bergeser saat cap diam.", + "ui.3d_mouse_denied": "SolidCraft melihat mouse 3D tetapi tidak diizinkan membacanya. Lihat catatan penyiapan mouse 3D untuk aturan udev.", + "ui.3d_mouse_denied_short": "Ditemukan, tetapi tidak bisa dibaca", + "ui.3d_mouse_driver_fallback": "Sementara dibaca lewat spacenavd: perangkatnya sendiri tidak dapat dibuka.", + "ui.3d_mouse_forget": "Lupakan kalibrasi", + "ui.3d_mouse_ghost_left_viewport": "Viewport kiri", + "ui.3d_mouse_ghost_left_window": "Jendela kiri", + "ui.3d_mouse_ghost_position": "Posisi", + "ui.3d_mouse_ghost_right_viewport": "Viewport kanan", + "ui.3d_mouse_hint": "Gerakkan dan putar tampilan bersama; sketsa hanya memakai pan dan zoom.", + "ui.3d_mouse_navigate": "Navigasi dengan mouse 3D", + "ui.3d_mouse_none": "Mouse 3D tidak ditemukan", + "ui.3d_mouse_not_calibrated": "Belum dikalibrasi", + "ui.3d_mouse_offer": "Mengkalibrasi mouse 3D memperbaiki sumbu yang tertukar atau terbalik dan mengatur kecepatan serta rasanya.", + "ui.3d_mouse_offer_never": "Jangan tampilkan lagi", + "ui.3d_mouse_offer_swap": "Beralih ke Hidraw (driver langsung)", + "ui.3d_mouse_reading": "Pembacaan", + "ui.3d_mouse_reset_view": "Atur ulang tampilan", + "ui.3d_mouse_restore": "Pulihkan bawaan mouse 3D", + "ui.3d_mouse_restore_ask": "Kembalikan pengaturan mouse 3D ke bawaan? Kalibrasi dan pemetaan tombol tetap ada.", "ui.3d_mouse_speed": "Kecepatan mouse 3D", + "ui.3d_mouse_step_lift": "Angkat cap ke atas, tahan, lalu lepaskan.", + "ui.3d_mouse_step_press": "Tekan cap ke bawah, tahan, lalu lepaskan.", + "ui.3d_mouse_step_pull": "Tarik cap ke arah Anda, tahan, lalu lepaskan.", + "ui.3d_mouse_step_push": "Dorong cap menjauh dari Anda, tahan, lalu lepaskan.", + "ui.3d_mouse_step_rest": "Lepaskan tangan dari cap dan biarkan diam.", + "ui.3d_mouse_step_slide_left": "Geser cap ke kiri, tahan, lalu lepaskan.", + "ui.3d_mouse_step_slide_right": "Geser cap ke kanan, tahan, lalu lepaskan.", + "ui.3d_mouse_step_tilt_left": "Miringkan cap ke kiri (tepi kiri turun), tahan, lalu lepaskan.", + "ui.3d_mouse_step_tilt_right": "Miringkan cap ke kanan (tepi kanan turun), tahan, lalu lepaskan.", + "ui.3d_mouse_step_twist_clockwise": "Putar cap searah jarum jam, tahan, lalu lepaskan.", + "ui.3d_mouse_step_twist_counterclockwise": "Putar cap berlawanan arah jarum jam, tahan, lalu lepaskan.", + "ui.3d_mouse_waiting": "Menunggu gerakan", + "ui.3d_mouse_wiggle": "Jika Anda memasang satu, goyangkan sedikit.", "ui.3d_sketch": "Sketsa 3D", "ui.activate": "Aktifkan", "ui.active_component": "Komponen aktif", @@ -1793,34 +1840,6 @@ "ui.smooth_wheel_zoom": "Zoom roda halus", "ui.smooth_wheel_zoom_tip": "Matikan untuk menerapkan setiap peristiwa roda segera, tanpa ekor penghalusan", "ui.snap": "Snap", - "ui.space_mouse_calibrate": "Kalibrasi…", - "ui.space_mouse_calibrate_tip": "Gerakkan cap sesuai permintaan jendela, agar SolidCraft mempelajari arah dan jarak setiap sumbu mouse 3D ini. Disimpan untuk mouse 3D ini.", - "ui.space_mouse_calibrate_title": "Kalibrasi mouse 3D", - "ui.space_mouse_calibrated": "Terkalibrasi", - "ui.space_mouse_calibration": "Kalibrasi mouse 3D", - "ui.space_mouse_calibration_done": "Selesai. Simpan untuk dipakai setiap kali mouse 3D ini terhubung.", - "ui.space_mouse_calibration_no_response": "Satu sumbu hampir tidak bergerak, sehingga tidak dapat dikalibrasi. Mulai ulang dan gerakkan cap lebih jauh setiap kali.", - "ui.space_mouse_dead_zone": "Zona mati", - "ui.space_mouse_dead_zone_tip": "Seberapa jauh cap harus bergerak sebelum tampilan bergerak, sebagai bagian dari total jangkauannya. Naikkan jika tampilan bergeser saat cap diam.", - "ui.space_mouse_forget": "Lupakan kalibrasi", - "ui.space_mouse_hint": "Gerakkan dan putar tampilan bersama; sketsa hanya memakai pan dan zoom.", - "ui.space_mouse_navigate": "Navigasi dengan SpaceNavigator / SpaceMouse", - "ui.space_mouse_none": "Mouse 3D tidak ditemukan", - "ui.space_mouse_not_calibrated": "Belum dikalibrasi", - "ui.space_mouse_reading": "Pembacaan", - "ui.space_mouse_step_lift": "Angkat cap ke atas, tahan, lalu lepaskan.", - "ui.space_mouse_step_press": "Tekan cap ke bawah, tahan, lalu lepaskan.", - "ui.space_mouse_step_pull": "Tarik cap ke arah Anda, tahan, lalu lepaskan.", - "ui.space_mouse_step_push": "Dorong cap menjauh dari Anda, tahan, lalu lepaskan.", - "ui.space_mouse_step_rest": "Lepaskan tangan dari cap dan biarkan diam.", - "ui.space_mouse_step_slide_left": "Geser cap ke kiri, tahan, lalu lepaskan.", - "ui.space_mouse_step_slide_right": "Geser cap ke kanan, tahan, lalu lepaskan.", - "ui.space_mouse_step_tilt_back": "Miringkan cap ke belakang (tepi depan naik), tahan, lalu lepaskan.", - "ui.space_mouse_step_tilt_forward": "Miringkan cap ke depan (tepi depan turun), tahan, lalu lepaskan.", - "ui.space_mouse_step_tilt_left": "Miringkan cap ke kiri (tepi kiri turun), tahan, lalu lepaskan.", - "ui.space_mouse_step_tilt_right": "Miringkan cap ke kanan (tepi kanan turun), tahan, lalu lepaskan.", - "ui.space_mouse_step_twist_clockwise": "Putar cap searah jarum jam, tahan, lalu lepaskan.", - "ui.space_mouse_step_twist_counterclockwise": "Putar cap berlawanan arah jarum jam, tahan, lalu lepaskan.", "ui.spacing": "Jarak antar", "ui.spacing_2": "Jarak antar 2", "ui.sphere": "Bola", diff --git a/assets/locales/it.json b/assets/locales/it.json index 92bca1f1f2..c24d862abd 100644 --- a/assets/locales/it.json +++ b/assets/locales/it.json @@ -533,6 +533,10 @@ "cmd.file_insert_step": "Inserisci STEP", "cmd.file_save_mesh": "Salva come mesh", "cmd.file_slicers": "Slicer installati", + "cmd.frame_member": "Membro strutturale", + "cmd.frame_profile_insert": "Inserisci profilo", + "cmd.frame_profile_list": "Libreria profili", + "cmd.frame_profile_properties": "Proprietà del profilo", "cmd.inspect_accessibility": "Analisi di accessibilità", "cmd.inspect_center_of_mass": "Centro di massa", "cmd.inspect_clearance": "Distanza libera", @@ -1124,7 +1128,106 @@ "trace.stopped_by_contact": "Termina al campione {sample}: {a} attraverserebbe {b}", "trace.stopped_by_loop": "Termina al campione {sample}: la catena chiusa che passa per {joint} non può chiudersi ulteriormente", "ui.1_degree_of_freedom": "1 grado di libertà", + "ui.3d_mouse_axes_reassigned": "Gli assi sono riassegnati nelle Preferenze, e questo vale anche dopo questa calibrazione.", + "ui.3d_mouse_button": "Pulsante {number}", + "ui.3d_mouse_button_nothing": "Niente", + "ui.3d_mouse_button_settings": "Impostazioni del mouse 3D", + "ui.3d_mouse_buttons": "Pulsanti", + "ui.3d_mouse_buttons_hint": "Premi un pulsante del tuo mouse 3D per trovarlo qui, poi scegli cosa fa.", + "ui.3d_mouse_buttons_no_device": "Collega un mouse 3D per scegliere cosa fanno i suoi pulsanti.", + "ui.3d_mouse_buttons_reset": "Ripristina i pulsanti predefiniti", + "ui.3d_mouse_calibrate": "Calibra…", + "ui.3d_mouse_calibrate_tip": "Muovi la manopola come chiede la finestra, così SolidCraft impara in che direzione e quanto va ogni asse di questo mouse 3D. Viene salvata per questo mouse 3D.", + "ui.3d_mouse_calibrate_title": "Calibra mouse 3D", + "ui.3d_mouse_calibrate_wait": "Muovi il mouse 3D una volta così SolidCraft lo vede.", + "ui.3d_mouse_calibrated": "Calibrato", + "ui.3d_mouse_calibration": "Calibrazione del mouse 3D", + "ui.3d_mouse_calibration_done": "Fatto. Salvala per usarla ogni volta che questo mouse 3D è collegato.", + "ui.3d_mouse_calibration_no_response": "Un asse si è mosso appena, quindi non si può calibrare. Ricomincia e muovi la manopola più a fondo ogni volta.", + "ui.3d_mouse_circles_help": "I cerchi mostrano ciò che il dispositivo invia dopo la calibrazione. Il modello di prova mostra cosa ne fa la vista, dopo inversione, velocità e assegnazione degli assi.", + "ui.3d_mouse_dead_zone": "Zona morta", + "ui.3d_mouse_dead_zone_tip": "Quanto deve muoversi la manopola prima che si muova la vista, come quota della sua corsa completa. Aumentala se la vista si sposta con la manopola a riposo.", + "ui.3d_mouse_denied": "SolidCraft vede un mouse 3D ma non ha il permesso di leggerlo. La regola udev è nelle note di configurazione del mouse 3D.", + "ui.3d_mouse_denied_short": "Trovato, ma non leggibile", + "ui.3d_mouse_driven_by": "Quale movimento calibrato del dispositivo comanda questo asse. Se ne scegli uno già usato da un altro asse, i due si scambiano.", + "ui.3d_mouse_driven_by_label": "comandato da", + "ui.3d_mouse_driver": "Driver del mouse 3D", + "ui.3d_mouse_driver_auto": "Automatico", + "ui.3d_mouse_driver_direct": "Diretto (hidraw)", + "ui.3d_mouse_driver_fallback": "Per ora legge tramite spacenavd: il dispositivo stesso non si è potuto aprire.", + "ui.3d_mouse_driver_only_direct": "Lettura diretta del dispositivo; spacenavd non è in esecuzione", + "ui.3d_mouse_driver_only_spacenavd": "Lettura tramite spacenavd, l'unica possibilità disponibile", + "ui.3d_mouse_forget": "Dimentica calibrazione", + "ui.3d_mouse_ghost_left_viewport": "Vista a sinistra", + "ui.3d_mouse_ghost_left_window": "Finestra a sinistra", + "ui.3d_mouse_ghost_position": "Posizione", + "ui.3d_mouse_ghost_right_viewport": "Vista a destra", + "ui.3d_mouse_hint": "Sposta e ruota la vista insieme; gli schizzi usano solo panoramica e zoom.", + "ui.3d_mouse_kind_cap": "Ritorno al centro", + "ui.3d_mouse_kind_question": "Che tipo di mouse 3D è?", + "ui.3d_mouse_kind_trackball": "Trackball", + "ui.3d_mouse_looks_like_cap": "Il valore resta finché tieni, come in un dispositivo di tipo «Ritorno al centro».", + "ui.3d_mouse_looks_like_trackball": "Il valore torna indietro appena smetti di muovere, come in una trackball.", + "ui.3d_mouse_motion_rotation_x": "Rotazione X", + "ui.3d_mouse_motion_rotation_y": "Rotazione Y", + "ui.3d_mouse_motion_rotation_z": "Rotazione Z", + "ui.3d_mouse_motion_translation_x": "Traslazione X", + "ui.3d_mouse_motion_translation_y": "Traslazione Y", + "ui.3d_mouse_motion_translation_z": "Traslazione Z", + "ui.3d_mouse_navigate": "Naviga con il mouse 3D", + "ui.3d_mouse_none": "Nessun mouse 3D trovato", + "ui.3d_mouse_not_calibrated": "Non calibrato", + "ui.3d_mouse_offer": "Calibrare il mouse 3D corregge gli assi scambiati o invertiti e imposta velocità e sensibilità.", + "ui.3d_mouse_offer_never": "Non mostrare più", + "ui.3d_mouse_offer_swap": "Passa a Hidraw (driver diretto)", + "ui.3d_mouse_reading": "Lettura", + "ui.3d_mouse_reset_axes": "Ripristina assegnazione assi", + "ui.3d_mouse_reset_view": "Ripristina vista", + "ui.3d_mouse_restore": "Ripristina impostazioni mouse 3D", + "ui.3d_mouse_restore_ask": "Riportare le impostazioni del mouse 3D ai valori predefiniti? Calibrazione e assegnazione dei pulsanti restano.", + "ui.3d_mouse_section": "Mouse 3D", + "ui.3d_mouse_show_in_view": "Mostra il mouse 3D nella vista 3D", + "ui.3d_mouse_show_in_view_tip": "Un'immagine del tuo mouse 3D nell'angolo in basso a sinistra, che si muove quando lo muovi.", + "ui.3d_mouse_skip": "Il mio dispositivo non può farlo", + "ui.3d_mouse_skip_tip": "Salta questo movimento in entrambi i versi; la calibrazione lo esclude.", "ui.3d_mouse_speed": "Velocità del mouse 3D", + "ui.3d_mouse_step_lift": "Solleva la manopola, tienila, poi lasciala.", + "ui.3d_mouse_step_press": "Premi la manopola verso il basso, tienila, poi lasciala.", + "ui.3d_mouse_step_pull": "Tira la manopola verso di te, tienila, poi lasciala.", + "ui.3d_mouse_step_push": "Spingi la manopola lontano da te, tienila, poi lasciala.", + "ui.3d_mouse_step_rest": "Togli la mano dalla manopola e lasciala ferma.", + "ui.3d_mouse_step_rest_trackball": "Togli la mano dalla sfera e lasciala ferma.", + "ui.3d_mouse_step_ring_clockwise": "Gira la ghiera di scorrimento di qualche scatto in senso orario.", + "ui.3d_mouse_step_ring_counterclockwise": "Gira la ghiera di scorrimento di qualche scatto in senso antiorario.", + "ui.3d_mouse_step_roll_away": "Fai rotolare la sfera lontano da te qualche volta, poi lasciala fermare.", + "ui.3d_mouse_step_roll_left": "Fai rotolare la sfera verso sinistra qualche volta, poi lasciala fermare.", + "ui.3d_mouse_step_roll_right": "Fai rotolare la sfera verso destra qualche volta, poi lasciala fermare.", + "ui.3d_mouse_step_roll_toward": "Fai rotolare la sfera verso di te qualche volta, poi lasciala fermare.", + "ui.3d_mouse_step_slide_left": "Fai scorrere la manopola a sinistra, tienila, poi lasciala.", + "ui.3d_mouse_step_slide_right": "Fai scorrere la manopola a destra, tienila, poi lasciala.", + "ui.3d_mouse_step_spin_clockwise": "Ruota la sfera in senso orario qualche volta, poi lasciala fermare.", + "ui.3d_mouse_step_spin_counterclockwise": "Ruota la sfera in senso antiorario qualche volta, poi lasciala fermare.", + "ui.3d_mouse_step_tilt_away": "Inclina la parte superiore della manopola lontano da te (il bordo più vicino sale), tienila, poi lasciala.", + "ui.3d_mouse_step_tilt_left": "Inclina la manopola a sinistra (bordo sinistro in basso), tienila, poi lasciala.", + "ui.3d_mouse_step_tilt_right": "Inclina la manopola a destra (bordo destro in basso), tienila, poi lasciala.", + "ui.3d_mouse_step_tilt_toward": "Inclina la parte superiore della manopola verso di te (il bordo più vicino scende), tienila, poi lasciala.", + "ui.3d_mouse_step_touch": "Appoggia la mano sulla manopola senza spingerla.", + "ui.3d_mouse_step_twist_clockwise": "Ruota la manopola in senso orario, tienila, poi lasciala.", + "ui.3d_mouse_step_twist_counterclockwise": "Ruota la manopola in senso antiorario, tienila, poi lasciala.", + "ui.3d_mouse_switch_cap": "Calibra come ritorno al centro", + "ui.3d_mouse_switch_trackball": "Calibra come trackball", + "ui.3d_mouse_view_axis": "Asse della vista", + "ui.3d_mouse_view_off": "Disattivato", + "ui.3d_mouse_view_off_tip": "Il profilo del driver del mouse 3D disattiva questo asse, quindi il suo movimento non muove la vista. Se qui scegli il movimento di un altro asse, i due si scambiano e quel movimento smette di muovere la vista.", + "ui.3d_mouse_view_orbit_sideways": "Orbita sinistra / destra", + "ui.3d_mouse_view_orbit_up_down": "Orbita su / giù", + "ui.3d_mouse_view_pan_sideways": "Panoramica sinistra / destra", + "ui.3d_mouse_view_pan_up_down": "Panoramica su / giù", + "ui.3d_mouse_view_roll": "Rollio", + "ui.3d_mouse_view_unmapped": "Asse {n}", + "ui.3d_mouse_view_zoom": "Zoom", + "ui.3d_mouse_waiting": "In attesa di movimento", + "ui.3d_mouse_wiggle": "Se ne hai uno collegato, muovilo un po'.", "ui.3d_sketch": "Schizzo 3D", "ui.activate": "Attiva", "ui.active_component": "Componente attivo", @@ -1354,6 +1457,7 @@ "ui.blocked": "Bloccato", "ui.bodies": "Corpi", "ui.bodies_all_if_none": "Corpi (tutti se nessuno)", + "ui.bottom": "Inferiore", "ui.box": "Parallelepipedo", "ui.break": "Spezza", "ui.browser_bodies_one": "{count} corpo", @@ -1797,59 +1901,6 @@ "ui.smooth_wheel_zoom": "Zoom fluido con rotellina", "ui.smooth_wheel_zoom_tip": "Disattiva per applicare immediatamente ogni evento della rotellina, senza coda di interpolazione", "ui.snap": "Aggancio", - "ui.space_mouse_axes_reassigned": "Gli assi sono riassegnati nelle Preferenze, e questo vale anche dopo questa calibrazione.", - "ui.space_mouse_calibrate": "Calibra…", - "ui.space_mouse_calibrate_tip": "Muovi la manopola come chiede la finestra, così SolidCraft impara in che direzione e quanto va ogni asse di questo mouse 3D. Viene salvata per questo mouse 3D.", - "ui.space_mouse_calibrate_title": "Calibra mouse 3D", - "ui.space_mouse_calibrated": "Calibrato", - "ui.space_mouse_calibration": "Calibrazione del mouse 3D", - "ui.space_mouse_calibration_done": "Fatto. Salvala per usarla ogni volta che questo mouse 3D è collegato.", - "ui.space_mouse_calibration_no_response": "Un asse si è mosso appena, quindi non si può calibrare. Ricomincia e muovi la manopola più a fondo ogni volta.", - "ui.space_mouse_dead_zone": "Zona morta", - "ui.space_mouse_dead_zone_tip": "Quanto deve muoversi la manopola prima che si muova la vista, come quota della sua corsa completa. Aumentala se la vista si sposta con la manopola a riposo.", - "ui.space_mouse_driven_by": "Quale movimento calibrato del dispositivo comanda questo asse. Se ne scegli uno già usato da un altro asse, i due si scambiano.", - "ui.space_mouse_driven_by_label": "comandato da", - "ui.space_mouse_driver": "Driver del mouse 3D", - "ui.space_mouse_driver_auto": "Automatico", - "ui.space_mouse_driver_direct": "Diretto (hidraw)", - "ui.space_mouse_driver_only_direct": "Lettura diretta del dispositivo; spacenavd non è in esecuzione", - "ui.space_mouse_driver_only_spacenavd": "Lettura tramite spacenavd, l'unica possibilità disponibile", - "ui.space_mouse_forget": "Dimentica calibrazione", - "ui.space_mouse_hint": "Sposta e ruota la vista insieme; gli schizzi usano solo panoramica e zoom.", - "ui.space_mouse_motion_rotation_x": "Rotazione X", - "ui.space_mouse_motion_rotation_y": "Rotazione Y", - "ui.space_mouse_motion_rotation_z": "Rotazione Z", - "ui.space_mouse_motion_translation_x": "Traslazione X", - "ui.space_mouse_motion_translation_y": "Traslazione Y", - "ui.space_mouse_motion_translation_z": "Traslazione Z", - "ui.space_mouse_navigate": "Naviga con SpaceNavigator / SpaceMouse", - "ui.space_mouse_none": "Nessun mouse 3D trovato", - "ui.space_mouse_not_calibrated": "Non calibrato", - "ui.space_mouse_reading": "Lettura", - "ui.space_mouse_reset_axes": "Ripristina assegnazione assi", - "ui.space_mouse_step_lift": "Solleva la manopola, tienila, poi lasciala.", - "ui.space_mouse_step_press": "Premi la manopola verso il basso, tienila, poi lasciala.", - "ui.space_mouse_step_pull": "Tira la manopola verso di te, tienila, poi lasciala.", - "ui.space_mouse_step_push": "Spingi la manopola lontano da te, tienila, poi lasciala.", - "ui.space_mouse_step_rest": "Togli la mano dalla manopola e lasciala ferma.", - "ui.space_mouse_step_slide_left": "Fai scorrere la manopola a sinistra, tienila, poi lasciala.", - "ui.space_mouse_step_slide_right": "Fai scorrere la manopola a destra, tienila, poi lasciala.", - "ui.space_mouse_step_tilt_back": "Inclina la manopola all'indietro (bordo anteriore in alto), tienila, poi lasciala.", - "ui.space_mouse_step_tilt_forward": "Inclina la manopola in avanti (bordo anteriore in basso), tienila, poi lasciala.", - "ui.space_mouse_step_tilt_left": "Inclina la manopola a sinistra (bordo sinistro in basso), tienila, poi lasciala.", - "ui.space_mouse_step_tilt_right": "Inclina la manopola a destra (bordo destro in basso), tienila, poi lasciala.", - "ui.space_mouse_step_twist_clockwise": "Ruota la manopola in senso orario, tienila, poi lasciala.", - "ui.space_mouse_step_twist_counterclockwise": "Ruota la manopola in senso antiorario, tienila, poi lasciala.", - "ui.space_mouse_view_axis": "Asse della vista", - "ui.space_mouse_view_off": "Disattivato", - "ui.space_mouse_view_off_tip": "Il profilo del driver del mouse 3D disattiva questo asse, quindi il suo movimento non muove la vista. Se qui scegli il movimento di un altro asse, i due si scambiano e quel movimento smette di muovere la vista.", - "ui.space_mouse_view_orbit_sideways": "Orbita sinistra / destra", - "ui.space_mouse_view_orbit_up_down": "Orbita su / giù", - "ui.space_mouse_view_pan_sideways": "Panoramica sinistra / destra", - "ui.space_mouse_view_pan_up_down": "Panoramica su / giù", - "ui.space_mouse_view_roll": "Rollio", - "ui.space_mouse_view_unmapped": "Asse {n}", - "ui.space_mouse_view_zoom": "Zoom", "ui.spacing": "Spaziatura", "ui.spacing_2": "Spaziatura 2", "ui.sphere": "Sfera", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index 88b639d1d4..a5e99b7573 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -5,7 +5,9 @@ "2. Attach your screenshot: copy and paste it, or save and upload the PNG.": "2. スクリーンショットを添付します。コピーして貼り付けるか、PNGを保存してアップロードします。", "2D": "2D", "3. Click Submit new issue on GitHub to send your report.": "3. GitHubでSubmit new issueをクリックしてレポートを送信します。", + "A Single hole snaps to the middle of its face and to the centres of its round edges": "単一の穴を面の中心と丸いエッジの中心にスナップします", "A new configuration, a copy of the active one": "アクティブな構成をコピーして新しい構成を作成", + "A placed Single hole has a handle to drag it, and picking its face again moves the hole there instead of taking it out": "配置した単一の穴にドラッグ用のハンドルを付け、面をもう一度選ぶと穴を削除せずにそこへ移動します", "ARRANGE": "配置", "About SolidCraft": "SolidCraft について", "Acceleration": "加速度", @@ -25,11 +27,13 @@ "Advanced": "詳細", "Against": "相手", "Alignment": "配置", + "All Lines": "すべての線分", "All Sketch Points": "すべてのスケッチ点", "All components": "すべてのコンポーネント", "All open designs": "すべての開いているデザイン", "Allowed expressions, separated by semicolons": "使用可能な式(セミコロンで区切る)", "Also deletes": "同時に削除される要素:", + "Anchor": "基準点", "Angle 2": "角度 2", "Animate": "アニメーション", "App/system information and command settings are included. Review private details before sharing.": "アプリ・システム情報とコマンド設定が含まれます。共有前に非公開の情報を確認してください。", @@ -47,6 +51,7 @@ "BLEND SURFACE": "ブレンドサーフェス", "BOUNDARY SURFACE": "境界サーフェス", "BROWSER": "ブラウザ", + "Bar": "平鋼", "Bend Edges": "曲げエッジ", "Bend Line Position": "曲げ線位置", "Bend Position": "曲げ位置", @@ -59,6 +64,9 @@ "Body (last if none)": "ボディ (未指定時は最後)", "Body to split": "分割するボディ", "Body, or faces to take out": "ボディ、または取り除く面", + "Bottom Left": "左下", + "Bottom Middle": "下中央", + "Bottom Right": "右下", "Boundary": "境界", "Bounding box": "境界ボックス", "CHECK": "チェック", @@ -78,12 +86,15 @@ "Center Y": "中心 Y", "Center of mass": "重心", "Centre to centre": "中心間", + "Centroid": "図心", "Chamfer Type": "面取りタイプ", + "Channel": "溝形鋼", "Check collisions": "衝突チェック", "Checked": "チェック済み", "Choose Variable radius or Spline in Type.": "タイプで Variable radius またはスプラインを選択してください。", "Choose a new plane. Sketch geometry and dimensions stay unchanged in its two-dimensional coordinates.": "新しい平面を選択してください。スケッチジオメトリと寸法は2次元座標系のまま維持されます。", "Choose…": "選択…", + "Circle center": "円の中心", "Circular": "円形", "Circumference": "円周", "Close draft": "下書きを閉じる", @@ -113,10 +124,12 @@ "Copy screenshot": "スクリーンショットをコピー", "Corner Relief": "コーナーリリーフ", "Corner Relief Shape": "コーナーリリーフ形状", + "Corner radius": "コーナー半径", "Counterbore depth": "座ぐり深さ", "Counterbore Ø": "座ぐり Ø", "Countersink angle": "皿穴角度", "Countersink Ø": "皿穴 Ø", + "Create Hole": "穴を作成", "Create Issue — review draft": "Issueを作成 — 下書きの確認", "Create Issue…": "Issueを作成…", "Cross curves": "交差する曲線", @@ -152,6 +165,7 @@ "Don't ask again": "今後確認しない", "Double-click to rename": "ダブルクリックで名前変更", "Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "ビュー上で中ボタンをダブルクリックした時の動作: ホーム(全体を収めたホームビュー)、全体表示(向きを維持して全体を収める)、または無効", + "Drag to move the hole; click to remove it": "ドラッグで穴を移動、クリックで削除", "Drive Joint": "ジョイントを駆動", "Duration": "時間", "Edges": "エッジ", @@ -185,6 +199,7 @@ "Face": "面", "Face Appearance": "面の外観", "Face attachment lost": "面アタッチメントが失われました", + "Face center": "面の中心", "Faces": "面", "Faces / edges / vertices": "面 / エッジ / 頂点", "Faces to split": "分割する面", @@ -200,6 +215,7 @@ "First plane": "第1平面", "Fit and G2 corners require Constant radius.": "フィットと G2 コーナーには Constant radius が必要です。", "Fixed Face": "固定面", + "Flange thickness": "フランジ厚", "Flat Pattern": "フラットパターン", "Flat size": "展開サイズ", "Follow camera (Look At sketches)": "カメラ追従(スケッチを正面視)", @@ -222,6 +238,7 @@ "Holes (edges on them)": "穴(そのエッジ)", "Home view": "ホームビュー", "Horiz./Vert.": "水平 / 垂直", + "I-Beam": "I形鋼", "IMAGES": "画像", "Import…": "読み込み…", "In This Design": "このデザイン内", @@ -242,10 +259,12 @@ "Keep of the other surface": "相手のサーフェスで残す部分", "Key a joint's value at this step": "このステップでジョイントの値をキー設定", "Keys": "キー", + "L-Angle": "山形鋼", "LIBRARY": "ライブラリ", "LINKS": "リンク", "Later": "後で", "Leave it": "そのままにする", + "Left Middle": "左中央", "Legacy face attachment. Rebinding to a certified face creates an exact attachment.": "従来の面アタッチメントです。確定された面に再バインドすると正確なアタッチメントが作成されます。", "Length": "長さ", "Length units": "長さの単位", @@ -255,6 +274,7 @@ "Limits": "制限", "Line Spacing": "行間", "Line Type": "線種", + "Lines": "線分", "Listing the installed fonts…": "インストール済みフォントを一覧表示中…", "Load library": "ライブラリを読み込む", "Local library": "ローカルライブラリ", @@ -288,6 +308,7 @@ "Move Type": "移動タイプ", "Move down": "下へ移動", "Move earlier": "前へ移動", + "Move holes by clicking or dragging": "クリックやドラッグで穴を移動", "Move later": "後へ移動", "Move up": "上へ移動", "Name": "名前", @@ -319,6 +340,7 @@ "Orbit (right drag)": "回転(右ドラッグ)", "Other side": "反対側", "Other surface (mutual trim)": "相手のサーフェス(相互トリム)", + "Outer radius": "外側コーナー半径", "Outside": "外側", "Overall length": "全長", "Param. Graph": "パラメータ図", @@ -361,6 +383,7 @@ "Recover": "復元", "Recover unsaved design?": "未保存のデザインを復元しますか?", "Rectangular": "長方形", + "Rectangular Tube": "角形鋼管", "Reference face": "参照面", "Remove Tools": "ツールボディを削除", "Remove condition": "条件を削除", @@ -398,11 +421,15 @@ "Rib Offset": "リブのオフセット", "Rib Thickness": "リブの厚さ", "Ribs": "リブ", + "Right Middle": "右中央", "Rim Edge": "リムエッジ", "Ring of tools on the right button": "右ボタンのツールリング", "Roll Back Here": "ここまでロールバック", "Roll History Marker Here": "履歴マーカーをここに移動", + "Root radius": "付け根半径", "Round": "丸め", + "Round Bar": "丸鋼", + "Round Tube": "丸形鋼管", "Rule": "ルール", "Rule name": "ルール名", "Rules run from top to bottom. Matched drives change effective values.": "ルールは上から順に実行されます。条件に一致する駆動ルールが有効値を変更します。", @@ -421,6 +448,7 @@ "Search this library": "このライブラリを検索", "Second plane": "第2平面", "Section": "断面", + "Section (optional)": "断面(任意)", "Sections (one sketch each)": "セクション (各1スケッチ)", "Select Other": "その他を選択", "Select Other: hold the left button still on the model (or right-click › Select Other…) to list everything under the cursor; click again on the same spot to select the next thing there. Double-click the middle button to go Home (Preferences › Navigation).": "その他を選択: モデル上で左ボタンを長押し(または右クリック › その他を選択…)するとカーソル下の要素が一覧表示されます。同じ場所を再度クリックすると次の要素が選択されます。中ボタンをダブルクリックするとホームビューに戻ります(環境設定 › ナビゲーション)。", @@ -445,6 +473,7 @@ "Sketch plane has no body-face attachment.": "スケッチ平面にボディ面のアタッチメントがありません。", "Sketch points": "スケッチ点", "Slot width": "長穴の幅", + "Snap to face and edge centres": "面とエッジの中心にスナップ", "SolidCraft closed before these designs were saved. Their last autosave can be opened.": "これらのデザインが保存される前にSolidCraftが終了しました。最後の自動保存ファイルを開くことができます。", "Specialise": "特殊操作", "Speed": "速度", @@ -492,11 +521,15 @@ "To": "終了", "To Offset": "オフセットまで", "To edges": "終了側エッジ", + "Toe radius": "先端半径", "Tolerance": "公差", "Too close": "近すぎる", "Tool Bodies": "ツールボディ", "Tools": "ツール", "Tools, clockwise from the top": "上から時計回りにツール", + "Top Left": "左上", + "Top Middle": "上中央", + "Top Right": "右上", "Trimming tool (plane, face or curves)": "トリムツール(平面、面、または曲線)", "Turn off section analysis before exporting the view.": "断面解析をオフにしてからエクスポートしてください。", "Type": "タイプ", @@ -521,6 +554,7 @@ "Wall": "肉厚", "Wall Location": "壁の位置", "Wall Thickness": "壁の厚さ", + "Web thickness": "ウェブ厚", "What did you do just before the problem? A short list is enough": "問題の直前に行った操作を簡単に記入してください", "What did you expect to see?": "どのような結果を期待していましたか?", "What happened?": "何が起きましたか?", @@ -542,6 +576,8 @@ "Z angle": "Z角度", "Z distance": "Z距離", "Zebra": "ゼブラ", + "ai.prefs.allow_control": "AI エージェントの接続を許可(コントロールチャンネル / MCP)", + "ai.prefs.allow_control_note": "このコンピューター内だけのコントロールチャンネル(127.0.0.1、ポート 27878 または次に空いているポート)を開きます。接続するプログラムは、起動のたびに作られ、あなたのユーザーアカウントだけが読める秘密のトークンも提示する必要があります。次に SolidCraft を起動したときから有効です。オフ:--control を付けて起動しない限り、何も待ち受けません。", "ai.prefs.availability": "アシスタントの操作", "ai.prefs.background_commands": "エージェントのコマンドをバックグラウンドで実行", "ai.prefs.follow_camera": "カメラをスケッチに向ける", @@ -689,6 +725,10 @@ "cmd.file_insert_step": "STEP を挿入", "cmd.file_save_mesh": "メッシュとして保存", "cmd.file_slicers": "インストール済みのスライサー", + "cmd.frame_member": "フレーム部材", + "cmd.frame_profile_insert": "形材を挿入", + "cmd.frame_profile_list": "形材ライブラリ", + "cmd.frame_profile_properties": "形材のプロパティ", "cmd.inspect_accessibility": "アクセシビリティ解析", "cmd.inspect_center_of_mass": "重心", "cmd.inspect_clearance": "クリアランス", @@ -747,6 +787,7 @@ "cmd.motion_trace_edit": "モーション トレースを編集", "cmd.motion_trace_keep": "モーション トレースを保持", "cmd.occurrence_copy": "貼り付け(インスタンス)", + "cmd.occurrence_drag": "オカレンスをドラッグ", "cmd.occurrence_ground": "空間固定", "cmd.occurrence_move": "オカレンスを移動", "cmd.occurrence_properties": "オカレンスの物理的プロパティ", @@ -804,6 +845,7 @@ "cmd.sketch_auto_constrain_from_datum": "基準から自動拘束", "cmd.sketch_auto_project": "参照上のエッジを自動投影", "cmd.sketch_blend_curve": "ブレンド曲線", + "cmd.sketch_cancel": "スケッチをキャンセル", "cmd.sketch_chain": "チェーン", "cmd.sketch_chamfer_distance_angle": "距離と角度の面取り", "cmd.sketch_chamfer_equal_distance": "等距離面取り", @@ -1008,6 +1050,7 @@ "file_load.stopping": "前の操作の停止を待っています...", "file_load.waiting": "ファイル操作を待っています", "fits the face, e.g. M8": "面に適合するサイズ (例: M8)", + "from the profile": "形材から", "from the rule": "ルールから", "help.apache": "Apacheライセンス2.0", "help.attributions": "帰属表示 (ATTRIBUTION.md)", @@ -1363,6 +1406,7 @@ "search.suggest": "{label} ですか?", "search.try": "コマンド名、このデザインのフィーチャー、またはパラメーターを入力します。値を指定して実行するには、IDとJSONオブジェクトを入力します:{example}", "search.valid": "有効", + "section.off_hint": "平面が選択されていません。OK を押すとセクションを解除します。", "settings.display_quality_note": "表示だけに影響します。設計や書き出しは変わりません。", "settings.frame_rate_note": "ビューの右下に 1 秒あたりのフレーム数を表示します。", "settings.measure_axis_distances": "計測のX・Y・Z距離を表示", @@ -1370,6 +1414,8 @@ "settings.msaa_applies_on_restart": "アンチエイリアスは次回 SolidCraft を起動したときに適用されます", "settings.msaa_reset": "{setting}: {requested}× はこのグラフィックスデバイスではサポートされていないため、{reset}× に変更しました(サポート: {supported})。", "settings.msaa_unsupported": "このグラフィックスデバイスではサポートされていません", + "settings.reference_geometry_through_bodies": "ボディ越しに参照ジオメトリを表示", + "settings.reference_geometry_through_bodies_help": "非アクティブなスケッチ輪郭と基準平面をシェーディングされたボディの上に描画します", "settings.reset_intro": "次の設定はこのコンピューターでは使用できず、SolidCraft が起動できなくなるため変更されました:", "settings.reset_title": "設定が変更されました", "settings.supersampling_note": "ビューを高い解像度で描画してから縮小します。4K 未満の画面でエッジ、線、グリッドが滑らかになります。GPU の負荷が増えます(1.5× で約 2.25 倍、2× で 4 倍のピクセル)。", @@ -1429,7 +1475,106 @@ "trace.stopped_by_contact": "サンプル {sample} で終了: {a} が {b} を通り抜けてしまいます", "trace.stopped_by_loop": "サンプル {sample} で終了: {joint} を通るループはこれ以上閉じられません", "ui.1_degree_of_freedom": "自由度 1", + "ui.3d_mouse_axes_reassigned": "軸の割り当ては環境設定で変更されており、このキャリブレーションの後も適用されます。", + "ui.3d_mouse_button": "ボタン {number}", + "ui.3d_mouse_button_nothing": "なし", + "ui.3d_mouse_button_settings": "3Dマウスの設定", + "ui.3d_mouse_buttons": "ボタン", + "ui.3d_mouse_buttons_hint": "3Dマウスのボタンを押すとここに表示されます。動作を選んでください。", + "ui.3d_mouse_buttons_no_device": "ボタンの割り当てを設定するには3Dマウスを接続してください。", + "ui.3d_mouse_buttons_reset": "ボタンを既定に戻す", + "ui.3d_mouse_calibrate": "キャリブレーション…", + "ui.3d_mouse_calibrate_tip": "ウィンドウの指示どおりにキャップを動かすと、この3Dマウスの各軸の向きと移動量をSolidCraftが学習します。この3Dマウス用に保存されます。", + "ui.3d_mouse_calibrate_title": "3Dマウスのキャリブレーション", + "ui.3d_mouse_calibrate_wait": "SolidCraftが認識できるように、3Dマウスを一度動かしてください。", + "ui.3d_mouse_calibrated": "キャリブレーション済み", + "ui.3d_mouse_calibration": "3Dマウスのキャリブレーション", + "ui.3d_mouse_calibration_done": "完了しました。保存すると、この3Dマウスを接続したときに毎回使われます。", + "ui.3d_mouse_calibration_no_response": "ほとんど動かなかった軸があるため、キャリブレーションできません。最初からやり直し、毎回キャップをもっと大きく動かしてください。", + "ui.3d_mouse_circles_help": "円はデバイスがキャリブレーション後に送る値を示します。テストモデルは、反転・速度・軸の割り当てを経て、表示がそれをどう扱うかを示します。", + "ui.3d_mouse_dead_zone": "デッドゾーン", + "ui.3d_mouse_dead_zone_tip": "視点が動き出すまでにキャップを動かす量(全ストロークに対する割合)。キャップに触れていないのに視点が動く場合は大きくしてください。", + "ui.3d_mouse_denied": "SolidCraftは3Dマウスを認識していますが、読み取る権限がありません。udevルールは3Dマウスのセットアップ手順を参照してください。", + "ui.3d_mouse_denied_short": "見つかりましたが読み取れません", + "ui.3d_mouse_driven_by": "この軸を操作するデバイスのキャリブレーション済みの動き。他の軸で使われている動きを選ぶと、2つが入れ替わります。", + "ui.3d_mouse_driven_by_label": "操作元", + "ui.3d_mouse_driver": "3Dマウスのドライバー", + "ui.3d_mouse_driver_auto": "自動", + "ui.3d_mouse_driver_direct": "直接 (hidraw)", + "ui.3d_mouse_driver_fallback": "いまは spacenavd 経由で読み取っています。デバイス自体を開けませんでした。", + "ui.3d_mouse_driver_only_direct": "デバイスを直接読み取り中(spacenavd は起動していません)", + "ui.3d_mouse_driver_only_spacenavd": "spacenavd 経由で読み取り中(他の方法はありません)", + "ui.3d_mouse_forget": "キャリブレーションを削除", + "ui.3d_mouse_ghost_left_viewport": "ビューポート左", + "ui.3d_mouse_ghost_left_window": "ウィンドウ左", + "ui.3d_mouse_ghost_position": "位置", + "ui.3d_mouse_ghost_right_viewport": "ビューポート右", + "ui.3d_mouse_hint": "視点の移動と回転を同時に行います(スケッチ中はパンとズームのみ)", + "ui.3d_mouse_kind_cap": "センターに戻る", + "ui.3d_mouse_kind_question": "どの種類の3Dマウスですか?", + "ui.3d_mouse_kind_trackball": "トラックボール", + "ui.3d_mouse_looks_like_cap": "押さえている間は値がそのまま残ります。「センターに戻る」タイプの動き方です。", + "ui.3d_mouse_looks_like_trackball": "動きを止めるとすぐに値が戻ります。トラックボールの動き方です。", + "ui.3d_mouse_motion_rotation_x": "回転 X", + "ui.3d_mouse_motion_rotation_y": "回転 Y", + "ui.3d_mouse_motion_rotation_z": "回転 Z", + "ui.3d_mouse_motion_translation_x": "平行移動 X", + "ui.3d_mouse_motion_translation_y": "平行移動 Y", + "ui.3d_mouse_motion_translation_z": "平行移動 Z", + "ui.3d_mouse_navigate": "3Dマウスで視点を操作", + "ui.3d_mouse_none": "3Dマウスが見つかりません", + "ui.3d_mouse_not_calibrated": "未キャリブレーション", + "ui.3d_mouse_offer": "3Dマウスをキャリブレーションすると、入れ替わった軸や反転した軸が直り、速度と操作感も整います。", + "ui.3d_mouse_offer_never": "今後表示しない", + "ui.3d_mouse_offer_swap": "Hidraw(直接ドライバー)に切り替える", + "ui.3d_mouse_reading": "読み取り値", + "ui.3d_mouse_reset_axes": "軸の割り当てをリセット", + "ui.3d_mouse_reset_view": "表示をリセット", + "ui.3d_mouse_restore": "3Dマウスの設定を既定に戻す", + "ui.3d_mouse_restore_ask": "3Dマウスの設定を既定に戻しますか?キャリブレーションとボタン割り当ては残ります。", + "ui.3d_mouse_section": "3Dマウス", + "ui.3d_mouse_show_in_view": "3Dビューに3Dマウスを表示", + "ui.3d_mouse_show_in_view_tip": "左下に3Dマウスの絵を表示し、操作に合わせて動かします。", + "ui.3d_mouse_skip": "このデバイスではできません", + "ui.3d_mouse_skip_tip": "この動きを両方向とも飛ばし、キャリブレーションから外します。", "ui.3d_mouse_speed": "3D マウス速度", + "ui.3d_mouse_step_lift": "キャップを持ち上げて保持し、離してください。", + "ui.3d_mouse_step_press": "キャップを押し下げて保持し、離してください。", + "ui.3d_mouse_step_pull": "キャップを手前に引いて保持し、離してください。", + "ui.3d_mouse_step_push": "キャップを奥に押して保持し、離してください。", + "ui.3d_mouse_step_rest": "キャップから手を離し、動かさないでください。", + "ui.3d_mouse_step_rest_trackball": "ボールから手を離し、動かさないでください。", + "ui.3d_mouse_step_ring_clockwise": "スクロールリングを時計回りに数クリック回してください。", + "ui.3d_mouse_step_ring_counterclockwise": "スクロールリングを反時計回りに数クリック回してください。", + "ui.3d_mouse_step_roll_away": "ボールを奥に数回転がし、止まるまで待ってください。", + "ui.3d_mouse_step_roll_left": "ボールを左に数回転がし、止まるまで待ってください。", + "ui.3d_mouse_step_roll_right": "ボールを右に数回転がし、止まるまで待ってください。", + "ui.3d_mouse_step_roll_toward": "ボールを手前に数回転がし、止まるまで待ってください。", + "ui.3d_mouse_step_slide_left": "キャップを左にスライドして保持し、離してください。", + "ui.3d_mouse_step_slide_right": "キャップを右にスライドして保持し、離してください。", + "ui.3d_mouse_step_spin_clockwise": "ボールを時計回りに数回ひねり、止まるまで待ってください。", + "ui.3d_mouse_step_spin_counterclockwise": "ボールを反時計回りに数回ひねり、止まるまで待ってください。", + "ui.3d_mouse_step_tilt_away": "キャップの上側を奥に傾けて(手前の縁が上がります)保持し、離してください。", + "ui.3d_mouse_step_tilt_left": "キャップを左に傾け(左の縁を下げて)保持し、離してください。", + "ui.3d_mouse_step_tilt_right": "キャップを右に傾け(右の縁を下げて)保持し、離してください。", + "ui.3d_mouse_step_tilt_toward": "キャップの上側を手前に傾けて(手前の縁が下がります)保持し、離してください。", + "ui.3d_mouse_step_touch": "押さずにキャップに手を置いてください。", + "ui.3d_mouse_step_twist_clockwise": "キャップを時計回りにひねって保持し、離してください。", + "ui.3d_mouse_step_twist_counterclockwise": "キャップを反時計回りにひねって保持し、離してください。", + "ui.3d_mouse_switch_cap": "センターに戻るタイプとしてキャリブレーション", + "ui.3d_mouse_switch_trackball": "トラックボールとしてキャリブレーション", + "ui.3d_mouse_view_axis": "ビューの軸", + "ui.3d_mouse_view_off": "オフ", + "ui.3d_mouse_view_off_tip": "3D マウスのドライバーのプロファイルがこの軸をオフにしているため、この軸の動きではビューは動きません。ここで他の軸の動きを選ぶと 2 つが入れ替わり、その動きではビューが動かなくなります。", + "ui.3d_mouse_view_orbit_sideways": "左右オービット", + "ui.3d_mouse_view_orbit_up_down": "上下オービット", + "ui.3d_mouse_view_pan_sideways": "左右パン", + "ui.3d_mouse_view_pan_up_down": "上下パン", + "ui.3d_mouse_view_roll": "ロール", + "ui.3d_mouse_view_unmapped": "軸 {n}", + "ui.3d_mouse_view_zoom": "ズーム", + "ui.3d_mouse_waiting": "動きを待機中", + "ui.3d_mouse_wiggle": "接続している場合は、少し動かしてみてください。", "ui.3d_sketch": "3D スケッチ", "ui.activate": "アクティブ化", "ui.active_component": "アクティブなコンポーネント", @@ -1667,6 +1812,7 @@ "ui.blocked": "到達不可", "ui.bodies": "ボディ", "ui.bodies_all_if_none": "ボディ(未選択ならすべて)", + "ui.bottom": "底面", "ui.box": "直方体", "ui.break": "分割", "ui.browser_bodies_one": "{count} ボディ", @@ -1792,6 +1938,8 @@ "ui.extract_design_changed": "ファイルの選択中にデザインが変更されました。ファイルへの抽出をやり直してください。", "ui.extract_done": "コンポーネントは独立したデザインになり、ここにリンクされました。", "ui.extrude": "押し出し", + "ui.extrude_auto_to_object": "選択した対象まで押し出し", + "ui.extrude_auto_to_object_tip": "押し出しでプロファイルを選択した後、その平面外の面・頂点・平行な平面を選ぶと、範囲タイプが「オブジェクトまで」に切り替わります", "ui.factor": "倍率", "ui.feature_history": "フィーチャー履歴", "ui.feature_options": "フィーチャーオプション", @@ -2047,6 +2195,9 @@ "ui.rectangle": "長方形", "ui.rectangular_pattern": "矩形パターン", "ui.redo": "やり直す", + "ui.reference_1": "参照 1", + "ui.reference_2": "参照 2", + "ui.reference_distance": "参照からの距離", "ui.relink_design_changed": "参照元の選択中にアセンブリが変更されました。再リンクをやり直してください。", "ui.relink_done": "すべてのインスタンスの参照元を再リンクしました。", "ui.relink_not_linked": "このコンポーネントは参照元の設計にリンクされていません。", @@ -2096,6 +2247,12 @@ "ui.sent_to_slicer": "{slicer} に送信しました", "ui.sheet_gap": "ギャップ", "ui.shell": "シェル", + "ui.shortcut_capture_add_key": "通常のキーを1つ追加してください。Escでキャンセルします。", + "ui.shortcut_capture_hint": "割り当てをクリックし、修飾キーと1つのキーを押します。1秒以内に修飾キーを追加し、すべて離して確定します。Escでキャンセルします。", + "ui.shortcut_capture_press": "修飾キーと1つのキーを押してください。Escでキャンセルします。", + "ui.shortcut_capture_release": "すべてのキーを離すと確定します。Escでキャンセルします。", + "ui.shortcut_capture_settling": "ショートカットを確定中… Escでキャンセルします。", + "ui.shortcut_multiple_keys": "修飾キーと、それ以外のキー1つだけを使用してください。割り当てをクリックしてやり直してください。", "ui.show": "表示", "ui.showAll": "すべて表示", "ui.showAllBodies": "すべてのボディを表示", @@ -2125,59 +2282,6 @@ "ui.smooth_wheel_zoom": "滑らかなホイールズーム", "ui.smooth_wheel_zoom_tip": "オフにすると、ホイール操作のたびにズームが即座に反映され、滑らかな減速効果は適用されません。", "ui.snap": "スナップ", - "ui.space_mouse_axes_reassigned": "軸の割り当ては環境設定で変更されており、このキャリブレーションの後も適用されます。", - "ui.space_mouse_calibrate": "キャリブレーション…", - "ui.space_mouse_calibrate_tip": "ウィンドウの指示どおりにキャップを動かすと、この3Dマウスの各軸の向きと移動量をSolidCraftが学習します。この3Dマウス用に保存されます。", - "ui.space_mouse_calibrate_title": "3Dマウスのキャリブレーション", - "ui.space_mouse_calibrated": "キャリブレーション済み", - "ui.space_mouse_calibration": "3Dマウスのキャリブレーション", - "ui.space_mouse_calibration_done": "完了しました。保存すると、この3Dマウスを接続したときに毎回使われます。", - "ui.space_mouse_calibration_no_response": "ほとんど動かなかった軸があるため、キャリブレーションできません。最初からやり直し、毎回キャップをもっと大きく動かしてください。", - "ui.space_mouse_dead_zone": "デッドゾーン", - "ui.space_mouse_dead_zone_tip": "視点が動き出すまでにキャップを動かす量(全ストロークに対する割合)。キャップに触れていないのに視点が動く場合は大きくしてください。", - "ui.space_mouse_driven_by": "この軸を操作するデバイスのキャリブレーション済みの動き。他の軸で使われている動きを選ぶと、2つが入れ替わります。", - "ui.space_mouse_driven_by_label": "操作元", - "ui.space_mouse_driver": "3Dマウスのドライバー", - "ui.space_mouse_driver_auto": "自動", - "ui.space_mouse_driver_direct": "直接 (hidraw)", - "ui.space_mouse_driver_only_direct": "デバイスを直接読み取り中(spacenavd は起動していません)", - "ui.space_mouse_driver_only_spacenavd": "spacenavd 経由で読み取り中(他の方法はありません)", - "ui.space_mouse_forget": "キャリブレーションを削除", - "ui.space_mouse_hint": "視点の移動と回転を同時に行います(スケッチ中はパンとズームのみ)", - "ui.space_mouse_motion_rotation_x": "回転 X", - "ui.space_mouse_motion_rotation_y": "回転 Y", - "ui.space_mouse_motion_rotation_z": "回転 Z", - "ui.space_mouse_motion_translation_x": "平行移動 X", - "ui.space_mouse_motion_translation_y": "平行移動 Y", - "ui.space_mouse_motion_translation_z": "平行移動 Z", - "ui.space_mouse_navigate": "SpaceNavigator / SpaceMouseで視点を操作", - "ui.space_mouse_none": "3Dマウスが見つかりません", - "ui.space_mouse_not_calibrated": "未キャリブレーション", - "ui.space_mouse_reading": "読み取り値", - "ui.space_mouse_reset_axes": "軸の割り当てをリセット", - "ui.space_mouse_step_lift": "キャップを持ち上げて保持し、離してください。", - "ui.space_mouse_step_press": "キャップを押し下げて保持し、離してください。", - "ui.space_mouse_step_pull": "キャップを手前に引いて保持し、離してください。", - "ui.space_mouse_step_push": "キャップを奥に押して保持し、離してください。", - "ui.space_mouse_step_rest": "キャップから手を離し、動かさないでください。", - "ui.space_mouse_step_slide_left": "キャップを左にスライドして保持し、離してください。", - "ui.space_mouse_step_slide_right": "キャップを右にスライドして保持し、離してください。", - "ui.space_mouse_step_tilt_back": "キャップを後ろに傾け(手前の縁を上げて)保持し、離してください。", - "ui.space_mouse_step_tilt_forward": "キャップを前に傾け(手前の縁を下げて)保持し、離してください。", - "ui.space_mouse_step_tilt_left": "キャップを左に傾け(左の縁を下げて)保持し、離してください。", - "ui.space_mouse_step_tilt_right": "キャップを右に傾け(右の縁を下げて)保持し、離してください。", - "ui.space_mouse_step_twist_clockwise": "キャップを時計回りにひねって保持し、離してください。", - "ui.space_mouse_step_twist_counterclockwise": "キャップを反時計回りにひねって保持し、離してください。", - "ui.space_mouse_view_axis": "ビューの軸", - "ui.space_mouse_view_off": "オフ", - "ui.space_mouse_view_off_tip": "3D マウスのドライバーのプロファイルがこの軸をオフにしているため、この軸の動きではビューは動きません。ここで他の軸の動きを選ぶと 2 つが入れ替わり、その動きではビューが動かなくなります。", - "ui.space_mouse_view_orbit_sideways": "左右オービット", - "ui.space_mouse_view_orbit_up_down": "上下オービット", - "ui.space_mouse_view_pan_sideways": "左右パン", - "ui.space_mouse_view_pan_up_down": "上下パン", - "ui.space_mouse_view_roll": "ロール", - "ui.space_mouse_view_unmapped": "軸 {n}", - "ui.space_mouse_view_zoom": "ズーム", "ui.spacing": "間隔", "ui.spacing_2": "間隔 2", "ui.sphere": "球", @@ -2219,7 +2323,7 @@ "ui.top": "上面", "ui.torus": "トーラス", "ui.total_angle": "合計角度", - "ui.trackpad_pan_tip": "トラックパッドのジェスチャーでパンします(オフの場合はマウスホイールと同様にズームします)。マウスホイールはズーム、サイドホイールは横方向のパン、ピンチ操作は常にズームになります。", + "ui.trackpad_pan_tip": "2本指スクロールでパンします。FusionではShift + 2本指スクロールで回転します。オフの場合はスクロールでズームします。マウスホイールとピンチ操作はズーム、サイドホイールは横方向のパンになります。", "ui.trim": "トリム", "ui.twist": "ねじれ", "ui.two_finger_scroll_pans": "2本指スクロールでパン", diff --git a/assets/locales/ko.json b/assets/locales/ko.json index 828a598f10..ebf91c8145 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -531,6 +531,10 @@ "cmd.file_insert_step": "STEP 삽입", "cmd.file_save_mesh": "메쉬로 저장", "cmd.file_slicers": "설치된 슬라이서", + "cmd.frame_member": "프레임 부재", + "cmd.frame_profile_insert": "프로파일 삽입", + "cmd.frame_profile_list": "프로파일 라이브러리", + "cmd.frame_profile_properties": "프로파일 속성", "cmd.inspect_accessibility": "접근성 분석", "cmd.inspect_center_of_mass": "무게중심", "cmd.inspect_clearance": "여유 간격", @@ -1070,6 +1074,8 @@ "settings.msaa_applies_on_restart": "안티앨리어싱은 다음에 SolidCraft를 시작할 때 적용됩니다", "settings.msaa_reset": "{setting}: {requested}×는 이 그래픽 장치에서 지원되지 않아 {reset}×로 변경했습니다(지원: {supported}).", "settings.msaa_unsupported": "이 그래픽 장치에서 지원되지 않음", + "settings.reference_geometry_through_bodies": "바디를 통해 참조 형상 표시", + "settings.reference_geometry_through_bodies_help": "비활성 스케치 윤곽과 기준 평면을 음영 처리된 바디 위에 그립니다", "settings.reset_intro": "다음 설정은 이 컴퓨터에서 작동하지 않아 SolidCraft를 시작할 수 없게 하므로 변경되었습니다:", "settings.reset_title": "설정이 변경되었습니다", "settings.supersampling_note": "뷰를 더 높은 해상도로 그린 뒤 축소합니다. 4K 미만 화면에서 모서리, 선, 그리드가 더 매끄러워집니다. GPU 시간이 듭니다: 1.5×에서 약 2.25배, 2×에서 4배의 픽셀.", @@ -1125,7 +1131,106 @@ "trace.stopped_by_contact": "샘플 {sample}에서 끝남: {a}이(가) {b}을(를) 통과합니다", "trace.stopped_by_loop": "샘플 {sample}에서 끝남: {joint}을(를) 지나는 루프를 더 이상 닫을 수 없습니다", "ui.1_degree_of_freedom": "자유도 1개", + "ui.3d_mouse_axes_reassigned": "축이 환경설정에서 다시 지정되어 있으며, 이 보정 후에도 적용됩니다.", + "ui.3d_mouse_button": "버튼 {number}", + "ui.3d_mouse_button_nothing": "없음", + "ui.3d_mouse_button_settings": "3D 마우스 설정", + "ui.3d_mouse_buttons": "버튼", + "ui.3d_mouse_buttons_hint": "3D 마우스의 버튼을 누르면 여기에 나타납니다. 동작을 고르세요.", + "ui.3d_mouse_buttons_no_device": "버튼 동작을 정하려면 3D 마우스를 연결하세요.", + "ui.3d_mouse_buttons_reset": "기본 버튼으로 되돌리기", + "ui.3d_mouse_calibrate": "보정…", + "ui.3d_mouse_calibrate_tip": "창의 안내대로 캡을 움직이면 SolidCraft가 이 3D 마우스의 각 축이 어느 방향으로 얼마나 움직이는지 학습합니다. 이 3D 마우스에 저장됩니다.", + "ui.3d_mouse_calibrate_title": "3D 마우스 보정", + "ui.3d_mouse_calibrate_wait": "SolidCraft가 인식할 수 있도록 3D 마우스를 한 번 움직여 주세요.", + "ui.3d_mouse_calibrated": "보정됨", + "ui.3d_mouse_calibration": "3D 마우스 보정", + "ui.3d_mouse_calibration_done": "완료했습니다. 저장하면 이 3D 마우스를 연결할 때마다 사용됩니다.", + "ui.3d_mouse_calibration_no_response": "거의 움직이지 않은 축이 있어 보정할 수 없습니다. 처음부터 다시 하고 매번 캡을 더 멀리 움직이세요.", + "ui.3d_mouse_circles_help": "원은 보정 후 장치가 보내는 값을 보여 줍니다. 테스트 모델은 반전, 속도, 축 할당을 거쳐 화면이 그 값으로 하는 일을 보여 줍니다.", + "ui.3d_mouse_dead_zone": "데드존", + "ui.3d_mouse_dead_zone_tip": "뷰가 움직이기 전에 캡이 움직여야 하는 정도로, 전체 이동 범위에 대한 비율입니다. 캡을 건드리지 않았는데 뷰가 움직이면 높이세요.", + "ui.3d_mouse_denied": "SolidCraft가 3D 마우스를 찾았지만 읽을 권한이 없습니다. udev 규칙은 3D 마우스 설정 안내를 참고하세요.", + "ui.3d_mouse_denied_short": "찾았지만 읽을 수 없음", + "ui.3d_mouse_driven_by": "이 축을 움직이는 장치의 보정된 동작입니다. 다른 축이 이미 쓰는 동작을 고르면 둘이 서로 바뀝니다.", + "ui.3d_mouse_driven_by_label": "제어 동작", + "ui.3d_mouse_driver": "3D 마우스 드라이버", + "ui.3d_mouse_driver_auto": "자동", + "ui.3d_mouse_driver_direct": "직접 (hidraw)", + "ui.3d_mouse_driver_fallback": "지금은 spacenavd를 통해 읽는 중입니다. 장치 자체를 열 수 없었습니다.", + "ui.3d_mouse_driver_only_direct": "장치를 직접 읽는 중 (spacenavd가 실행 중이 아님)", + "ui.3d_mouse_driver_only_spacenavd": "spacenavd로 읽는 중 (다른 방법이 없음)", + "ui.3d_mouse_forget": "보정 삭제", + "ui.3d_mouse_ghost_left_viewport": "뷰포트 왼쪽", + "ui.3d_mouse_ghost_left_window": "창 왼쪽", + "ui.3d_mouse_ghost_position": "위치", + "ui.3d_mouse_ghost_right_viewport": "뷰포트 오른쪽", + "ui.3d_mouse_hint": "뷰를 함께 이동하고 회전합니다. 스케치는 이동과 확대/축소만 사용합니다.", + "ui.3d_mouse_kind_cap": "중앙 복귀", + "ui.3d_mouse_kind_question": "어떤 종류의 3D 마우스인가요?", + "ui.3d_mouse_kind_trackball": "트랙볼", + "ui.3d_mouse_looks_like_cap": "잡고 있는 동안 값이 그대로 유지됩니다. ‘중앙 복귀’ 방식 장치처럼요.", + "ui.3d_mouse_looks_like_trackball": "움직임을 멈추면 바로 값이 돌아옵니다. 트랙볼처럼요.", + "ui.3d_mouse_motion_rotation_x": "회전 X", + "ui.3d_mouse_motion_rotation_y": "회전 Y", + "ui.3d_mouse_motion_rotation_z": "회전 Z", + "ui.3d_mouse_motion_translation_x": "이동 X", + "ui.3d_mouse_motion_translation_y": "이동 Y", + "ui.3d_mouse_motion_translation_z": "이동 Z", + "ui.3d_mouse_navigate": "3D 마우스로 탐색", + "ui.3d_mouse_none": "3D 마우스를 찾을 수 없음", + "ui.3d_mouse_not_calibrated": "보정 안 됨", + "ui.3d_mouse_offer": "3D 마우스를 보정하면 바뀌거나 반전된 축이 바로잡히고 속도와 조작감이 맞춰집니다.", + "ui.3d_mouse_offer_never": "다시 표시하지 않음", + "ui.3d_mouse_offer_swap": "Hidraw(직접 드라이버)로 전환", + "ui.3d_mouse_reading": "읽은 값", + "ui.3d_mouse_reset_axes": "축 지정 초기화", + "ui.3d_mouse_reset_view": "뷰 초기화", + "ui.3d_mouse_restore": "3D 마우스 기본값 복원", + "ui.3d_mouse_restore_ask": "3D 마우스 설정을 기본값으로 되돌릴까요? 보정과 버튼 할당은 그대로 유지됩니다.", + "ui.3d_mouse_section": "3D 마우스", + "ui.3d_mouse_show_in_view": "3D 뷰에 3D 마우스 표시", + "ui.3d_mouse_show_in_view_tip": "왼쪽 아래에 3D 마우스 그림을 띄워 움직임을 따라 보여 줍니다.", + "ui.3d_mouse_skip": "내 장치는 이 동작을 못 해요", + "ui.3d_mouse_skip_tip": "이 동작을 양방향 모두 건너뛰고 보정에서 뺍니다.", "ui.3d_mouse_speed": "3D 마우스 속도", + "ui.3d_mouse_step_lift": "캡을 위로 들어 유지한 다음 놓으세요.", + "ui.3d_mouse_step_press": "캡을 아래로 눌러 유지한 다음 놓으세요.", + "ui.3d_mouse_step_pull": "캡을 몸 쪽으로 당겨 유지한 다음 놓으세요.", + "ui.3d_mouse_step_push": "캡을 앞쪽으로 밀어 유지한 다음 놓으세요.", + "ui.3d_mouse_step_rest": "캡에서 손을 떼고 가만히 두세요.", + "ui.3d_mouse_step_rest_trackball": "볼에서 손을 떼고 가만히 두세요.", + "ui.3d_mouse_step_ring_clockwise": "스크롤 링을 시계 방향으로 몇 칸 돌리세요.", + "ui.3d_mouse_step_ring_counterclockwise": "스크롤 링을 시계 반대 방향으로 몇 칸 돌리세요.", + "ui.3d_mouse_step_roll_away": "볼을 바깥쪽으로 몇 번 굴린 다음 멈추게 두세요.", + "ui.3d_mouse_step_roll_left": "볼을 왼쪽으로 몇 번 굴린 다음 멈추게 두세요.", + "ui.3d_mouse_step_roll_right": "볼을 오른쪽으로 몇 번 굴린 다음 멈추게 두세요.", + "ui.3d_mouse_step_roll_toward": "볼을 몸 쪽으로 몇 번 굴린 다음 멈추게 두세요.", + "ui.3d_mouse_step_slide_left": "캡을 왼쪽으로 밀어 유지한 다음 놓으세요.", + "ui.3d_mouse_step_slide_right": "캡을 오른쪽으로 밀어 유지한 다음 놓으세요.", + "ui.3d_mouse_step_spin_clockwise": "볼을 시계 방향으로 몇 번 돌린 다음 멈추게 두세요.", + "ui.3d_mouse_step_spin_counterclockwise": "볼을 시계 반대 방향으로 몇 번 돌린 다음 멈추게 두세요.", + "ui.3d_mouse_step_tilt_away": "캡 윗부분을 바깥쪽으로 기울여(가까운 쪽 가장자리가 올라감) 유지한 다음 놓으세요.", + "ui.3d_mouse_step_tilt_left": "캡을 왼쪽으로 기울여(왼쪽 가장자리를 아래로) 유지한 다음 놓으세요.", + "ui.3d_mouse_step_tilt_right": "캡을 오른쪽으로 기울여(오른쪽 가장자리를 아래로) 유지한 다음 놓으세요.", + "ui.3d_mouse_step_tilt_toward": "캡 윗부분을 몸 쪽으로 기울여(가까운 쪽 가장자리가 내려감) 유지한 다음 놓으세요.", + "ui.3d_mouse_step_touch": "캡을 누르지 말고 손만 올려 두세요.", + "ui.3d_mouse_step_twist_clockwise": "캡을 시계 방향으로 돌려 유지한 다음 놓으세요.", + "ui.3d_mouse_step_twist_counterclockwise": "캡을 시계 반대 방향으로 돌려 유지한 다음 놓으세요.", + "ui.3d_mouse_switch_cap": "중앙 복귀형으로 보정", + "ui.3d_mouse_switch_trackball": "트랙볼로 보정", + "ui.3d_mouse_view_axis": "뷰 축", + "ui.3d_mouse_view_off": "끔", + "ui.3d_mouse_view_off_tip": "3D 마우스 드라이버 프로필이 이 축을 끄므로, 이 축의 동작으로는 뷰가 움직이지 않습니다. 여기서 다른 축의 동작을 고르면 둘이 바뀌고, 그 동작은 더 이상 뷰를 움직이지 않습니다.", + "ui.3d_mouse_view_orbit_sideways": "좌우 궤도", + "ui.3d_mouse_view_orbit_up_down": "상하 궤도", + "ui.3d_mouse_view_pan_sideways": "좌우 이동", + "ui.3d_mouse_view_pan_up_down": "상하 이동", + "ui.3d_mouse_view_roll": "롤", + "ui.3d_mouse_view_unmapped": "축 {n}", + "ui.3d_mouse_view_zoom": "확대/축소", + "ui.3d_mouse_waiting": "움직임 대기 중", + "ui.3d_mouse_wiggle": "연결되어 있다면 살짝 움직여 보세요.", "ui.3d_sketch": "3D 스케치", "ui.activate": "활성화", "ui.active_component": "활성 컴포넌트", @@ -1355,6 +1460,7 @@ "ui.blocked": "막힘", "ui.bodies": "바디", "ui.bodies_all_if_none": "바디 (없으면 전체)", + "ui.bottom": "저면", "ui.box": "박스", "ui.break": "끊기", "ui.browser_bodies_one": "{count}개 바디", @@ -1769,6 +1875,12 @@ "ui.sent_to_slicer": "{slicer}(으)로 보냈습니다", "ui.sheet_gap": "틈", "ui.shell": "쉘", + "ui.shortcut_capture_add_key": "일반 키 하나를 추가하세요. Esc를 누르면 취소됩니다.", + "ui.shortcut_capture_hint": "단축키를 클릭한 다음 보조 키와 일반 키 하나를 누르세요. 1초 이내에 보조 키를 추가하고 모든 키를 놓아 확정하세요. Esc를 누르면 취소됩니다.", + "ui.shortcut_capture_press": "보조 키와 일반 키 하나를 누르세요. Esc를 누르면 취소됩니다.", + "ui.shortcut_capture_release": "모든 키를 놓아 확정하세요. Esc를 누르면 취소됩니다.", + "ui.shortcut_capture_settling": "단축키 확인 중… Esc를 누르면 취소됩니다.", + "ui.shortcut_multiple_keys": "보조 키와 일반 키 하나만 사용하세요. 단축키를 클릭하여 다시 시도하세요.", "ui.show": "표시", "ui.showAll": "모두 표시", "ui.showAllBodies": "모두 표시", @@ -1798,59 +1910,6 @@ "ui.smooth_wheel_zoom": "부드러운 휠 확대/축소", "ui.smooth_wheel_zoom_tip": "끄면 휠 이벤트를 부드러운 꼬리 없이 즉시 적용합니다", "ui.snap": "스냅", - "ui.space_mouse_axes_reassigned": "축이 환경설정에서 다시 지정되어 있으며, 이 보정 후에도 적용됩니다.", - "ui.space_mouse_calibrate": "보정…", - "ui.space_mouse_calibrate_tip": "창의 안내대로 캡을 움직이면 SolidCraft가 이 3D 마우스의 각 축이 어느 방향으로 얼마나 움직이는지 학습합니다. 이 3D 마우스에 저장됩니다.", - "ui.space_mouse_calibrate_title": "3D 마우스 보정", - "ui.space_mouse_calibrated": "보정됨", - "ui.space_mouse_calibration": "3D 마우스 보정", - "ui.space_mouse_calibration_done": "완료했습니다. 저장하면 이 3D 마우스를 연결할 때마다 사용됩니다.", - "ui.space_mouse_calibration_no_response": "거의 움직이지 않은 축이 있어 보정할 수 없습니다. 처음부터 다시 하고 매번 캡을 더 멀리 움직이세요.", - "ui.space_mouse_dead_zone": "데드존", - "ui.space_mouse_dead_zone_tip": "보기가 움직이기 전에 캡이 움직여야 하는 정도로, 전체 이동 범위에 대한 비율입니다. 캡을 건드리지 않았는데 보기가 움직이면 높이세요.", - "ui.space_mouse_driven_by": "이 축을 움직이는 장치의 보정된 동작입니다. 다른 축이 이미 쓰는 동작을 고르면 둘이 서로 바뀝니다.", - "ui.space_mouse_driven_by_label": "제어 동작", - "ui.space_mouse_driver": "3D 마우스 드라이버", - "ui.space_mouse_driver_auto": "자동", - "ui.space_mouse_driver_direct": "직접 (hidraw)", - "ui.space_mouse_driver_only_direct": "장치를 직접 읽는 중 (spacenavd가 실행 중이 아님)", - "ui.space_mouse_driver_only_spacenavd": "spacenavd로 읽는 중 (다른 방법이 없음)", - "ui.space_mouse_forget": "보정 삭제", - "ui.space_mouse_hint": "뷰를 함께 이동하고 회전합니다. 스케치는 이동과 확대/축소만 사용합니다.", - "ui.space_mouse_motion_rotation_x": "회전 X", - "ui.space_mouse_motion_rotation_y": "회전 Y", - "ui.space_mouse_motion_rotation_z": "회전 Z", - "ui.space_mouse_motion_translation_x": "이동 X", - "ui.space_mouse_motion_translation_y": "이동 Y", - "ui.space_mouse_motion_translation_z": "이동 Z", - "ui.space_mouse_navigate": "SpaceNavigator / SpaceMouse로 탐색", - "ui.space_mouse_none": "3D 마우스를 찾을 수 없음", - "ui.space_mouse_not_calibrated": "보정 안 됨", - "ui.space_mouse_reading": "읽은 값", - "ui.space_mouse_reset_axes": "축 지정 초기화", - "ui.space_mouse_step_lift": "캡을 위로 들어 유지한 다음 놓으세요.", - "ui.space_mouse_step_press": "캡을 아래로 눌러 유지한 다음 놓으세요.", - "ui.space_mouse_step_pull": "캡을 몸 쪽으로 당겨 유지한 다음 놓으세요.", - "ui.space_mouse_step_push": "캡을 앞쪽으로 밀어 유지한 다음 놓으세요.", - "ui.space_mouse_step_rest": "캡에서 손을 떼고 가만히 두세요.", - "ui.space_mouse_step_slide_left": "캡을 왼쪽으로 밀어 유지한 다음 놓으세요.", - "ui.space_mouse_step_slide_right": "캡을 오른쪽으로 밀어 유지한 다음 놓으세요.", - "ui.space_mouse_step_tilt_back": "캡을 뒤로 기울여(앞쪽 가장자리를 위로) 유지한 다음 놓으세요.", - "ui.space_mouse_step_tilt_forward": "캡을 앞으로 기울여(앞쪽 가장자리를 아래로) 유지한 다음 놓으세요.", - "ui.space_mouse_step_tilt_left": "캡을 왼쪽으로 기울여(왼쪽 가장자리를 아래로) 유지한 다음 놓으세요.", - "ui.space_mouse_step_tilt_right": "캡을 오른쪽으로 기울여(오른쪽 가장자리를 아래로) 유지한 다음 놓으세요.", - "ui.space_mouse_step_twist_clockwise": "캡을 시계 방향으로 돌려 유지한 다음 놓으세요.", - "ui.space_mouse_step_twist_counterclockwise": "캡을 시계 반대 방향으로 돌려 유지한 다음 놓으세요.", - "ui.space_mouse_view_axis": "보기 축", - "ui.space_mouse_view_off": "끔", - "ui.space_mouse_view_off_tip": "3D 마우스 드라이버 프로필이 이 축을 끄므로, 이 축의 동작으로는 뷰가 움직이지 않습니다. 여기서 다른 축의 동작을 고르면 둘이 바뀌고, 그 동작은 더 이상 뷰를 움직이지 않습니다.", - "ui.space_mouse_view_orbit_sideways": "좌우 궤도", - "ui.space_mouse_view_orbit_up_down": "상하 궤도", - "ui.space_mouse_view_pan_sideways": "좌우 이동", - "ui.space_mouse_view_pan_up_down": "상하 이동", - "ui.space_mouse_view_roll": "롤", - "ui.space_mouse_view_unmapped": "축 {n}", - "ui.space_mouse_view_zoom": "확대/축소", "ui.spacing": "간격", "ui.spacing_2": "간격 2", "ui.sphere": "구", @@ -1892,7 +1951,7 @@ "ui.top": "상면", "ui.torus": "토러스", "ui.total_angle": "전진 각도", - "ui.trackpad_pan_tip": "트랙패드 제스처로 이동(끔: 휠처럼 확대/축소). 마우스 휠은 확대/축소, 엄지 휠은 좌우 이동, 핀치는 항상 확대/축소합니다", + "ui.trackpad_pan_tip": "두 손가락 스크롤로 화면을 이동합니다. Fusion에서는 Shift + 두 손가락 스크롤로 회전합니다. 끄면 스크롤로 확대/축소합니다. 마우스 휠과 핀치는 확대/축소하고, 엄지 휠은 좌우로 이동합니다.", "ui.trim": "트림", "ui.twist": "비틀림", "ui.two_finger_scroll_pans": "두 손가락 스크롤로 이동", diff --git a/assets/locales/ms.json b/assets/locales/ms.json index 5a13055b11..68e76b85e2 100644 --- a/assets/locales/ms.json +++ b/assets/locales/ms.json @@ -530,6 +530,10 @@ "cmd.file_insert_step": "Sisip STEP", "cmd.file_save_mesh": "Simpan sebagai Mesh", "cmd.file_slicers": "Penghiris Terpasang", + "cmd.frame_member": "Anggota Rangka", + "cmd.frame_profile_insert": "Sisipkan Profil", + "cmd.frame_profile_list": "Pustaka Profil", + "cmd.frame_profile_properties": "Sifat Profil", "cmd.inspect_accessibility": "Analisis Kebolehcapaian", "cmd.inspect_center_of_mass": "Pusat Jisim", "cmd.inspect_clearance": "Kelegaan", @@ -1121,7 +1125,106 @@ "trace.stopped_by_contact": "Berakhir pada sampel {sample}: {a} akan menembusi {b}", "trace.stopped_by_loop": "Berakhir pada sampel {sample}: gelung melalui {joint} tidak lagi dapat ditutup", "ui.1_degree_of_freedom": "1 darjah kebebasan", + "ui.3d_mouse_axes_reassigned": "Paksi ditetapkan semula dalam Keutamaan, dan itu masih terpakai di atas penentukuran ini.", + "ui.3d_mouse_button": "Butang {number}", + "ui.3d_mouse_button_nothing": "Tiada", + "ui.3d_mouse_button_settings": "Tetapan tetikus 3D", + "ui.3d_mouse_buttons": "Butang", + "ui.3d_mouse_buttons_hint": "Tekan butang pada tetikus 3D anda untuk mencarinya di sini, kemudian pilih fungsinya.", + "ui.3d_mouse_buttons_no_device": "Sambungkan tetikus 3D untuk menetapkan fungsi butangnya.", + "ui.3d_mouse_buttons_reset": "Pulihkan butang lalai", + "ui.3d_mouse_calibrate": "Tentukur…", + "ui.3d_mouse_calibrate_tip": "Gerakkan penutup seperti yang diminta tetingkap, supaya SolidCraft mempelajari arah dan jarak setiap paksi tetikus 3D ini. Disimpan untuk tetikus 3D ini.", + "ui.3d_mouse_calibrate_title": "Tentukur tetikus 3D", + "ui.3d_mouse_calibrate_wait": "Gerakkan tetikus 3D sekali supaya SolidCraft dapat melihatnya.", + "ui.3d_mouse_calibrated": "Telah ditentukur", + "ui.3d_mouse_calibration": "Penentukuran tetikus 3D", + "ui.3d_mouse_calibration_done": "Selesai. Simpan untuk digunakan setiap kali tetikus 3D ini disambungkan.", + "ui.3d_mouse_calibration_no_response": "Satu paksi hampir tidak bergerak, jadi tidak dapat ditentukur. Mulakan semula dan gerakkan penutup lebih jauh setiap kali.", + "ui.3d_mouse_circles_help": "Bulatan menunjukkan apa yang dihantar peranti selepas penentukuran. Model ujian menunjukkan apa yang dilakukan paparan dengannya, selepas songsangan, kelajuan dan penetapan paksi.", + "ui.3d_mouse_dead_zone": "Zon mati", + "ui.3d_mouse_dead_zone_tip": "Sejauh mana penutup perlu bergerak sebelum pandangan bergerak, sebagai bahagian daripada julat penuhnya. Naikkan jika pandangan hanyut semasa penutup dalam keadaan rehat.", + "ui.3d_mouse_denied": "SolidCraft nampak tetikus 3D tetapi tidak dibenarkan membacanya. Lihat nota persediaan tetikus 3D untuk peraturan udev.", + "ui.3d_mouse_denied_short": "Dijumpai, tetapi tidak boleh dibaca", + "ui.3d_mouse_driven_by": "Gerakan peranti yang telah ditentukur yang memacu paksi ini. Memilih gerakan yang sudah digunakan paksi lain akan menukar kedua-duanya.", + "ui.3d_mouse_driven_by_label": "dipacu oleh", + "ui.3d_mouse_driver": "Pemacu tetikus 3D", + "ui.3d_mouse_driver_auto": "Automatik", + "ui.3d_mouse_driver_direct": "Terus (hidraw)", + "ui.3d_mouse_driver_fallback": "Buat masa ini dibaca melalui spacenavd: peranti itu sendiri tidak dapat dibuka.", + "ui.3d_mouse_driver_only_direct": "Membaca peranti secara terus; spacenavd tidak berjalan", + "ui.3d_mouse_driver_only_spacenavd": "Membaca melalui spacenavd, satu-satunya cara yang ada", + "ui.3d_mouse_forget": "Lupakan penentukuran", + "ui.3d_mouse_ghost_left_viewport": "Viewport kiri", + "ui.3d_mouse_ghost_left_window": "Tetingkap kiri", + "ui.3d_mouse_ghost_position": "Kedudukan", + "ui.3d_mouse_ghost_right_viewport": "Viewport kanan", + "ui.3d_mouse_hint": "Alih dan putar pandangan serentak; dalam lakaran, hanya sorot dan zum digunakan.", + "ui.3d_mouse_kind_cap": "Kembali ke tengah", + "ui.3d_mouse_kind_question": "Apakah jenis tetikus 3D ini?", + "ui.3d_mouse_kind_trackball": "Bola jejak", + "ui.3d_mouse_looks_like_cap": "Bacaan kekal selagi anda menahannya, seperti peranti jenis “Kembali ke tengah”.", + "ui.3d_mouse_looks_like_trackball": "Bacaan kembali sebaik sahaja anda berhenti bergerak, seperti bola jejak.", + "ui.3d_mouse_motion_rotation_x": "Putaran X", + "ui.3d_mouse_motion_rotation_y": "Putaran Y", + "ui.3d_mouse_motion_rotation_z": "Putaran Z", + "ui.3d_mouse_motion_translation_x": "Anjakan X", + "ui.3d_mouse_motion_translation_y": "Anjakan Y", + "ui.3d_mouse_motion_translation_z": "Anjakan Z", + "ui.3d_mouse_navigate": "Navigasi dengan tetikus 3D", + "ui.3d_mouse_none": "Tiada tetikus 3D ditemui", + "ui.3d_mouse_not_calibrated": "Belum ditentukur", + "ui.3d_mouse_offer": "Menentukur tetikus 3D membetulkan paksi yang bertukar atau terbalik dan menetapkan kelajuan serta rasanya.", + "ui.3d_mouse_offer_never": "Jangan tunjuk lagi", + "ui.3d_mouse_offer_swap": "Tukar ke Hidraw (pemacu terus)", + "ui.3d_mouse_reading": "Bacaan", + "ui.3d_mouse_reset_axes": "Tetapkan semula penetapan paksi", + "ui.3d_mouse_reset_view": "Set semula paparan", + "ui.3d_mouse_restore": "Pulihkan lalai tetikus 3D", + "ui.3d_mouse_restore_ask": "Tetapkan semula tetapan tetikus 3D kepada lalai? Penentukuran dan pemetaan butang kekal.", + "ui.3d_mouse_section": "Tetikus 3D", + "ui.3d_mouse_show_in_view": "Tunjukkan tetikus 3D dalam paparan 3D", + "ui.3d_mouse_show_in_view_tip": "Gambar tetikus 3D anda di sudut kiri bawah, yang bergerak apabila anda menggerakkannya.", + "ui.3d_mouse_skip": "Peranti saya tidak boleh buat ini", + "ui.3d_mouse_skip_tip": "Langkau gerakan ini kedua-dua arah; penentukuran tidak menggunakannya.", "ui.3d_mouse_speed": "Kelajuan tetikus 3D", + "ui.3d_mouse_step_lift": "Angkat penutup ke atas, tahan, kemudian lepaskan.", + "ui.3d_mouse_step_press": "Tekan penutup ke bawah, tahan, kemudian lepaskan.", + "ui.3d_mouse_step_pull": "Tarik penutup ke arah anda, tahan, kemudian lepaskan.", + "ui.3d_mouse_step_push": "Tolak penutup menjauhi anda, tahan, kemudian lepaskan.", + "ui.3d_mouse_step_rest": "Alihkan tangan anda daripada penutup dan biarkan ia diam.", + "ui.3d_mouse_step_rest_trackball": "Alihkan tangan anda daripada bola dan biarkan ia diam.", + "ui.3d_mouse_step_ring_clockwise": "Putar gelang tatal ikut arah jam beberapa klik.", + "ui.3d_mouse_step_ring_counterclockwise": "Putar gelang tatal lawan arah jam beberapa klik.", + "ui.3d_mouse_step_roll_away": "Gulingkan bola menjauhi anda beberapa kali, kemudian biarkan ia berhenti.", + "ui.3d_mouse_step_roll_left": "Gulingkan bola ke kiri beberapa kali, kemudian biarkan ia berhenti.", + "ui.3d_mouse_step_roll_right": "Gulingkan bola ke kanan beberapa kali, kemudian biarkan ia berhenti.", + "ui.3d_mouse_step_roll_toward": "Gulingkan bola ke arah anda beberapa kali, kemudian biarkan ia berhenti.", + "ui.3d_mouse_step_slide_left": "Luncurkan penutup ke kiri, tahan, kemudian lepaskan.", + "ui.3d_mouse_step_slide_right": "Luncurkan penutup ke kanan, tahan, kemudian lepaskan.", + "ui.3d_mouse_step_spin_clockwise": "Pusing bola ikut arah jam beberapa kali, kemudian biarkan ia berhenti.", + "ui.3d_mouse_step_spin_counterclockwise": "Pusing bola lawan arah jam beberapa kali, kemudian biarkan ia berhenti.", + "ui.3d_mouse_step_tilt_away": "Condongkan bahagian atas penutup menjauhi anda (tepi terdekat naik), tahan, kemudian lepaskan.", + "ui.3d_mouse_step_tilt_left": "Condongkan penutup ke kiri (tepi kiri turun), tahan, kemudian lepaskan.", + "ui.3d_mouse_step_tilt_right": "Condongkan penutup ke kanan (tepi kanan turun), tahan, kemudian lepaskan.", + "ui.3d_mouse_step_tilt_toward": "Condongkan bahagian atas penutup ke arah anda (tepi terdekat turun), tahan, kemudian lepaskan.", + "ui.3d_mouse_step_touch": "Letakkan tangan anda pada penutup tanpa menolaknya.", + "ui.3d_mouse_step_twist_clockwise": "Pusing penutup ikut arah jam, tahan, kemudian lepaskan.", + "ui.3d_mouse_step_twist_counterclockwise": "Pusing penutup lawan arah jam, tahan, kemudian lepaskan.", + "ui.3d_mouse_switch_cap": "Tentukur sebagai kembali ke tengah", + "ui.3d_mouse_switch_trackball": "Tentukur sebagai bola jejak", + "ui.3d_mouse_view_axis": "Paksi paparan", + "ui.3d_mouse_view_off": "Mati", + "ui.3d_mouse_view_off_tip": "Profil pemacu tetikus 3D mematikan paksi ini, jadi gerakannya tidak menggerakkan paparan. Memilih gerakan paksi lain di sini menukar kedua-duanya, dan gerakan itu tidak lagi menggerakkan paparan.", + "ui.3d_mouse_view_orbit_sideways": "Orbit kiri / kanan", + "ui.3d_mouse_view_orbit_up_down": "Orbit atas / bawah", + "ui.3d_mouse_view_pan_sideways": "Pan kiri / kanan", + "ui.3d_mouse_view_pan_up_down": "Pan atas / bawah", + "ui.3d_mouse_view_roll": "Gelek", + "ui.3d_mouse_view_unmapped": "Paksi {n}", + "ui.3d_mouse_view_zoom": "Zum", + "ui.3d_mouse_waiting": "Menunggu pergerakan", + "ui.3d_mouse_wiggle": "Jika anda ada yang disambungkan, gerakkannya sedikit.", "ui.3d_sketch": "Lakaran 3D", "ui.activate": "Aktifkan", "ui.active_component": "Komponen aktif", @@ -1351,6 +1454,7 @@ "ui.blocked": "Disekat", "ui.bodies": "Jasad", "ui.bodies_all_if_none": "Jasad (semua jika tiada)", + "ui.bottom": "Bawah", "ui.box": "Kotak", "ui.break": "Putuskan", "ui.browser_bodies_one": "{count} jasad", @@ -1794,34 +1898,6 @@ "ui.smooth_wheel_zoom": "Zum roda lancar", "ui.smooth_wheel_zoom_tip": "Matikan untuk mengezum serta-merta bagi setiap putaran roda tetikus, tanpa gerakan susulan", "ui.snap": "Lekap", - "ui.space_mouse_calibrate": "Tentukur…", - "ui.space_mouse_calibrate_tip": "Gerakkan penutup seperti yang diminta tetingkap, supaya SolidCraft mempelajari arah dan jarak setiap paksi tetikus 3D ini. Disimpan untuk tetikus 3D ini.", - "ui.space_mouse_calibrate_title": "Tentukur tetikus 3D", - "ui.space_mouse_calibrated": "Telah ditentukur", - "ui.space_mouse_calibration": "Penentukuran tetikus 3D", - "ui.space_mouse_calibration_done": "Selesai. Simpan untuk digunakan setiap kali tetikus 3D ini disambungkan.", - "ui.space_mouse_calibration_no_response": "Satu paksi hampir tidak bergerak, jadi tidak dapat ditentukur. Mulakan semula dan gerakkan penutup lebih jauh setiap kali.", - "ui.space_mouse_dead_zone": "Zon mati", - "ui.space_mouse_dead_zone_tip": "Sejauh mana penutup perlu bergerak sebelum pandangan bergerak, sebagai bahagian daripada julat penuhnya. Naikkan jika pandangan hanyut semasa penutup dalam keadaan rehat.", - "ui.space_mouse_forget": "Lupakan penentukuran", - "ui.space_mouse_hint": "Alih dan putar pandangan serentak; dalam lakaran, hanya sorot dan zum digunakan.", - "ui.space_mouse_navigate": "Navigasi dengan SpaceNavigator / SpaceMouse", - "ui.space_mouse_none": "Tiada tetikus 3D ditemui", - "ui.space_mouse_not_calibrated": "Belum ditentukur", - "ui.space_mouse_reading": "Bacaan", - "ui.space_mouse_step_lift": "Angkat penutup ke atas, tahan, kemudian lepaskan.", - "ui.space_mouse_step_press": "Tekan penutup ke bawah, tahan, kemudian lepaskan.", - "ui.space_mouse_step_pull": "Tarik penutup ke arah anda, tahan, kemudian lepaskan.", - "ui.space_mouse_step_push": "Tolak penutup menjauhi anda, tahan, kemudian lepaskan.", - "ui.space_mouse_step_rest": "Alihkan tangan anda daripada penutup dan biarkan ia diam.", - "ui.space_mouse_step_slide_left": "Luncurkan penutup ke kiri, tahan, kemudian lepaskan.", - "ui.space_mouse_step_slide_right": "Luncurkan penutup ke kanan, tahan, kemudian lepaskan.", - "ui.space_mouse_step_tilt_back": "Condongkan penutup ke belakang (tepi depan naik), tahan, kemudian lepaskan.", - "ui.space_mouse_step_tilt_forward": "Condongkan penutup ke hadapan (tepi depan turun), tahan, kemudian lepaskan.", - "ui.space_mouse_step_tilt_left": "Condongkan penutup ke kiri (tepi kiri turun), tahan, kemudian lepaskan.", - "ui.space_mouse_step_tilt_right": "Condongkan penutup ke kanan (tepi kanan turun), tahan, kemudian lepaskan.", - "ui.space_mouse_step_twist_clockwise": "Pusing penutup ikut arah jam, tahan, kemudian lepaskan.", - "ui.space_mouse_step_twist_counterclockwise": "Pusing penutup lawan arah jam, tahan, kemudian lepaskan.", "ui.spacing": "Selang", "ui.spacing_2": "Selang 2", "ui.sphere": "Sfera", diff --git a/assets/locales/nl.json b/assets/locales/nl.json index 32b410e597..1e0ae79583 100644 --- a/assets/locales/nl.json +++ b/assets/locales/nl.json @@ -529,6 +529,10 @@ "cmd.file_insert_step": "STEP invoegen", "cmd.file_save_mesh": "Opslaan als mesh", "cmd.file_slicers": "Geïnstalleerde slicers", + "cmd.frame_member": "Constructiedeel", + "cmd.frame_profile_insert": "Profiel invoegen", + "cmd.frame_profile_list": "Profielbibliotheek", + "cmd.frame_profile_properties": "Profieleigenschappen", "cmd.inspect_accessibility": "Toegankelijkheidsanalyse", "cmd.inspect_center_of_mass": "Zwaartepunt", "cmd.inspect_clearance": "Speling", @@ -1120,7 +1124,50 @@ "trace.stopped_by_contact": "Eindigt bij monster {sample}: {a} zou door {b} heen gaan", "trace.stopped_by_loop": "Eindigt bij monster {sample}: de lus via {joint} kan niet verder sluiten", "ui.1_degree_of_freedom": "1 vrijheidsgraad", + "ui.3d_mouse_calibrate": "Kalibreren…", + "ui.3d_mouse_calibrate_tip": "Beweeg de dop zoals het venster vraagt, zodat SolidCraft leert welke kant elke as van deze 3D-muis op gaat en hoe ver. Opgeslagen voor deze 3D-muis.", + "ui.3d_mouse_calibrate_title": "3D-muis kalibreren", + "ui.3d_mouse_calibrate_wait": "Beweeg de 3D-muis één keer, zodat SolidCraft hem ziet.", + "ui.3d_mouse_calibrated": "Gekalibreerd", + "ui.3d_mouse_calibration": "Kalibratie van 3D-muis", + "ui.3d_mouse_calibration_done": "Gereed. Sla op om het te gebruiken wanneer deze 3D-muis is aangesloten.", + "ui.3d_mouse_calibration_no_response": "Een as bewoog nauwelijks en kan dus niet worden gekalibreerd. Begin opnieuw en beweeg de dop telkens verder.", + "ui.3d_mouse_circles_help": "De cirkels tonen wat het apparaat na kalibratie stuurt. Het testmodel toont wat de weergave ermee doet, na omkeren, snelheid en asindeling.", + "ui.3d_mouse_dead_zone": "Dode zone", + "ui.3d_mouse_dead_zone_tip": "Hoe ver de dop moet bewegen voordat de weergave dat doet, als aandeel van de volledige slag. Verhoog dit als de weergave afdrijft terwijl de dop in rust is.", + "ui.3d_mouse_denied": "SolidCraft ziet een 3D-muis maar mag hem niet lezen. Zie de installatienotities voor de 3D-muis voor de udev-regel.", + "ui.3d_mouse_denied_short": "Gevonden, maar niet leesbaar", + "ui.3d_mouse_driver_fallback": "Voorlopig via spacenavd gelezen: het apparaat zelf kon niet worden geopend.", + "ui.3d_mouse_forget": "Kalibratie vergeten", + "ui.3d_mouse_ghost_left_viewport": "Viewport links", + "ui.3d_mouse_ghost_left_window": "Venster links", + "ui.3d_mouse_ghost_position": "Positie", + "ui.3d_mouse_ghost_right_viewport": "Viewport rechts", + "ui.3d_mouse_hint": "Verplaats en roteer de weergave samen; schetsen gebruiken alleen pannen en zoomen.", + "ui.3d_mouse_navigate": "Navigeren met de 3D-muis", + "ui.3d_mouse_none": "Geen 3D-muis gevonden", + "ui.3d_mouse_not_calibrated": "Niet gekalibreerd", + "ui.3d_mouse_offer": "Je 3D-muis kalibreren verhelpt verwisselde of omgekeerde assen en stelt snelheid en gevoel in.", + "ui.3d_mouse_offer_never": "Niet meer tonen", + "ui.3d_mouse_offer_swap": "Naar Hidraw wisselen (direct)", + "ui.3d_mouse_reading": "Uitlezen", + "ui.3d_mouse_reset_view": "Weergave resetten", + "ui.3d_mouse_restore": "3D-muis standaardwaarden herstellen", + "ui.3d_mouse_restore_ask": "De 3D-muisinstellingen terugzetten naar de standaardwaarden? Je kalibratie en knoppentoewijzing blijven staan.", "ui.3d_mouse_speed": "Snelheid van 3D-muis", + "ui.3d_mouse_step_lift": "Til de dop omhoog, houd vast en laat dan los.", + "ui.3d_mouse_step_press": "Druk de dop omlaag, houd vast en laat dan los.", + "ui.3d_mouse_step_pull": "Trek de dop naar u toe, houd vast en laat dan los.", + "ui.3d_mouse_step_push": "Duw de dop van u af, houd vast en laat dan los.", + "ui.3d_mouse_step_rest": "Haal uw hand van de dop en laat hem stilstaan.", + "ui.3d_mouse_step_slide_left": "Schuif de dop naar links, houd vast en laat dan los.", + "ui.3d_mouse_step_slide_right": "Schuif de dop naar rechts, houd vast en laat dan los.", + "ui.3d_mouse_step_tilt_left": "Kantel de dop naar links (linkerrand omlaag), houd vast en laat dan los.", + "ui.3d_mouse_step_tilt_right": "Kantel de dop naar rechts (rechterrand omlaag), houd vast en laat dan los.", + "ui.3d_mouse_step_twist_clockwise": "Draai de dop met de klok mee, houd vast en laat dan los.", + "ui.3d_mouse_step_twist_counterclockwise": "Draai de dop tegen de klok in, houd vast en laat dan los.", + "ui.3d_mouse_waiting": "Wacht op beweging", + "ui.3d_mouse_wiggle": "Als je er een hebt aangesloten, beweeg hem even.", "ui.3d_sketch": "3D-schets", "ui.activate": "Activeren", "ui.active_component": "Actief component", @@ -1793,34 +1840,6 @@ "ui.smooth_wheel_zoom": "Vloeiend zoomen met wiel", "ui.smooth_wheel_zoom_tip": "Uitschakelen om elke wielgebeurtenis direct toe te passen, zonder uitloop", "ui.snap": "Snap", - "ui.space_mouse_calibrate": "Kalibreren…", - "ui.space_mouse_calibrate_tip": "Beweeg de dop zoals het venster vraagt, zodat SolidCraft leert welke kant elke as van deze 3D-muis op gaat en hoe ver. Opgeslagen voor deze 3D-muis.", - "ui.space_mouse_calibrate_title": "3D-muis kalibreren", - "ui.space_mouse_calibrated": "Gekalibreerd", - "ui.space_mouse_calibration": "Kalibratie van 3D-muis", - "ui.space_mouse_calibration_done": "Gereed. Sla op om het te gebruiken wanneer deze 3D-muis is aangesloten.", - "ui.space_mouse_calibration_no_response": "Een as bewoog nauwelijks en kan dus niet worden gekalibreerd. Begin opnieuw en beweeg de dop telkens verder.", - "ui.space_mouse_dead_zone": "Dode zone", - "ui.space_mouse_dead_zone_tip": "Hoe ver de dop moet bewegen voordat de weergave dat doet, als aandeel van de volledige slag. Verhoog dit als de weergave afdrijft terwijl de dop in rust is.", - "ui.space_mouse_forget": "Kalibratie vergeten", - "ui.space_mouse_hint": "Verplaats en roteer de weergave samen; schetsen gebruiken alleen pannen en zoomen.", - "ui.space_mouse_navigate": "Navigeren met SpaceNavigator / SpaceMouse", - "ui.space_mouse_none": "Geen 3D-muis gevonden", - "ui.space_mouse_not_calibrated": "Niet gekalibreerd", - "ui.space_mouse_reading": "Uitlezen", - "ui.space_mouse_step_lift": "Til de dop omhoog, houd vast en laat dan los.", - "ui.space_mouse_step_press": "Druk de dop omlaag, houd vast en laat dan los.", - "ui.space_mouse_step_pull": "Trek de dop naar u toe, houd vast en laat dan los.", - "ui.space_mouse_step_push": "Duw de dop van u af, houd vast en laat dan los.", - "ui.space_mouse_step_rest": "Haal uw hand van de dop en laat hem stilstaan.", - "ui.space_mouse_step_slide_left": "Schuif de dop naar links, houd vast en laat dan los.", - "ui.space_mouse_step_slide_right": "Schuif de dop naar rechts, houd vast en laat dan los.", - "ui.space_mouse_step_tilt_back": "Kantel de dop naar achteren (voorrand omhoog), houd vast en laat dan los.", - "ui.space_mouse_step_tilt_forward": "Kantel de dop naar voren (voorrand omlaag), houd vast en laat dan los.", - "ui.space_mouse_step_tilt_left": "Kantel de dop naar links (linkerrand omlaag), houd vast en laat dan los.", - "ui.space_mouse_step_tilt_right": "Kantel de dop naar rechts (rechterrand omlaag), houd vast en laat dan los.", - "ui.space_mouse_step_twist_clockwise": "Draai de dop met de klok mee, houd vast en laat dan los.", - "ui.space_mouse_step_twist_counterclockwise": "Draai de dop tegen de klok in, houd vast en laat dan los.", "ui.spacing": "Tussenruimte", "ui.spacing_2": "Tussenruimte 2", "ui.sphere": "Bol", diff --git a/assets/locales/pl.json b/assets/locales/pl.json index 1ef280a400..5c604a6a85 100644 --- a/assets/locales/pl.json +++ b/assets/locales/pl.json @@ -578,6 +578,10 @@ "cmd.file_insert_step": "Wstaw STEP", "cmd.file_save_mesh": "Zapisz jako siatkę", "cmd.file_slicers": "Zainstalowane slicery", + "cmd.frame_member": "Element ramy", + "cmd.frame_profile_insert": "Wstaw profil", + "cmd.frame_profile_list": "Biblioteka profili", + "cmd.frame_profile_properties": "Właściwości profilu", "cmd.inspect_accessibility": "Analiza dostępności", "cmd.inspect_center_of_mass": "Środek masy", "cmd.inspect_clearance": "Prześwit", @@ -1245,6 +1249,8 @@ "settings.msaa_applies_on_restart": "Wygładzanie krawędzi zadziała po ponownym uruchomieniu SolidCraft", "settings.msaa_reset": "{setting}: {requested}× nie jest obsługiwane przez to urządzenie graficzne; ustawiono {reset}× (obsługiwane: {supported}).", "settings.msaa_unsupported": "Nieobsługiwane przez to urządzenie graficzne", + "settings.reference_geometry_through_bodies": "Pokaż geometrię odniesienia przez bryły", + "settings.reference_geometry_through_bodies_help": "Rysuj kontury nieaktywnych szkiców i płaszczyzny odniesienia na cieniowanych bryłach", "settings.reset_intro": "Te ustawienia nie działają na tym komputerze i uniemożliwiłyby uruchomienie SolidCraft, więc zostały zmienione:", "settings.reset_title": "Zmieniono ustawienia", "settings.supersampling_note": "Rysuje widok w wyższej rozdzielczości i go zmniejsza: gładsze krawędzie, linie i siatka na ekranach poniżej 4K. Kosztuje czas GPU: ok. 2,25× więcej pikseli przy 1,5× i 4× przy 2×.", @@ -1303,7 +1309,106 @@ "trace.stopped_by_contact": "Kończy się na próbce {sample}: {a} przeszedłby przez {b}", "trace.stopped_by_loop": "Kończy się na próbce {sample}: pętla przez {joint} nie może się bardziej zamknąć", "ui.1_degree_of_freedom": "1 stopień swobody", + "ui.3d_mouse_axes_reassigned": "Osie są przypisane na nowo w Ustawieniach i to nadal obowiązuje razem z tą kalibracją.", + "ui.3d_mouse_button": "Przycisk {number}", + "ui.3d_mouse_button_nothing": "Nic", + "ui.3d_mouse_button_settings": "Ustawienia myszy 3D", + "ui.3d_mouse_buttons": "Przyciski", + "ui.3d_mouse_buttons_hint": "Naciśnij przycisk na myszy 3D, aby go tu znaleźć, a potem wybierz, co ma robić.", + "ui.3d_mouse_buttons_no_device": "Podłącz mysz 3D, aby ustawić, co robią jej przyciski.", + "ui.3d_mouse_buttons_reset": "Przywróć domyślne przyciski", + "ui.3d_mouse_calibrate": "Kalibruj…", + "ui.3d_mouse_calibrate_tip": "Przesuwaj nakładkę zgodnie z poleceniami okna, aby SolidCraft nauczył się kierunku i zakresu każdej osi tej myszy 3D. Zapisywane dla tej myszy 3D.", + "ui.3d_mouse_calibrate_title": "Kalibracja myszy 3D", + "ui.3d_mouse_calibrate_wait": "Rusz raz myszą 3D, aby SolidCraft ją zobaczył.", + "ui.3d_mouse_calibrated": "Skalibrowano", + "ui.3d_mouse_calibration": "Kalibracja myszy 3D", + "ui.3d_mouse_calibration_done": "Gotowe. Zapisz, aby używać kalibracji po każdym podłączeniu tej myszy 3D.", + "ui.3d_mouse_calibration_no_response": "Jedna oś prawie się nie poruszyła, więc nie można jej skalibrować. Zacznij od nowa i za każdym razem wychylaj nakładkę mocniej.", + "ui.3d_mouse_circles_help": "Koła pokazują, co urządzenie wysyła po kalibracji. Model testowy pokazuje, co widok z tym robi po odwróceniu, szybkości i przypisaniu osi.", + "ui.3d_mouse_dead_zone": "Strefa martwa", + "ui.3d_mouse_dead_zone_tip": "O ile trzeba wychylić nakładkę, zanim widok się poruszy, jako część pełnego wychylenia. Zwiększ, jeśli widok dryfuje przy nieruszanej nakładce.", + "ui.3d_mouse_denied": "SolidCraft widzi mysz 3D, ale nie ma pozwolenia, by ją odczytać. Regułę udev opisują uwagi o konfiguracji myszy 3D.", + "ui.3d_mouse_denied_short": "Znaleziono, ale nie można odczytać", + "ui.3d_mouse_driven_by": "Który skalibrowany ruch urządzenia steruje tą osią. Wybranie ruchu używanego już przez inną oś zamienia je miejscami.", + "ui.3d_mouse_driven_by_label": "sterowana przez", + "ui.3d_mouse_driver": "Sterownik myszy 3D", + "ui.3d_mouse_driver_auto": "Automatyczny", + "ui.3d_mouse_driver_direct": "Bezpośredni (hidraw)", + "ui.3d_mouse_driver_fallback": "Na razie odczyt przez spacenavd: samego urządzenia nie udało się otworzyć.", + "ui.3d_mouse_driver_only_direct": "Urządzenie jest czytane bezpośrednio; spacenavd nie działa", + "ui.3d_mouse_driver_only_spacenavd": "Odczyt przez spacenavd, jedyna dostępna droga", + "ui.3d_mouse_forget": "Zapomnij kalibrację", + "ui.3d_mouse_ghost_left_viewport": "Lewy widok", + "ui.3d_mouse_ghost_left_window": "Lewe okno", + "ui.3d_mouse_ghost_position": "Położenie", + "ui.3d_mouse_ghost_right_viewport": "Prawy widok", + "ui.3d_mouse_hint": "Przesuwaj i obracaj widok jednocześnie; w szkicach działa tylko przesuwanie i powiększanie.", + "ui.3d_mouse_kind_cap": "Powrót do środka", + "ui.3d_mouse_kind_question": "Jaki to rodzaj myszy 3D?", + "ui.3d_mouse_kind_trackball": "Trackball", + "ui.3d_mouse_looks_like_cap": "Odczyt zostaje, dopóki trzymasz, jak w urządzeniu typu „Powrót do środka”.", + "ui.3d_mouse_looks_like_trackball": "Odczyt wraca, gdy tylko przestajesz ruszać, jak w trackballu.", + "ui.3d_mouse_motion_rotation_x": "Obrót X", + "ui.3d_mouse_motion_rotation_y": "Obrót Y", + "ui.3d_mouse_motion_rotation_z": "Obrót Z", + "ui.3d_mouse_motion_translation_x": "Przesunięcie X", + "ui.3d_mouse_motion_translation_y": "Przesunięcie Y", + "ui.3d_mouse_motion_translation_z": "Przesunięcie Z", + "ui.3d_mouse_navigate": "Nawiguj za pomocą myszy 3D", + "ui.3d_mouse_none": "Nie znaleziono myszy 3D", + "ui.3d_mouse_not_calibrated": "Nieskalibrowano", + "ui.3d_mouse_offer": "Kalibracja myszy 3D naprawia zamienione lub odwrócone osie oraz ustawia jej szybkość i odczucie.", + "ui.3d_mouse_offer_never": "Nie pokazuj więcej", + "ui.3d_mouse_offer_swap": "Przełącz na Hidraw (sterownik bezpośredni)", + "ui.3d_mouse_reading": "Odczyt", + "ui.3d_mouse_reset_axes": "Przywróć przypisanie osi", + "ui.3d_mouse_reset_view": "Resetuj widok", + "ui.3d_mouse_restore": "Przywróć ustawienia myszy 3D", + "ui.3d_mouse_restore_ask": "Przywrócić domyślne ustawienia myszy 3D? Kalibracja i przypisanie przycisków zostają.", + "ui.3d_mouse_section": "Mysz 3D", + "ui.3d_mouse_show_in_view": "Pokazuj mysz 3D w widoku 3D", + "ui.3d_mouse_show_in_view_tip": "Obraz twojej myszy 3D w lewym dolnym rogu, który porusza się razem z nią.", + "ui.3d_mouse_skip": "Moje urządzenie tego nie potrafi", + "ui.3d_mouse_skip_tip": "Pomija ten ruch w obu kierunkach; kalibracja go nie uwzględnia.", "ui.3d_mouse_speed": "Czułość myszy 3D", + "ui.3d_mouse_step_lift": "Unieś nakładkę, przytrzymaj, a następnie puść.", + "ui.3d_mouse_step_press": "Wciśnij nakładkę w dół, przytrzymaj, a następnie puść.", + "ui.3d_mouse_step_pull": "Pociągnij nakładkę do siebie, przytrzymaj, a następnie puść.", + "ui.3d_mouse_step_push": "Popchnij nakładkę od siebie, przytrzymaj, a następnie puść.", + "ui.3d_mouse_step_rest": "Zdejmij dłoń z nakładki i nie ruszaj jej.", + "ui.3d_mouse_step_rest_trackball": "Zdejmij dłoń z kulki i nie ruszaj jej.", + "ui.3d_mouse_step_ring_clockwise": "Obróć pierścień przewijania o kilka kliknięć zgodnie z ruchem wskazówek zegara.", + "ui.3d_mouse_step_ring_counterclockwise": "Obróć pierścień przewijania o kilka kliknięć przeciwnie do ruchu wskazówek zegara.", + "ui.3d_mouse_step_roll_away": "Potocz kulkę kilka razy od siebie, a potem pozwól jej się zatrzymać.", + "ui.3d_mouse_step_roll_left": "Potocz kulkę kilka razy w lewo, a potem pozwól jej się zatrzymać.", + "ui.3d_mouse_step_roll_right": "Potocz kulkę kilka razy w prawo, a potem pozwól jej się zatrzymać.", + "ui.3d_mouse_step_roll_toward": "Potocz kulkę kilka razy do siebie, a potem pozwól jej się zatrzymać.", + "ui.3d_mouse_step_slide_left": "Przesuń nakładkę w lewo, przytrzymaj, a następnie puść.", + "ui.3d_mouse_step_slide_right": "Przesuń nakładkę w prawo, przytrzymaj, a następnie puść.", + "ui.3d_mouse_step_spin_clockwise": "Zakręć kulką kilka razy zgodnie z ruchem wskazówek zegara, a potem pozwól jej się zatrzymać.", + "ui.3d_mouse_step_spin_counterclockwise": "Zakręć kulką kilka razy przeciwnie do ruchu wskazówek zegara, a potem pozwól jej się zatrzymać.", + "ui.3d_mouse_step_tilt_away": "Przechyl górę nakładki od siebie (krawędź najbliżej ciebie idzie w górę), przytrzymaj, a następnie puść.", + "ui.3d_mouse_step_tilt_left": "Przechyl nakładkę w lewo (lewa krawędź w dół), przytrzymaj, a następnie puść.", + "ui.3d_mouse_step_tilt_right": "Przechyl nakładkę w prawo (prawa krawędź w dół), przytrzymaj, a następnie puść.", + "ui.3d_mouse_step_tilt_toward": "Przechyl górę nakładki do siebie (krawędź najbliżej ciebie idzie w dół), przytrzymaj, a następnie puść.", + "ui.3d_mouse_step_touch": "Połóż dłoń na nakładce, nie naciskając jej.", + "ui.3d_mouse_step_twist_clockwise": "Obróć nakładkę zgodnie z ruchem wskazówek zegara, przytrzymaj, a następnie puść.", + "ui.3d_mouse_step_twist_counterclockwise": "Obróć nakładkę przeciwnie do ruchu wskazówek zegara, przytrzymaj, a następnie puść.", + "ui.3d_mouse_switch_cap": "Kalibruj jako powrót do środka", + "ui.3d_mouse_switch_trackball": "Kalibruj jako trackball", + "ui.3d_mouse_view_axis": "Oś widoku", + "ui.3d_mouse_view_off": "Wyłączona", + "ui.3d_mouse_view_off_tip": "Profil sterownika myszy 3D wyłącza tę oś, więc jej ruch nie porusza widokiem. Wybranie tu ruchu innej osi zamienia je miejscami, a ten ruch przestaje poruszać widokiem.", + "ui.3d_mouse_view_orbit_sideways": "Orbita w lewo / prawo", + "ui.3d_mouse_view_orbit_up_down": "Orbita w górę / dół", + "ui.3d_mouse_view_pan_sideways": "Przesuwanie w lewo / prawo", + "ui.3d_mouse_view_pan_up_down": "Przesuwanie w górę / dół", + "ui.3d_mouse_view_roll": "Przechył", + "ui.3d_mouse_view_unmapped": "Oś {n}", + "ui.3d_mouse_view_zoom": "Powiększenie", + "ui.3d_mouse_waiting": "Czekam na ruch", + "ui.3d_mouse_wiggle": "Jeśli masz podłączoną, poruszaj nią chwilę.", "ui.3d_sketch": "Szkic 3D", "ui.activate": "Aktywuj", "ui.active_component": "Aktywny komponent", @@ -1535,6 +1640,7 @@ "ui.blocked": "Zablokowane", "ui.bodies": "Bryły", "ui.bodies_all_if_none": "Bryły (wszystkie, jeśli brak)", + "ui.bottom": "Dół", "ui.box": "Prostopadłościan", "ui.break": "Podziel", "ui.browser_bodies_one": "{count} bryła", @@ -1985,59 +2091,6 @@ "ui.smooth_wheel_zoom": "Płynne powiększanie kółkiem", "ui.smooth_wheel_zoom_tip": "Wyłącz, aby każdy ruch kółka działał od razu, bez wygładzania", "ui.snap": "Przyciąganie", - "ui.space_mouse_axes_reassigned": "Osie są przypisane inaczej w Preferencjach i to przypisanie nadal działa niezależnie od tej kalibracji.", - "ui.space_mouse_calibrate": "Kalibruj…", - "ui.space_mouse_calibrate_tip": "Przesuwaj nakładkę zgodnie z poleceniami okna, aby SolidCraft nauczył się kierunku i zakresu każdej osi tej myszy 3D. Zapisywane dla tej myszy 3D.", - "ui.space_mouse_calibrate_title": "Kalibracja myszy 3D", - "ui.space_mouse_calibrated": "Skalibrowano", - "ui.space_mouse_calibration": "Kalibracja myszy 3D", - "ui.space_mouse_calibration_done": "Gotowe. Zapisz, aby używać kalibracji po każdym podłączeniu tej myszy 3D.", - "ui.space_mouse_calibration_no_response": "Jedna oś prawie się nie poruszyła, więc nie można jej skalibrować. Zacznij od nowa i za każdym razem wychylaj nakładkę mocniej.", - "ui.space_mouse_dead_zone": "Strefa martwa", - "ui.space_mouse_dead_zone_tip": "O ile trzeba wychylić nakładkę, zanim widok się poruszy, jako część pełnego wychylenia. Zwiększ, jeśli widok dryfuje przy nieruszanej nakładce.", - "ui.space_mouse_driven_by": "Który skalibrowany ruch urządzenia steruje tą osią. Wybranie ruchu używanego już przez inną oś zamienia je miejscami.", - "ui.space_mouse_driven_by_label": "sterowana przez", - "ui.space_mouse_driver": "Sterownik myszy 3D", - "ui.space_mouse_driver_auto": "Automatycznie", - "ui.space_mouse_driver_direct": "Bezpośrednio (hidraw)", - "ui.space_mouse_driver_only_direct": "Bezpośredni odczyt urządzenia; spacenavd nie działa", - "ui.space_mouse_driver_only_spacenavd": "Odczyt przez spacenavd, jedyny dostępny sposób", - "ui.space_mouse_forget": "Zapomnij kalibrację", - "ui.space_mouse_hint": "Przesuwaj i obracaj widok jednocześnie; w szkicach działa tylko przesuwanie i powiększanie.", - "ui.space_mouse_motion_rotation_x": "Obrót X", - "ui.space_mouse_motion_rotation_y": "Obrót Y", - "ui.space_mouse_motion_rotation_z": "Obrót Z", - "ui.space_mouse_motion_translation_x": "Przesunięcie X", - "ui.space_mouse_motion_translation_y": "Przesunięcie Y", - "ui.space_mouse_motion_translation_z": "Przesunięcie Z", - "ui.space_mouse_navigate": "Nawiguj za pomocą SpaceNavigator / SpaceMouse", - "ui.space_mouse_none": "Nie znaleziono myszy 3D", - "ui.space_mouse_not_calibrated": "Nieskalibrowano", - "ui.space_mouse_reading": "Odczyt", - "ui.space_mouse_reset_axes": "Resetuj przypisanie osi", - "ui.space_mouse_step_lift": "Unieś nakładkę, przytrzymaj, a następnie puść.", - "ui.space_mouse_step_press": "Wciśnij nakładkę w dół, przytrzymaj, a następnie puść.", - "ui.space_mouse_step_pull": "Pociągnij nakładkę do siebie, przytrzymaj, a następnie puść.", - "ui.space_mouse_step_push": "Popchnij nakładkę od siebie, przytrzymaj, a następnie puść.", - "ui.space_mouse_step_rest": "Zdejmij dłoń z nakładki i nie ruszaj jej.", - "ui.space_mouse_step_slide_left": "Przesuń nakładkę w lewo, przytrzymaj, a następnie puść.", - "ui.space_mouse_step_slide_right": "Przesuń nakładkę w prawo, przytrzymaj, a następnie puść.", - "ui.space_mouse_step_tilt_back": "Przechyl nakładkę do tyłu (przednia krawędź w górę), przytrzymaj, a następnie puść.", - "ui.space_mouse_step_tilt_forward": "Przechyl nakładkę do przodu (przednia krawędź w dół), przytrzymaj, a następnie puść.", - "ui.space_mouse_step_tilt_left": "Przechyl nakładkę w lewo (lewa krawędź w dół), przytrzymaj, a następnie puść.", - "ui.space_mouse_step_tilt_right": "Przechyl nakładkę w prawo (prawa krawędź w dół), przytrzymaj, a następnie puść.", - "ui.space_mouse_step_twist_clockwise": "Obróć nakładkę zgodnie z ruchem wskazówek zegara, przytrzymaj, a następnie puść.", - "ui.space_mouse_step_twist_counterclockwise": "Obróć nakładkę przeciwnie do ruchu wskazówek zegara, przytrzymaj, a następnie puść.", - "ui.space_mouse_view_axis": "Oś widoku", - "ui.space_mouse_view_off": "Wyłączona", - "ui.space_mouse_view_off_tip": "Profil sterownika myszy 3D wyłącza tę oś, więc jej ruch nie porusza widokiem. Wybranie tutaj ruchu innej osi zamienia je miejscami, a ten ruch przestaje poruszać widokiem.", - "ui.space_mouse_view_orbit_sideways": "Obracanie w lewo / w prawo", - "ui.space_mouse_view_orbit_up_down": "Obracanie w górę / w dół", - "ui.space_mouse_view_pan_sideways": "Przesuwanie w lewo / w prawo", - "ui.space_mouse_view_pan_up_down": "Przesuwanie w górę / w dół", - "ui.space_mouse_view_roll": "Przechylenie", - "ui.space_mouse_view_unmapped": "Oś {n}", - "ui.space_mouse_view_zoom": "Powiększanie", "ui.spacing": "Odstęp", "ui.spacing_2": "Odstęp 2", "ui.sphere": "Sfera", diff --git a/assets/locales/pt-BR.json b/assets/locales/pt-BR.json index 092683499b..da3e9ec8a7 100644 --- a/assets/locales/pt-BR.json +++ b/assets/locales/pt-BR.json @@ -530,6 +530,10 @@ "cmd.file_insert_step": "Inserir STEP", "cmd.file_save_mesh": "Salvar como malha", "cmd.file_slicers": "Fatiadores instalados", + "cmd.frame_member": "Membro estrutural", + "cmd.frame_profile_insert": "Inserir perfil", + "cmd.frame_profile_list": "Biblioteca de perfis", + "cmd.frame_profile_properties": "Propriedades do perfil", "cmd.inspect_accessibility": "Análise de acessibilidade", "cmd.inspect_center_of_mass": "Centro de massa", "cmd.inspect_clearance": "Folga", @@ -1121,7 +1125,106 @@ "trace.stopped_by_contact": "Termina na amostra {sample}: {a} atravessaria {b}", "trace.stopped_by_loop": "Termina na amostra {sample}: o laço por {joint} não pode fechar mais", "ui.1_degree_of_freedom": "1 grau de liberdade", + "ui.3d_mouse_axes_reassigned": "Os eixos estão reatribuídos nas Preferências, e isso continua valendo sobre esta calibração.", + "ui.3d_mouse_button": "Botão {number}", + "ui.3d_mouse_button_nothing": "Nada", + "ui.3d_mouse_button_settings": "Configurações do mouse 3D", + "ui.3d_mouse_buttons": "Botões", + "ui.3d_mouse_buttons_hint": "Aperte um botão do seu mouse 3D para encontrá-lo aqui e escolha o que ele faz.", + "ui.3d_mouse_buttons_no_device": "Conecte um mouse 3D para escolher o que os botões fazem.", + "ui.3d_mouse_buttons_reset": "Restaurar os botões padrão", + "ui.3d_mouse_calibrate": "Calibrar…", + "ui.3d_mouse_calibrate_tip": "Mova a tampa como a janela pedir, para que o SolidCraft aprenda para onde e até onde vai cada eixo deste mouse 3D. Salvo para este mouse 3D.", + "ui.3d_mouse_calibrate_title": "Calibrar mouse 3D", + "ui.3d_mouse_calibrate_wait": "Mova o mouse 3D uma vez para o SolidCraft vê-lo.", + "ui.3d_mouse_calibrated": "Calibrado", + "ui.3d_mouse_calibration": "Calibração do mouse 3D", + "ui.3d_mouse_calibration_done": "Pronto. Salve para usá-la sempre que este mouse 3D estiver conectado.", + "ui.3d_mouse_calibration_no_response": "Um eixo quase não se moveu, então não dá para calibrá-lo. Recomece e mova a tampa mais longe a cada vez.", + "ui.3d_mouse_circles_help": "Os círculos mostram o que o dispositivo envia após a calibração. O modelo de teste mostra o que a vista faz com isso, após inversão, velocidade e atribuição de eixos.", + "ui.3d_mouse_dead_zone": "Zona morta", + "ui.3d_mouse_dead_zone_tip": "Quanto a tampa precisa se mover antes de a vista se mover, como fração do curso completo. Aumente se a vista se mexer com a tampa parada.", + "ui.3d_mouse_denied": "O SolidCraft vê um mouse 3D, mas não tem permissão para lê-lo. A regra udev está nas notas de configuração do mouse 3D.", + "ui.3d_mouse_denied_short": "Encontrado, mas não legível", + "ui.3d_mouse_driven_by": "Qual movimento calibrado do dispositivo controla este eixo. Escolher um que outro eixo já usa troca os dois.", + "ui.3d_mouse_driven_by_label": "controlado por", + "ui.3d_mouse_driver": "Driver do mouse 3D", + "ui.3d_mouse_driver_auto": "Automático", + "ui.3d_mouse_driver_direct": "Direto (hidraw)", + "ui.3d_mouse_driver_fallback": "Lendo por enquanto pelo spacenavd: não foi possível abrir o dispositivo em si.", + "ui.3d_mouse_driver_only_direct": "Lendo o dispositivo diretamente; o spacenavd não está em execução", + "ui.3d_mouse_driver_only_spacenavd": "Lendo pelo spacenavd, a única opção disponível", + "ui.3d_mouse_forget": "Esquecer calibração", + "ui.3d_mouse_ghost_left_viewport": "Viewport à esquerda", + "ui.3d_mouse_ghost_left_window": "Janela à esquerda", + "ui.3d_mouse_ghost_position": "Posição", + "ui.3d_mouse_ghost_right_viewport": "Viewport à direita", + "ui.3d_mouse_hint": "Move e gira a vista juntos; esboços usam apenas deslocamento e zoom.", + "ui.3d_mouse_kind_cap": "Retorno ao centro", + "ui.3d_mouse_kind_question": "Que tipo de mouse 3D é?", + "ui.3d_mouse_kind_trackball": "Trackball", + "ui.3d_mouse_looks_like_cap": "O valor se mantém enquanto você segura, como em um dispositivo do tipo “Retorno ao centro”.", + "ui.3d_mouse_looks_like_trackball": "O valor volta assim que você para de mover, como em um trackball.", + "ui.3d_mouse_motion_rotation_x": "Rotação X", + "ui.3d_mouse_motion_rotation_y": "Rotação Y", + "ui.3d_mouse_motion_rotation_z": "Rotação Z", + "ui.3d_mouse_motion_translation_x": "Translação X", + "ui.3d_mouse_motion_translation_y": "Translação Y", + "ui.3d_mouse_motion_translation_z": "Translação Z", + "ui.3d_mouse_navigate": "Navegar com o mouse 3D", + "ui.3d_mouse_none": "Nenhum mouse 3D encontrado", + "ui.3d_mouse_not_calibrated": "Não calibrado", + "ui.3d_mouse_offer": "Calibrar o mouse 3D corrige eixos trocados ou invertidos e ajusta a velocidade e a sensação.", + "ui.3d_mouse_offer_never": "Não mostrar novamente", + "ui.3d_mouse_offer_swap": "Mudar para Hidraw (driver direto)", + "ui.3d_mouse_reading": "Leitura", + "ui.3d_mouse_reset_axes": "Redefinir atribuição de eixos", + "ui.3d_mouse_reset_view": "Redefinir visualização", + "ui.3d_mouse_restore": "Restaurar padrões do mouse 3D", + "ui.3d_mouse_restore_ask": "Voltar as configurações do mouse 3D aos padrões? A calibração e o mapeamento dos botões continuam.", + "ui.3d_mouse_section": "Mouse 3D", + "ui.3d_mouse_show_in_view": "Mostrar o mouse 3D na vista 3D", + "ui.3d_mouse_show_in_view_tip": "Uma imagem do seu mouse 3D no canto inferior esquerdo, que se move quando você o move.", + "ui.3d_mouse_skip": "Meu dispositivo não faz isso", + "ui.3d_mouse_skip_tip": "Pula este movimento nos dois sentidos; a calibração o deixa de fora.", "ui.3d_mouse_speed": "Velocidade do mouse 3D", + "ui.3d_mouse_step_lift": "Levante a tampa, segure e solte.", + "ui.3d_mouse_step_press": "Pressione a tampa para baixo, segure e solte.", + "ui.3d_mouse_step_pull": "Puxe a tampa na sua direção, segure e solte.", + "ui.3d_mouse_step_push": "Empurre a tampa para longe de você, segure e solte.", + "ui.3d_mouse_step_rest": "Tire a mão da tampa e deixe-a parada.", + "ui.3d_mouse_step_rest_trackball": "Tire a mão da bola e deixe-a parada.", + "ui.3d_mouse_step_ring_clockwise": "Gire o anel de rolagem alguns cliques no sentido horário.", + "ui.3d_mouse_step_ring_counterclockwise": "Gire o anel de rolagem alguns cliques no sentido anti-horário.", + "ui.3d_mouse_step_roll_away": "Role a bola para longe de você algumas vezes e deixe-a parar.", + "ui.3d_mouse_step_roll_left": "Role a bola para a esquerda algumas vezes e deixe-a parar.", + "ui.3d_mouse_step_roll_right": "Role a bola para a direita algumas vezes e deixe-a parar.", + "ui.3d_mouse_step_roll_toward": "Role a bola na sua direção algumas vezes e deixe-a parar.", + "ui.3d_mouse_step_slide_left": "Deslize a tampa para a esquerda, segure e solte.", + "ui.3d_mouse_step_slide_right": "Deslize a tampa para a direita, segure e solte.", + "ui.3d_mouse_step_spin_clockwise": "Gire a bola no sentido horário algumas vezes e deixe-a parar.", + "ui.3d_mouse_step_spin_counterclockwise": "Gire a bola no sentido anti-horário algumas vezes e deixe-a parar.", + "ui.3d_mouse_step_tilt_away": "Incline o topo da tampa para longe de você (a borda mais próxima sobe), segure e solte.", + "ui.3d_mouse_step_tilt_left": "Incline a tampa para a esquerda (borda esquerda para baixo), segure e solte.", + "ui.3d_mouse_step_tilt_right": "Incline a tampa para a direita (borda direita para baixo), segure e solte.", + "ui.3d_mouse_step_tilt_toward": "Incline o topo da tampa na sua direção (a borda mais próxima desce), segure e solte.", + "ui.3d_mouse_step_touch": "Apoie a mão na tampa sem empurrá-la.", + "ui.3d_mouse_step_twist_clockwise": "Gire a tampa no sentido horário, segure e solte.", + "ui.3d_mouse_step_twist_counterclockwise": "Gire a tampa no sentido anti-horário, segure e solte.", + "ui.3d_mouse_switch_cap": "Calibrar como retorno ao centro", + "ui.3d_mouse_switch_trackball": "Calibrar como trackball", + "ui.3d_mouse_view_axis": "Eixo da vista", + "ui.3d_mouse_view_off": "Desligado", + "ui.3d_mouse_view_off_tip": "O perfil do driver do mouse 3D desliga este eixo, então o movimento dele não move a vista. Escolher aqui o movimento de outro eixo troca os dois, e esse movimento deixa de mover a vista.", + "ui.3d_mouse_view_orbit_sideways": "Orbitar esquerda / direita", + "ui.3d_mouse_view_orbit_up_down": "Orbitar cima / baixo", + "ui.3d_mouse_view_pan_sideways": "Deslocar esquerda / direita", + "ui.3d_mouse_view_pan_up_down": "Deslocar cima / baixo", + "ui.3d_mouse_view_roll": "Girar a vista", + "ui.3d_mouse_view_unmapped": "Eixo {n}", + "ui.3d_mouse_view_zoom": "Zoom", + "ui.3d_mouse_waiting": "Aguardando movimento", + "ui.3d_mouse_wiggle": "Se você tiver um conectado, mexa nele um pouco.", "ui.3d_sketch": "Esboço 3D", "ui.activate": "Ativar", "ui.active_component": "Componente ativo", @@ -1351,6 +1454,7 @@ "ui.blocked": "Bloqueado", "ui.bodies": "Corpos", "ui.bodies_all_if_none": "Corpos (todos se nenhum)", + "ui.bottom": "Base", "ui.box": "Caixa", "ui.break": "Quebrar", "ui.browser_bodies_one": "{count} corpo", @@ -1794,59 +1898,6 @@ "ui.smooth_wheel_zoom": "Zoom suave com a roda", "ui.smooth_wheel_zoom_tip": "Desative para aplicar cada evento da roda na hora, sem rastro de suavização", "ui.snap": "Encaixe", - "ui.space_mouse_axes_reassigned": "Os eixos estão reatribuídos nas Preferências, e isso continua valendo sobre esta calibração.", - "ui.space_mouse_calibrate": "Calibrar…", - "ui.space_mouse_calibrate_tip": "Mova a tampa como a janela pedir, para que o SolidCraft aprenda para onde e até onde vai cada eixo deste mouse 3D. Salvo para este mouse 3D.", - "ui.space_mouse_calibrate_title": "Calibrar mouse 3D", - "ui.space_mouse_calibrated": "Calibrado", - "ui.space_mouse_calibration": "Calibração do mouse 3D", - "ui.space_mouse_calibration_done": "Pronto. Salve para usá-la sempre que este mouse 3D estiver conectado.", - "ui.space_mouse_calibration_no_response": "Um eixo quase não se moveu, então não dá para calibrá-lo. Recomece e mova a tampa mais longe a cada vez.", - "ui.space_mouse_dead_zone": "Zona morta", - "ui.space_mouse_dead_zone_tip": "Quanto a tampa precisa se mover antes de a vista se mover, como fração do curso completo. Aumente se a vista se mexer com a tampa parada.", - "ui.space_mouse_driven_by": "Qual movimento calibrado do dispositivo controla este eixo. Escolher um que outro eixo já usa troca os dois.", - "ui.space_mouse_driven_by_label": "controlado por", - "ui.space_mouse_driver": "Driver do mouse 3D", - "ui.space_mouse_driver_auto": "Automático", - "ui.space_mouse_driver_direct": "Direto (hidraw)", - "ui.space_mouse_driver_only_direct": "Lendo o dispositivo diretamente; o spacenavd não está em execução", - "ui.space_mouse_driver_only_spacenavd": "Lendo pelo spacenavd, a única opção disponível", - "ui.space_mouse_forget": "Esquecer calibração", - "ui.space_mouse_hint": "Move e gira a vista juntos; esboços usam apenas deslocamento e zoom.", - "ui.space_mouse_motion_rotation_x": "Rotação X", - "ui.space_mouse_motion_rotation_y": "Rotação Y", - "ui.space_mouse_motion_rotation_z": "Rotação Z", - "ui.space_mouse_motion_translation_x": "Translação X", - "ui.space_mouse_motion_translation_y": "Translação Y", - "ui.space_mouse_motion_translation_z": "Translação Z", - "ui.space_mouse_navigate": "Navegar com SpaceNavigator / SpaceMouse", - "ui.space_mouse_none": "Nenhum mouse 3D encontrado", - "ui.space_mouse_not_calibrated": "Não calibrado", - "ui.space_mouse_reading": "Leitura", - "ui.space_mouse_reset_axes": "Redefinir atribuição de eixos", - "ui.space_mouse_step_lift": "Levante a tampa, segure e solte.", - "ui.space_mouse_step_press": "Pressione a tampa para baixo, segure e solte.", - "ui.space_mouse_step_pull": "Puxe a tampa na sua direção, segure e solte.", - "ui.space_mouse_step_push": "Empurre a tampa para longe de você, segure e solte.", - "ui.space_mouse_step_rest": "Tire a mão da tampa e deixe-a parada.", - "ui.space_mouse_step_slide_left": "Deslize a tampa para a esquerda, segure e solte.", - "ui.space_mouse_step_slide_right": "Deslize a tampa para a direita, segure e solte.", - "ui.space_mouse_step_tilt_back": "Incline a tampa para trás (borda da frente para cima), segure e solte.", - "ui.space_mouse_step_tilt_forward": "Incline a tampa para a frente (borda da frente para baixo), segure e solte.", - "ui.space_mouse_step_tilt_left": "Incline a tampa para a esquerda (borda esquerda para baixo), segure e solte.", - "ui.space_mouse_step_tilt_right": "Incline a tampa para a direita (borda direita para baixo), segure e solte.", - "ui.space_mouse_step_twist_clockwise": "Gire a tampa no sentido horário, segure e solte.", - "ui.space_mouse_step_twist_counterclockwise": "Gire a tampa no sentido anti-horário, segure e solte.", - "ui.space_mouse_view_axis": "Eixo da vista", - "ui.space_mouse_view_off": "Desligado", - "ui.space_mouse_view_off_tip": "O perfil do driver do mouse 3D desliga este eixo, então o movimento dele não move a vista. Escolher aqui o movimento de outro eixo troca os dois, e esse movimento deixa de mover a vista.", - "ui.space_mouse_view_orbit_sideways": "Orbitar esquerda / direita", - "ui.space_mouse_view_orbit_up_down": "Orbitar cima / baixo", - "ui.space_mouse_view_pan_sideways": "Deslocar esquerda / direita", - "ui.space_mouse_view_pan_up_down": "Deslocar cima / baixo", - "ui.space_mouse_view_roll": "Girar a vista", - "ui.space_mouse_view_unmapped": "Eixo {n}", - "ui.space_mouse_view_zoom": "Zoom", "ui.spacing": "Espaçamento", "ui.spacing_2": "Espaçamento 2", "ui.sphere": "Esfera", diff --git a/assets/locales/ro.json b/assets/locales/ro.json index 2834558456..dd8d20406e 100644 --- a/assets/locales/ro.json +++ b/assets/locales/ro.json @@ -529,6 +529,10 @@ "cmd.file_insert_step": "Inserează STEP", "cmd.file_save_mesh": "Salvează ca plasă", "cmd.file_slicers": "Slicere instalate", + "cmd.frame_member": "Element de cadru", + "cmd.frame_profile_insert": "Inserare profil", + "cmd.frame_profile_list": "Bibliotecă de profile", + "cmd.frame_profile_properties": "Proprietăți profil", "cmd.inspect_accessibility": "Analiza accesibilității", "cmd.inspect_center_of_mass": "Centru de masă", "cmd.inspect_clearance": "Joc", @@ -1120,7 +1124,50 @@ "trace.stopped_by_contact": "Se termină la eșantionul {sample}: {a} ar trece prin {b}", "trace.stopped_by_loop": "Se termină la eșantionul {sample}: bucla prin {joint} nu se mai poate închide", "ui.1_degree_of_freedom": "1 grad de libertate", + "ui.3d_mouse_calibrate": "Calibrează…", + "ui.3d_mouse_calibrate_tip": "Mișcați capacul așa cum cere fereastra, ca SolidCraft să învețe în ce direcție merge fiecare axă a acestui mouse 3D și cât de departe. Salvat pentru acest mouse 3D.", + "ui.3d_mouse_calibrate_title": "Calibrează mouse-ul 3D", + "ui.3d_mouse_calibrate_wait": "Mișcă o dată mouse-ul 3D ca SolidCraft să îl vadă.", + "ui.3d_mouse_calibrated": "Calibrat", + "ui.3d_mouse_calibration": "Calibrarea mouse-ului 3D", + "ui.3d_mouse_calibration_done": "Gata. Salvați-o pentru a o folosi ori de câte ori acest mouse 3D este conectat.", + "ui.3d_mouse_calibration_no_response": "O axă abia s-a mișcat, deci nu poate fi calibrată. Reîncepeți și mișcați capacul mai mult de fiecare dată.", + "ui.3d_mouse_circles_help": "Cercurile arată ce trimite dispozitivul după calibrare. Modelul de test arată ce face vizualizarea cu acestea, după inversare, viteză și atribuirea axelor.", + "ui.3d_mouse_dead_zone": "Zonă moartă", + "ui.3d_mouse_dead_zone_tip": "Cât trebuie să se miște capacul înainte ca vizualizarea să se miște, ca fracțiune din cursa completă. Măriți-o dacă vizualizarea derivă cât capacul este în repaus.", + "ui.3d_mouse_denied": "SolidCraft vede un mouse 3D, dar nu are voie să îl citească. Regula udev este în notele de configurare a mouse-ului 3D.", + "ui.3d_mouse_denied_short": "Găsit, dar nu poate fi citit", + "ui.3d_mouse_driver_fallback": "Deocamdată se citește prin spacenavd: dispozitivul în sine nu a putut fi deschis.", + "ui.3d_mouse_forget": "Uită calibrarea", + "ui.3d_mouse_ghost_left_viewport": "Viewport stânga", + "ui.3d_mouse_ghost_left_window": "Fereastră stânga", + "ui.3d_mouse_ghost_position": "Poziție", + "ui.3d_mouse_ghost_right_viewport": "Viewport dreapta", + "ui.3d_mouse_hint": "Mută și rotește vizualizarea împreună; schițele folosesc doar panoramare și zoom.", + "ui.3d_mouse_navigate": "Navighează cu mouse-ul 3D", + "ui.3d_mouse_none": "Niciun mouse 3D găsit", + "ui.3d_mouse_not_calibrated": "Necalibrat", + "ui.3d_mouse_offer": "Calibrarea mouse-ului 3D corectează axele inversate sau schimbate între ele și îi reglează viteza și senzația.", + "ui.3d_mouse_offer_never": "Nu mai afișa", + "ui.3d_mouse_offer_swap": "Treci la Hidraw (driver direct)", + "ui.3d_mouse_reading": "Valoare citită", + "ui.3d_mouse_reset_view": "Resetează vizualizarea", + "ui.3d_mouse_restore": "Restaurează implicitele mouse-ului 3D", + "ui.3d_mouse_restore_ask": "Revii la valorile implicite ale setărilor mouse-ului 3D? Calibrarea și atribuirea butoanelor rămân.", "ui.3d_mouse_speed": "Viteza mouse-ului 3D", + "ui.3d_mouse_step_lift": "Ridicați capacul în sus, țineți-l, apoi eliberați-l.", + "ui.3d_mouse_step_press": "Apăsați capacul în jos, țineți-l, apoi eliberați-l.", + "ui.3d_mouse_step_pull": "Trageți capacul spre dumneavoastră, țineți-l, apoi eliberați-l.", + "ui.3d_mouse_step_push": "Împingeți capacul de la dumneavoastră, țineți-l, apoi eliberați-l.", + "ui.3d_mouse_step_rest": "Luați mâna de pe capac și lăsați-l nemișcat.", + "ui.3d_mouse_step_slide_left": "Glisați capacul spre stânga, țineți-l, apoi eliberați-l.", + "ui.3d_mouse_step_slide_right": "Glisați capacul spre dreapta, țineți-l, apoi eliberați-l.", + "ui.3d_mouse_step_tilt_left": "Înclinați capacul spre stânga (marginea stângă în jos), țineți-l, apoi eliberați-l.", + "ui.3d_mouse_step_tilt_right": "Înclinați capacul spre dreapta (marginea dreaptă în jos), țineți-l, apoi eliberați-l.", + "ui.3d_mouse_step_twist_clockwise": "Răsuciți capacul în sensul acelor de ceasornic, țineți-l, apoi eliberați-l.", + "ui.3d_mouse_step_twist_counterclockwise": "Răsuciți capacul în sens invers acelor de ceasornic, țineți-l, apoi eliberați-l.", + "ui.3d_mouse_waiting": "Se așteaptă mișcare", + "ui.3d_mouse_wiggle": "Dacă ai unul conectat, mișcă-l puțin.", "ui.3d_sketch": "Schiță 3D", "ui.activate": "Activează", "ui.active_component": "Componentă activă", @@ -1793,34 +1840,6 @@ "ui.smooth_wheel_zoom": "Zoom lin cu rotița", "ui.smooth_wheel_zoom_tip": "Dezactivați pentru a aplica imediat fiecare eveniment al rotiței, fără coadă de netezire", "ui.snap": "Agățare", - "ui.space_mouse_calibrate": "Calibrează…", - "ui.space_mouse_calibrate_tip": "Mișcați capacul așa cum cere fereastra, ca SolidCraft să învețe în ce direcție merge fiecare axă a acestui mouse 3D și cât de departe. Salvat pentru acest mouse 3D.", - "ui.space_mouse_calibrate_title": "Calibrează mouse-ul 3D", - "ui.space_mouse_calibrated": "Calibrat", - "ui.space_mouse_calibration": "Calibrarea mouse-ului 3D", - "ui.space_mouse_calibration_done": "Gata. Salvați-o pentru a o folosi ori de câte ori acest mouse 3D este conectat.", - "ui.space_mouse_calibration_no_response": "O axă abia s-a mișcat, deci nu poate fi calibrată. Reîncepeți și mișcați capacul mai mult de fiecare dată.", - "ui.space_mouse_dead_zone": "Zonă moartă", - "ui.space_mouse_dead_zone_tip": "Cât trebuie să se miște capacul înainte ca vizualizarea să se miște, ca fracțiune din cursa completă. Măriți-o dacă vizualizarea derivă cât capacul este în repaus.", - "ui.space_mouse_forget": "Uită calibrarea", - "ui.space_mouse_hint": "Mută și rotește vizualizarea împreună; schițele folosesc doar panoramare și zoom.", - "ui.space_mouse_navigate": "Navighează cu SpaceNavigator / SpaceMouse", - "ui.space_mouse_none": "Niciun mouse 3D găsit", - "ui.space_mouse_not_calibrated": "Necalibrat", - "ui.space_mouse_reading": "Valoare citită", - "ui.space_mouse_step_lift": "Ridicați capacul în sus, țineți-l, apoi eliberați-l.", - "ui.space_mouse_step_press": "Apăsați capacul în jos, țineți-l, apoi eliberați-l.", - "ui.space_mouse_step_pull": "Trageți capacul spre dumneavoastră, țineți-l, apoi eliberați-l.", - "ui.space_mouse_step_push": "Împingeți capacul de la dumneavoastră, țineți-l, apoi eliberați-l.", - "ui.space_mouse_step_rest": "Luați mâna de pe capac și lăsați-l nemișcat.", - "ui.space_mouse_step_slide_left": "Glisați capacul spre stânga, țineți-l, apoi eliberați-l.", - "ui.space_mouse_step_slide_right": "Glisați capacul spre dreapta, țineți-l, apoi eliberați-l.", - "ui.space_mouse_step_tilt_back": "Înclinați capacul spre spate (marginea din față în sus), țineți-l, apoi eliberați-l.", - "ui.space_mouse_step_tilt_forward": "Înclinați capacul spre față (marginea din față în jos), țineți-l, apoi eliberați-l.", - "ui.space_mouse_step_tilt_left": "Înclinați capacul spre stânga (marginea stângă în jos), țineți-l, apoi eliberați-l.", - "ui.space_mouse_step_tilt_right": "Înclinați capacul spre dreapta (marginea dreaptă în jos), țineți-l, apoi eliberați-l.", - "ui.space_mouse_step_twist_clockwise": "Răsuciți capacul în sensul acelor de ceasornic, țineți-l, apoi eliberați-l.", - "ui.space_mouse_step_twist_counterclockwise": "Răsuciți capacul în sens invers acelor de ceasornic, țineți-l, apoi eliberați-l.", "ui.spacing": "Spațiere", "ui.spacing_2": "Spațierea 2", "ui.sphere": "Sferă", diff --git a/assets/locales/ru.json b/assets/locales/ru.json index c193ff6478..cf001f0ac4 100644 --- a/assets/locales/ru.json +++ b/assets/locales/ru.json @@ -529,6 +529,10 @@ "cmd.file_insert_step": "Вставить STEP", "cmd.file_save_mesh": "Сохранить как меш", "cmd.file_slicers": "Установленные слайсеры", + "cmd.frame_member": "Элемент рамы", + "cmd.frame_profile_insert": "Вставить профиль", + "cmd.frame_profile_list": "Библиотека профилей", + "cmd.frame_profile_properties": "Свойства профиля", "cmd.inspect_accessibility": "Анализ доступности", "cmd.inspect_center_of_mass": "Центр масс", "cmd.inspect_clearance": "Зазор", @@ -1120,7 +1124,106 @@ "trace.stopped_by_contact": "Заканчивается на отсчёте {sample}: {a} прошёл бы сквозь {b}", "trace.stopped_by_loop": "Заканчивается на отсчёте {sample}: контур через {joint} дальше не замыкается", "ui.1_degree_of_freedom": "1 степень свободы", + "ui.3d_mouse_axes_reassigned": "Оси переназначены в настройках, и это действует и поверх этой калибровки.", + "ui.3d_mouse_button": "Кнопка {number}", + "ui.3d_mouse_button_nothing": "Ничего", + "ui.3d_mouse_button_settings": "Настройки 3D-мыши", + "ui.3d_mouse_buttons": "Кнопки", + "ui.3d_mouse_buttons_hint": "Нажмите кнопку на 3D-мыши, чтобы найти её здесь, и выберите, что она делает.", + "ui.3d_mouse_buttons_no_device": "Подключите 3D-мышь, чтобы назначить её кнопки.", + "ui.3d_mouse_buttons_reset": "Вернуть кнопки по умолчанию", + "ui.3d_mouse_calibrate": "Калибровать…", + "ui.3d_mouse_calibrate_tip": "Двигайте колпачок так, как просит окно, чтобы SolidCraft узнал, куда и насколько далеко движется каждая ось этой 3D-мыши. Сохраняется для этой 3D-мыши.", + "ui.3d_mouse_calibrate_title": "Калибровка 3D-мыши", + "ui.3d_mouse_calibrate_wait": "Один раз подвигайте 3D-мышь, чтобы SolidCraft её увидел.", + "ui.3d_mouse_calibrated": "Откалибрована", + "ui.3d_mouse_calibration": "Калибровка 3D-мыши", + "ui.3d_mouse_calibration_done": "Готово. Сохраните её, чтобы она применялась всякий раз, когда подключена эта 3D-мышь.", + "ui.3d_mouse_calibration_no_response": "Одна ось почти не сдвинулась, поэтому её нельзя откалибровать. Начните заново и каждый раз двигайте колпачок дальше.", + "ui.3d_mouse_circles_help": "Круги показывают, что устройство отправляет после калибровки. Тестовая модель показывает, что вид делает с этим после инверсии, скорости и назначения осей.", + "ui.3d_mouse_dead_zone": "Мёртвая зона", + "ui.3d_mouse_dead_zone_tip": "Насколько нужно сдвинуть колпачок, прежде чем сдвинется вид, в доле от полного хода. Увеличьте, если вид смещается, когда колпачок в покое.", + "ui.3d_mouse_denied": "SolidCraft видит 3D-мышь, но не имеет права её читать. Правило udev описано в заметках по настройке 3D-мыши.", + "ui.3d_mouse_denied_short": "Найдена, но недоступна для чтения", + "ui.3d_mouse_driven_by": "Какое откалиброванное движение устройства управляет этой осью. Если выбрать движение, которое уже использует другая ось, они поменяются местами.", + "ui.3d_mouse_driven_by_label": "управляется", + "ui.3d_mouse_driver": "Драйвер 3D-мыши", + "ui.3d_mouse_driver_auto": "Автоматически", + "ui.3d_mouse_driver_direct": "Напрямую (hidraw)", + "ui.3d_mouse_driver_fallback": "Пока чтение идёт через spacenavd: само устройство открыть не удалось.", + "ui.3d_mouse_driver_only_direct": "Чтение напрямую с устройства; spacenavd не запущен", + "ui.3d_mouse_driver_only_spacenavd": "Чтение через spacenavd, других вариантов нет", + "ui.3d_mouse_forget": "Забыть калибровку", + "ui.3d_mouse_ghost_left_viewport": "Область просмотра слева", + "ui.3d_mouse_ghost_left_window": "Окно слева", + "ui.3d_mouse_ghost_position": "Положение", + "ui.3d_mouse_ghost_right_viewport": "Область просмотра справа", + "ui.3d_mouse_hint": "Совместное перемещение и вращение вида; в эскизе — только панорама и масштаб.", + "ui.3d_mouse_kind_cap": "Возврат в центр", + "ui.3d_mouse_kind_question": "Какая у вас 3D-мышь?", + "ui.3d_mouse_kind_trackball": "Трекбол", + "ui.3d_mouse_looks_like_cap": "Значение держится, пока вы удерживаете, — как у устройства типа «Возврат в центр».", + "ui.3d_mouse_looks_like_trackball": "Значение возвращается, как только вы останавливаетесь, — как у трекбола.", + "ui.3d_mouse_motion_rotation_x": "Вращение X", + "ui.3d_mouse_motion_rotation_y": "Вращение Y", + "ui.3d_mouse_motion_rotation_z": "Вращение Z", + "ui.3d_mouse_motion_translation_x": "Смещение X", + "ui.3d_mouse_motion_translation_y": "Смещение Y", + "ui.3d_mouse_motion_translation_z": "Смещение Z", + "ui.3d_mouse_navigate": "Навигация с помощью 3D-мыши", + "ui.3d_mouse_none": "3D-мышь не найдена", + "ui.3d_mouse_not_calibrated": "Не откалибрована", + "ui.3d_mouse_offer": "Калибровка 3D-мыши исправляет перепутанные или перевёрнутые оси и настраивает скорость и ощущения.", + "ui.3d_mouse_offer_never": "Больше не показывать", + "ui.3d_mouse_offer_swap": "Переключить на Hidraw (прямой драйвер)", + "ui.3d_mouse_reading": "Показания", + "ui.3d_mouse_reset_axes": "Сбросить назначение осей", + "ui.3d_mouse_reset_view": "Сбросить вид", + "ui.3d_mouse_restore": "Восстановить настройки 3D-мыши", + "ui.3d_mouse_restore_ask": "Вернуть настройки 3D-мыши к значениям по умолчанию? Калибровка и назначение кнопок останутся.", + "ui.3d_mouse_section": "3D-мышь", + "ui.3d_mouse_show_in_view": "Показывать 3D-мышь в 3D-виде", + "ui.3d_mouse_show_in_view_tip": "Изображение вашей 3D-мыши в левом нижнем углу, которое двигается вместе с ней.", + "ui.3d_mouse_skip": "Моё устройство так не умеет", + "ui.3d_mouse_skip_tip": "Пропускает это движение в обе стороны; калибровка его не учитывает.", "ui.3d_mouse_speed": "Скорость 3D-мыши", + "ui.3d_mouse_step_lift": "Поднимите колпачок вверх, удержите и отпустите.", + "ui.3d_mouse_step_press": "Нажмите на колпачок вниз, удержите и отпустите.", + "ui.3d_mouse_step_pull": "Потяните колпачок на себя, удержите и отпустите.", + "ui.3d_mouse_step_push": "Толкните колпачок от себя, удержите и отпустите.", + "ui.3d_mouse_step_rest": "Уберите руку с колпачка и не трогайте его.", + "ui.3d_mouse_step_rest_trackball": "Уберите руку с шара и не трогайте его.", + "ui.3d_mouse_step_ring_clockwise": "Поверните кольцо прокрутки на несколько щелчков по часовой стрелке.", + "ui.3d_mouse_step_ring_counterclockwise": "Поверните кольцо прокрутки на несколько щелчков против часовой стрелки.", + "ui.3d_mouse_step_roll_away": "Прокатите шар от себя несколько раз и дайте ему остановиться.", + "ui.3d_mouse_step_roll_left": "Прокатите шар влево несколько раз и дайте ему остановиться.", + "ui.3d_mouse_step_roll_right": "Прокатите шар вправо несколько раз и дайте ему остановиться.", + "ui.3d_mouse_step_roll_toward": "Прокатите шар к себе несколько раз и дайте ему остановиться.", + "ui.3d_mouse_step_slide_left": "Сдвиньте колпачок влево, удержите и отпустите.", + "ui.3d_mouse_step_slide_right": "Сдвиньте колпачок вправо, удержите и отпустите.", + "ui.3d_mouse_step_spin_clockwise": "Покрутите шар по часовой стрелке несколько раз и дайте ему остановиться.", + "ui.3d_mouse_step_spin_counterclockwise": "Покрутите шар против часовой стрелки несколько раз и дайте ему остановиться.", + "ui.3d_mouse_step_tilt_away": "Наклоните верх колпачка от себя (ближний край поднимается), удержите и отпустите.", + "ui.3d_mouse_step_tilt_left": "Наклоните колпачок влево (левый край вниз), удержите и отпустите.", + "ui.3d_mouse_step_tilt_right": "Наклоните колпачок вправо (правый край вниз), удержите и отпустите.", + "ui.3d_mouse_step_tilt_toward": "Наклоните верх колпачка к себе (ближний край опускается), удержите и отпустите.", + "ui.3d_mouse_step_touch": "Положите руку на колпачок, не нажимая на него.", + "ui.3d_mouse_step_twist_clockwise": "Поверните колпачок по часовой стрелке, удержите и отпустите.", + "ui.3d_mouse_step_twist_counterclockwise": "Поверните колпачок против часовой стрелки, удержите и отпустите.", + "ui.3d_mouse_switch_cap": "Калибровать как возврат в центр", + "ui.3d_mouse_switch_trackball": "Калибровать как трекбол", + "ui.3d_mouse_view_axis": "Ось вида", + "ui.3d_mouse_view_off": "Выкл.", + "ui.3d_mouse_view_off_tip": "Профиль драйвера 3D-мыши отключает эту ось, поэтому её движение не двигает вид. Если выбрать здесь движение другой оси, они поменяются местами, и это движение перестанет двигать вид.", + "ui.3d_mouse_view_orbit_sideways": "Орбита влево / вправо", + "ui.3d_mouse_view_orbit_up_down": "Орбита вверх / вниз", + "ui.3d_mouse_view_pan_sideways": "Сдвиг влево / вправо", + "ui.3d_mouse_view_pan_up_down": "Сдвиг вверх / вниз", + "ui.3d_mouse_view_roll": "Крен", + "ui.3d_mouse_view_unmapped": "Ось {n}", + "ui.3d_mouse_view_zoom": "Масштаб", + "ui.3d_mouse_waiting": "Ожидание движения", + "ui.3d_mouse_wiggle": "Если 3D-мышь подключена, слегка подвигайте её.", "ui.3d_sketch": "3D-эскиз", "ui.activate": "Активировать", "ui.active_component": "Активный компонент", @@ -1350,6 +1453,7 @@ "ui.blocked": "Заблокировано", "ui.bodies": "Тела", "ui.bodies_all_if_none": "Тела (все, если не выбраны)", + "ui.bottom": "Снизу", "ui.box": "Бокс", "ui.break": "Разорвать", "ui.browser_bodies_one": "{count} тело", @@ -1793,59 +1897,6 @@ "ui.smooth_wheel_zoom": "Плавное масштабирование колесом", "ui.smooth_wheel_zoom_tip": "Отключите, чтобы каждое движение колеса применялось сразу, без сглаживания", "ui.snap": "Привязка", - "ui.space_mouse_axes_reassigned": "Оси переназначены в настройках, и это действует и поверх этой калибровки.", - "ui.space_mouse_calibrate": "Калибровать…", - "ui.space_mouse_calibrate_tip": "Двигайте колпачок так, как просит окно, чтобы SolidCraft узнал, куда и насколько далеко движется каждая ось этой 3D-мыши. Сохраняется для этой 3D-мыши.", - "ui.space_mouse_calibrate_title": "Калибровка 3D-мыши", - "ui.space_mouse_calibrated": "Откалибрована", - "ui.space_mouse_calibration": "Калибровка 3D-мыши", - "ui.space_mouse_calibration_done": "Готово. Сохраните её, чтобы она применялась всякий раз, когда подключена эта 3D-мышь.", - "ui.space_mouse_calibration_no_response": "Одна ось почти не сдвинулась, поэтому её нельзя откалибровать. Начните заново и каждый раз двигайте колпачок дальше.", - "ui.space_mouse_dead_zone": "Мёртвая зона", - "ui.space_mouse_dead_zone_tip": "Насколько нужно сдвинуть колпачок, прежде чем сдвинется вид, в доле от полного хода. Увеличьте, если вид смещается, когда колпачок в покое.", - "ui.space_mouse_driven_by": "Какое откалиброванное движение устройства управляет этой осью. Если выбрать движение, которое уже использует другая ось, они поменяются местами.", - "ui.space_mouse_driven_by_label": "управляется", - "ui.space_mouse_driver": "Драйвер 3D-мыши", - "ui.space_mouse_driver_auto": "Автоматически", - "ui.space_mouse_driver_direct": "Напрямую (hidraw)", - "ui.space_mouse_driver_only_direct": "Чтение напрямую с устройства; spacenavd не запущен", - "ui.space_mouse_driver_only_spacenavd": "Чтение через spacenavd, других вариантов нет", - "ui.space_mouse_forget": "Забыть калибровку", - "ui.space_mouse_hint": "Совместное перемещение и вращение вида; в эскизе — только панорама и масштаб.", - "ui.space_mouse_motion_rotation_x": "Вращение X", - "ui.space_mouse_motion_rotation_y": "Вращение Y", - "ui.space_mouse_motion_rotation_z": "Вращение Z", - "ui.space_mouse_motion_translation_x": "Смещение X", - "ui.space_mouse_motion_translation_y": "Смещение Y", - "ui.space_mouse_motion_translation_z": "Смещение Z", - "ui.space_mouse_navigate": "Навигация через SpaceNavigator / SpaceMouse", - "ui.space_mouse_none": "3D-мышь не найдена", - "ui.space_mouse_not_calibrated": "Не откалибрована", - "ui.space_mouse_reading": "Показания", - "ui.space_mouse_reset_axes": "Сбросить назначение осей", - "ui.space_mouse_step_lift": "Поднимите колпачок вверх, удержите и отпустите.", - "ui.space_mouse_step_press": "Нажмите на колпачок вниз, удержите и отпустите.", - "ui.space_mouse_step_pull": "Потяните колпачок на себя, удержите и отпустите.", - "ui.space_mouse_step_push": "Толкните колпачок от себя, удержите и отпустите.", - "ui.space_mouse_step_rest": "Уберите руку с колпачка и не трогайте его.", - "ui.space_mouse_step_slide_left": "Сдвиньте колпачок влево, удержите и отпустите.", - "ui.space_mouse_step_slide_right": "Сдвиньте колпачок вправо, удержите и отпустите.", - "ui.space_mouse_step_tilt_back": "Наклоните колпачок назад (передний край вверх), удержите и отпустите.", - "ui.space_mouse_step_tilt_forward": "Наклоните колпачок вперёд (передний край вниз), удержите и отпустите.", - "ui.space_mouse_step_tilt_left": "Наклоните колпачок влево (левый край вниз), удержите и отпустите.", - "ui.space_mouse_step_tilt_right": "Наклоните колпачок вправо (правый край вниз), удержите и отпустите.", - "ui.space_mouse_step_twist_clockwise": "Поверните колпачок по часовой стрелке, удержите и отпустите.", - "ui.space_mouse_step_twist_counterclockwise": "Поверните колпачок против часовой стрелки, удержите и отпустите.", - "ui.space_mouse_view_axis": "Ось вида", - "ui.space_mouse_view_off": "Выкл.", - "ui.space_mouse_view_off_tip": "Профиль драйвера 3D-мыши отключает эту ось, поэтому её движение не двигает вид. Если выбрать здесь движение другой оси, они поменяются местами, и это движение перестанет двигать вид.", - "ui.space_mouse_view_orbit_sideways": "Орбита влево / вправо", - "ui.space_mouse_view_orbit_up_down": "Орбита вверх / вниз", - "ui.space_mouse_view_pan_sideways": "Сдвиг влево / вправо", - "ui.space_mouse_view_pan_up_down": "Сдвиг вверх / вниз", - "ui.space_mouse_view_roll": "Крен", - "ui.space_mouse_view_unmapped": "Ось {n}", - "ui.space_mouse_view_zoom": "Масштаб", "ui.spacing": "Интервал", "ui.spacing_2": "Интервал 2", "ui.sphere": "Сфера", diff --git a/assets/locales/sv.json b/assets/locales/sv.json index ca4ae65212..312af8917a 100644 --- a/assets/locales/sv.json +++ b/assets/locales/sv.json @@ -529,6 +529,10 @@ "cmd.file_insert_step": "Infoga STEP", "cmd.file_save_mesh": "Spara som mesh", "cmd.file_slicers": "Installerade slicers", + "cmd.frame_member": "Ramdel", + "cmd.frame_profile_insert": "Infoga profil", + "cmd.frame_profile_list": "Profilbibliotek", + "cmd.frame_profile_properties": "Profilegenskaper", "cmd.inspect_accessibility": "Tillgänglighetsanalys", "cmd.inspect_center_of_mass": "Masscentrum", "cmd.inspect_clearance": "Frigång", @@ -1120,7 +1124,50 @@ "trace.stopped_by_contact": "Slutar vid prov {sample}: {a} skulle gå igenom {b}", "trace.stopped_by_loop": "Slutar vid prov {sample}: slingan genom {joint} kan inte stängas längre", "ui.1_degree_of_freedom": "1 frihetsgrad", + "ui.3d_mouse_calibrate": "Kalibrera…", + "ui.3d_mouse_calibrate_tip": "Flytta knappen som fönstret ber om, så att SolidCraft lär sig åt vilket håll och hur långt varje axel på denna 3D-mus går. Sparas för denna 3D-mus.", + "ui.3d_mouse_calibrate_title": "Kalibrera 3D-mus", + "ui.3d_mouse_calibrate_wait": "Rör 3D-musen en gång så att SolidCraft ser den.", + "ui.3d_mouse_calibrated": "Kalibrerad", + "ui.3d_mouse_calibration": "Kalibrering av 3D-mus", + "ui.3d_mouse_calibration_done": "Klart. Spara den för att använda den när denna 3D-mus är ansluten.", + "ui.3d_mouse_calibration_no_response": "En axel rörde sig knappt, så den kan inte kalibreras. Börja om och flytta knappen längre varje gång.", + "ui.3d_mouse_circles_help": "Cirklarna visar vad enheten skickar efter kalibrering. Testmodellen visar vad vyn gör med det, efter inversion, hastighet och axeltilldelning.", + "ui.3d_mouse_dead_zone": "Dödzon", + "ui.3d_mouse_dead_zone_tip": "Hur långt knappen måste röra sig innan vyn gör det, som andel av dess fulla rörelse. Höj den om vyn driver medan knappen vilar.", + "ui.3d_mouse_denied": "SolidCraft ser en 3D-mus men får inte läsa den. Se installationsanteckningarna för 3D-musen om udev-regeln.", + "ui.3d_mouse_denied_short": "Hittad, men går inte att läsa", + "ui.3d_mouse_driver_fallback": "Läser tills vidare via spacenavd: själva enheten kunde inte öppnas.", + "ui.3d_mouse_forget": "Glöm kalibrering", + "ui.3d_mouse_ghost_left_viewport": "Vyn vänster", + "ui.3d_mouse_ghost_left_window": "Fönster vänster", + "ui.3d_mouse_ghost_position": "Placering", + "ui.3d_mouse_ghost_right_viewport": "Vyn höger", + "ui.3d_mouse_hint": "Flytta och rotera vyn tillsammans; skisser använder endast panorering och zoom.", + "ui.3d_mouse_navigate": "Navigera med 3D-musen", + "ui.3d_mouse_none": "Ingen 3D-mus hittades", + "ui.3d_mouse_not_calibrated": "Inte kalibrerad", + "ui.3d_mouse_offer": "Att kalibrera din 3D-mus rättar förväxlade eller omvända axlar och ställer in hastighet och känsla.", + "ui.3d_mouse_offer_never": "Visa inte igen", + "ui.3d_mouse_offer_swap": "Byt till Hidraw (direkt drivrutin)", + "ui.3d_mouse_reading": "Avläsning", + "ui.3d_mouse_reset_view": "Återställ vyn", + "ui.3d_mouse_restore": "Återställ 3D-musens standardvärden", + "ui.3d_mouse_restore_ask": "Vill du återställa 3D-musens inställningar till standard? Din kalibrering och knapptilldelning behålls.", "ui.3d_mouse_speed": "3D-musens hastighet", + "ui.3d_mouse_step_lift": "Lyft knappen uppåt, håll den och släpp sedan.", + "ui.3d_mouse_step_press": "Tryck ned knappen, håll den och släpp sedan.", + "ui.3d_mouse_step_pull": "Dra knappen mot dig, håll den och släpp sedan.", + "ui.3d_mouse_step_push": "Skjut knappen bort från dig, håll den och släpp sedan.", + "ui.3d_mouse_step_rest": "Ta bort handen från knappen och låt den vara stilla.", + "ui.3d_mouse_step_slide_left": "Skjut knappen åt vänster, håll den och släpp sedan.", + "ui.3d_mouse_step_slide_right": "Skjut knappen åt höger, håll den och släpp sedan.", + "ui.3d_mouse_step_tilt_left": "Luta knappen åt vänster (vänsterkanten nedåt), håll den och släpp sedan.", + "ui.3d_mouse_step_tilt_right": "Luta knappen åt höger (högerkanten nedåt), håll den och släpp sedan.", + "ui.3d_mouse_step_twist_clockwise": "Vrid knappen medurs, håll den och släpp sedan.", + "ui.3d_mouse_step_twist_counterclockwise": "Vrid knappen moturs, håll den och släpp sedan.", + "ui.3d_mouse_waiting": "Väntar på rörelse", + "ui.3d_mouse_wiggle": "Om du har en ansluten, rör lite på den.", "ui.3d_sketch": "3D-skiss", "ui.activate": "Aktivera", "ui.active_component": "Aktiv komponent", @@ -1793,34 +1840,6 @@ "ui.smooth_wheel_zoom": "Jämn hjulzoom", "ui.smooth_wheel_zoom_tip": "Stäng av för att tillämpa varje hjulhändelse direkt, utan utjämningsfördröjning", "ui.snap": "Snappa", - "ui.space_mouse_calibrate": "Kalibrera…", - "ui.space_mouse_calibrate_tip": "Flytta knappen som fönstret ber om, så att SolidCraft lär sig åt vilket håll och hur långt varje axel på denna 3D-mus går. Sparas för denna 3D-mus.", - "ui.space_mouse_calibrate_title": "Kalibrera 3D-mus", - "ui.space_mouse_calibrated": "Kalibrerad", - "ui.space_mouse_calibration": "Kalibrering av 3D-mus", - "ui.space_mouse_calibration_done": "Klart. Spara den för att använda den när denna 3D-mus är ansluten.", - "ui.space_mouse_calibration_no_response": "En axel rörde sig knappt, så den kan inte kalibreras. Börja om och flytta knappen längre varje gång.", - "ui.space_mouse_dead_zone": "Dödzon", - "ui.space_mouse_dead_zone_tip": "Hur långt knappen måste röra sig innan vyn gör det, som andel av dess fulla rörelse. Höj den om vyn driver medan knappen vilar.", - "ui.space_mouse_forget": "Glöm kalibrering", - "ui.space_mouse_hint": "Flytta och rotera vyn tillsammans; skisser använder endast panorering och zoom.", - "ui.space_mouse_navigate": "Navigera med SpaceNavigator / SpaceMouse", - "ui.space_mouse_none": "Ingen 3D-mus hittades", - "ui.space_mouse_not_calibrated": "Inte kalibrerad", - "ui.space_mouse_reading": "Avläsning", - "ui.space_mouse_step_lift": "Lyft knappen uppåt, håll den och släpp sedan.", - "ui.space_mouse_step_press": "Tryck ned knappen, håll den och släpp sedan.", - "ui.space_mouse_step_pull": "Dra knappen mot dig, håll den och släpp sedan.", - "ui.space_mouse_step_push": "Skjut knappen bort från dig, håll den och släpp sedan.", - "ui.space_mouse_step_rest": "Ta bort handen från knappen och låt den vara stilla.", - "ui.space_mouse_step_slide_left": "Skjut knappen åt vänster, håll den och släpp sedan.", - "ui.space_mouse_step_slide_right": "Skjut knappen åt höger, håll den och släpp sedan.", - "ui.space_mouse_step_tilt_back": "Luta knappen bakåt (framkanten uppåt), håll den och släpp sedan.", - "ui.space_mouse_step_tilt_forward": "Luta knappen framåt (framkanten nedåt), håll den och släpp sedan.", - "ui.space_mouse_step_tilt_left": "Luta knappen åt vänster (vänsterkanten nedåt), håll den och släpp sedan.", - "ui.space_mouse_step_tilt_right": "Luta knappen åt höger (högerkanten nedåt), håll den och släpp sedan.", - "ui.space_mouse_step_twist_clockwise": "Vrid knappen medurs, håll den och släpp sedan.", - "ui.space_mouse_step_twist_counterclockwise": "Vrid knappen moturs, håll den och släpp sedan.", "ui.spacing": "Avstånd", "ui.spacing_2": "Avstånd 2", "ui.sphere": "Sfär", diff --git a/assets/locales/tr.json b/assets/locales/tr.json index 3667efbbea..cedea77b24 100644 --- a/assets/locales/tr.json +++ b/assets/locales/tr.json @@ -529,6 +529,10 @@ "cmd.file_insert_step": "STEP ekle", "cmd.file_save_mesh": "Ağ olarak kaydet", "cmd.file_slicers": "Yüklü dilimleyiciler", + "cmd.frame_member": "Çerçeve Elemanı", + "cmd.frame_profile_insert": "Profil Ekle", + "cmd.frame_profile_list": "Profil Kitaplığı", + "cmd.frame_profile_properties": "Profil Özellikleri", "cmd.inspect_accessibility": "Erişilebilirlik analizi", "cmd.inspect_center_of_mass": "Kütle merkezi", "cmd.inspect_clearance": "Açıklık", @@ -1120,7 +1124,106 @@ "trace.stopped_by_contact": "Örnek {sample} noktasında biter: {a}, {b} içinden geçecek", "trace.stopped_by_loop": "Örnek {sample} noktasında biter: {joint} üzerinden geçen döngü daha fazla kapanamaz", "ui.1_degree_of_freedom": "1 serbestlik derecesi", + "ui.3d_mouse_axes_reassigned": "Eksenler Tercihler'de yeniden atanmış; bu, bu kalibrasyonun üstüne de uygulanır.", + "ui.3d_mouse_button": "Düğme {number}", + "ui.3d_mouse_button_nothing": "Hiçbir şey", + "ui.3d_mouse_button_settings": "3B fare ayarları", + "ui.3d_mouse_buttons": "Düğmeler", + "ui.3d_mouse_buttons_hint": "Burada bulmak için 3B farenizdeki bir düğmeye basın, sonra ne yapacağını seçin.", + "ui.3d_mouse_buttons_no_device": "Düğmelerinin ne yapacağını seçmek için bir 3B fare bağlayın.", + "ui.3d_mouse_buttons_reset": "Varsayılan düğmelere dön", + "ui.3d_mouse_calibrate": "Kalibre et…", + "ui.3d_mouse_calibrate_tip": "Pencerenin istediği gibi başlığı hareket ettirin; SolidCraft bu 3B farenin her ekseninin hangi yöne ve ne kadar gittiğini öğrensin. Bu 3B fare için kaydedilir.", + "ui.3d_mouse_calibrate_title": "3B fareyi kalibre et", + "ui.3d_mouse_calibrate_wait": "SolidCraft'ın görebilmesi için 3D fareyi bir kez hareket ettirin.", + "ui.3d_mouse_calibrated": "Kalibre edildi", + "ui.3d_mouse_calibration": "3B fare kalibrasyonu", + "ui.3d_mouse_calibration_done": "Bitti. Bu 3B fare her bağlandığında kullanılması için kaydedin.", + "ui.3d_mouse_calibration_no_response": "Bir eksen neredeyse hiç hareket etmedi, bu yüzden kalibre edilemiyor. Baştan başlayın ve başlığı her seferinde daha fazla hareket ettirin.", + "ui.3d_mouse_circles_help": "Daireler, aygıtın kalibrasyondan sonra gönderdiğini gösterir. Test modeli, görünümün bunu ters çevirme, hız ve eksen atamasından sonra ne yaptığını gösterir.", + "ui.3d_mouse_dead_zone": "Ölü bölge", + "ui.3d_mouse_dead_zone_tip": "Görünüm hareket etmeden önce başlığın ne kadar hareket etmesi gerektiği, tam hareket aralığının oranı olarak. Başlık dururken görünüm kayıyorsa artırın.", + "ui.3d_mouse_denied": "SolidCraft bir 3D fare görüyor ama onu okumasına izin verilmiyor. udev kuralı için 3D fare kurulum notlarına bakın.", + "ui.3d_mouse_denied_short": "Bulundu, ancak okunamıyor", + "ui.3d_mouse_driven_by": "Bu ekseni cihazın hangi kalibre edilmiş hareketinin yönettiği. Başka bir eksenin zaten kullandığını seçerseniz ikisi yer değiştirir.", + "ui.3d_mouse_driven_by_label": "kumanda eden", + "ui.3d_mouse_driver": "3B fare sürücüsü", + "ui.3d_mouse_driver_auto": "Otomatik", + "ui.3d_mouse_driver_direct": "Doğrudan (hidraw)", + "ui.3d_mouse_driver_fallback": "Şimdilik spacenavd üzerinden okunuyor: aygıtın kendisi açılamadı.", + "ui.3d_mouse_driver_only_direct": "Aygıt doğrudan okunuyor; spacenavd çalışmıyor", + "ui.3d_mouse_driver_only_spacenavd": "spacenavd üzerinden okunuyor, tek seçenek bu", + "ui.3d_mouse_forget": "Kalibrasyonu unut", + "ui.3d_mouse_ghost_left_viewport": "Sol görünüm", + "ui.3d_mouse_ghost_left_window": "Sol pencere", + "ui.3d_mouse_ghost_position": "Konum", + "ui.3d_mouse_ghost_right_viewport": "Sağ görünüm", + "ui.3d_mouse_hint": "Görünümü aynı anda kaydırın ve döndürün; eskizlerde yalnızca kaydırma ve yakınlaştırma kullanılır.", + "ui.3d_mouse_kind_cap": "Merkeze dönüş", + "ui.3d_mouse_kind_question": "Ne tür bir 3B fare?", + "ui.3d_mouse_kind_trackball": "İztopu", + "ui.3d_mouse_looks_like_cap": "Tuttuğunuz sürece değer yerinde kalıyor, “Merkeze dönüş” türü bir aygıttaki gibi.", + "ui.3d_mouse_looks_like_trackball": "Hareketi bırakır bırakmaz değer geri dönüyor, iztopunda olduğu gibi.", + "ui.3d_mouse_motion_rotation_x": "Dönme X", + "ui.3d_mouse_motion_rotation_y": "Dönme Y", + "ui.3d_mouse_motion_rotation_z": "Dönme Z", + "ui.3d_mouse_motion_translation_x": "Öteleme X", + "ui.3d_mouse_motion_translation_y": "Öteleme Y", + "ui.3d_mouse_motion_translation_z": "Öteleme Z", + "ui.3d_mouse_navigate": "3D fare ile gezin", + "ui.3d_mouse_none": "3B fare bulunamadı", + "ui.3d_mouse_not_calibrated": "Kalibre edilmedi", + "ui.3d_mouse_offer": "3D farenizi kalibre etmek, yer değiştirmiş veya ters dönmüş eksenleri düzeltir; hızını ve hissini ayarlar.", + "ui.3d_mouse_offer_never": "Bir daha gösterme", + "ui.3d_mouse_offer_swap": "Hidraw'a geç (doğrudan sürücü)", + "ui.3d_mouse_reading": "Okunan değer", + "ui.3d_mouse_reset_axes": "Eksen atamasını sıfırla", + "ui.3d_mouse_reset_view": "Görünümü sıfırla", + "ui.3d_mouse_restore": "3D fare varsayılanlarını geri yükle", + "ui.3d_mouse_restore_ask": "3D fare ayarları varsayılanlara döndürülsün mü? Kalibrasyon ve düğme eşlemesi korunur.", + "ui.3d_mouse_section": "3D fare", + "ui.3d_mouse_show_in_view": "3B fareyi 3B görünümde göster", + "ui.3d_mouse_show_in_view_tip": "Sol alt köşede, siz hareket ettirdikçe hareket eden bir 3B fare resmi.", + "ui.3d_mouse_skip": "Cihazım bunu yapamıyor", + "ui.3d_mouse_skip_tip": "Bu hareketi iki yönde de atlar; kalibrasyon onu dışarıda bırakır.", "ui.3d_mouse_speed": "3D fare hızı", + "ui.3d_mouse_step_lift": "Başlığı yukarı kaldırın, tutun, sonra bırakın.", + "ui.3d_mouse_step_press": "Başlığı aşağı bastırın, tutun, sonra bırakın.", + "ui.3d_mouse_step_pull": "Başlığı kendinize doğru çekin, tutun, sonra bırakın.", + "ui.3d_mouse_step_push": "Başlığı sizden uzağa itin, tutun, sonra bırakın.", + "ui.3d_mouse_step_rest": "Elinizi başlıktan çekin ve hareketsiz bırakın.", + "ui.3d_mouse_step_rest_trackball": "Elinizi toptan çekin ve hareketsiz bırakın.", + "ui.3d_mouse_step_ring_clockwise": "Kaydırma halkasını saat yönünde birkaç tık çevirin.", + "ui.3d_mouse_step_ring_counterclockwise": "Kaydırma halkasını saat yönünün tersine birkaç tık çevirin.", + "ui.3d_mouse_step_roll_away": "Topu birkaç kez sizden uzağa yuvarlayın, sonra durmasını bekleyin.", + "ui.3d_mouse_step_roll_left": "Topu birkaç kez sola yuvarlayın, sonra durmasını bekleyin.", + "ui.3d_mouse_step_roll_right": "Topu birkaç kez sağa yuvarlayın, sonra durmasını bekleyin.", + "ui.3d_mouse_step_roll_toward": "Topu birkaç kez kendinize doğru yuvarlayın, sonra durmasını bekleyin.", + "ui.3d_mouse_step_slide_left": "Başlığı sola kaydırın, tutun, sonra bırakın.", + "ui.3d_mouse_step_slide_right": "Başlığı sağa kaydırın, tutun, sonra bırakın.", + "ui.3d_mouse_step_spin_clockwise": "Topu birkaç kez saat yönünde döndürün, sonra durmasını bekleyin.", + "ui.3d_mouse_step_spin_counterclockwise": "Topu birkaç kez saat yönünün tersine döndürün, sonra durmasını bekleyin.", + "ui.3d_mouse_step_tilt_away": "Başlığın üstünü sizden uzağa eğin (size en yakın kenar yukarı kalkar), tutun, sonra bırakın.", + "ui.3d_mouse_step_tilt_left": "Başlığı sola eğin (sol kenar aşağı), tutun, sonra bırakın.", + "ui.3d_mouse_step_tilt_right": "Başlığı sağa eğin (sağ kenar aşağı), tutun, sonra bırakın.", + "ui.3d_mouse_step_tilt_toward": "Başlığın üstünü kendinize doğru eğin (size en yakın kenar aşağı iner), tutun, sonra bırakın.", + "ui.3d_mouse_step_touch": "Elinizi başlığa itmeden koyun.", + "ui.3d_mouse_step_twist_clockwise": "Başlığı saat yönünde çevirin, tutun, sonra bırakın.", + "ui.3d_mouse_step_twist_counterclockwise": "Başlığı saat yönünün tersine çevirin, tutun, sonra bırakın.", + "ui.3d_mouse_switch_cap": "Merkeze dönüş olarak kalibre et", + "ui.3d_mouse_switch_trackball": "İztopu olarak kalibre et", + "ui.3d_mouse_view_axis": "Görünüm ekseni", + "ui.3d_mouse_view_off": "Kapalı", + "ui.3d_mouse_view_off_tip": "3D fare sürücüsünün profili bu ekseni kapatıyor, bu yüzden hareketi görünümü oynatmaz. Burada başka bir eksenin hareketini seçerseniz ikisi yer değiştirir ve o hareket görünümü oynatmayı bırakır.", + "ui.3d_mouse_view_orbit_sideways": "Sola / sağa yörünge", + "ui.3d_mouse_view_orbit_up_down": "Yukarı / aşağı yörünge", + "ui.3d_mouse_view_pan_sideways": "Sola / sağa kaydır", + "ui.3d_mouse_view_pan_up_down": "Yukarı / aşağı kaydır", + "ui.3d_mouse_view_roll": "Yuvarlanma", + "ui.3d_mouse_view_unmapped": "Eksen {n}", + "ui.3d_mouse_view_zoom": "Yakınlaştır", + "ui.3d_mouse_waiting": "Hareket bekleniyor", + "ui.3d_mouse_wiggle": "Takılı bir tane varsa biraz oynatın.", "ui.3d_sketch": "3D eskiz", "ui.activate": "Etkinleştir", "ui.active_component": "Etkin bileşen", @@ -1350,6 +1453,7 @@ "ui.blocked": "Engellenmiş", "ui.bodies": "Gövdeler", "ui.bodies_all_if_none": "Gövdeler (yoksa tümü)", + "ui.bottom": "Alt", "ui.box": "Kutu", "ui.break": "Kır", "ui.browser_bodies_one": "{count} gövde", @@ -1793,59 +1897,6 @@ "ui.smooth_wheel_zoom": "Yumuşak tekerlek yakınlaştırması", "ui.smooth_wheel_zoom_tip": "Her tekerlek hareketini yumuşatma olmadan hemen uygulamak için kapatın", "ui.snap": "Yakala", - "ui.space_mouse_axes_reassigned": "Eksenler Tercihler'de yeniden atanmış; bu, bu kalibrasyonun üstüne de uygulanır.", - "ui.space_mouse_calibrate": "Kalibre et…", - "ui.space_mouse_calibrate_tip": "Pencerenin istediği gibi başlığı hareket ettirin; SolidCraft bu 3B farenin her ekseninin hangi yöne ve ne kadar gittiğini öğrensin. Bu 3B fare için kaydedilir.", - "ui.space_mouse_calibrate_title": "3B fareyi kalibre et", - "ui.space_mouse_calibrated": "Kalibre edildi", - "ui.space_mouse_calibration": "3B fare kalibrasyonu", - "ui.space_mouse_calibration_done": "Bitti. Bu 3B fare her bağlandığında kullanılması için kaydedin.", - "ui.space_mouse_calibration_no_response": "Bir eksen neredeyse hiç hareket etmedi, bu yüzden kalibre edilemiyor. Baştan başlayın ve başlığı her seferinde daha fazla hareket ettirin.", - "ui.space_mouse_dead_zone": "Ölü bölge", - "ui.space_mouse_dead_zone_tip": "Görünüm hareket etmeden önce başlığın ne kadar hareket etmesi gerektiği, tam hareket aralığının oranı olarak. Başlık dururken görünüm kayıyorsa artırın.", - "ui.space_mouse_driven_by": "Bu ekseni cihazın hangi kalibre edilmiş hareketinin yönettiği. Başka bir eksenin zaten kullandığını seçerseniz ikisi yer değiştirir.", - "ui.space_mouse_driven_by_label": "kumanda eden", - "ui.space_mouse_driver": "3B fare sürücüsü", - "ui.space_mouse_driver_auto": "Otomatik", - "ui.space_mouse_driver_direct": "Doğrudan (hidraw)", - "ui.space_mouse_driver_only_direct": "Aygıt doğrudan okunuyor; spacenavd çalışmıyor", - "ui.space_mouse_driver_only_spacenavd": "spacenavd üzerinden okunuyor, tek seçenek bu", - "ui.space_mouse_forget": "Kalibrasyonu unut", - "ui.space_mouse_hint": "Görünümü aynı anda kaydırın ve döndürün; eskizlerde yalnızca kaydırma ve yakınlaştırma kullanılır.", - "ui.space_mouse_motion_rotation_x": "Dönme X", - "ui.space_mouse_motion_rotation_y": "Dönme Y", - "ui.space_mouse_motion_rotation_z": "Dönme Z", - "ui.space_mouse_motion_translation_x": "Öteleme X", - "ui.space_mouse_motion_translation_y": "Öteleme Y", - "ui.space_mouse_motion_translation_z": "Öteleme Z", - "ui.space_mouse_navigate": "SpaceNavigator / SpaceMouse ile gezin", - "ui.space_mouse_none": "3B fare bulunamadı", - "ui.space_mouse_not_calibrated": "Kalibre edilmedi", - "ui.space_mouse_reading": "Okunan değer", - "ui.space_mouse_reset_axes": "Eksen atamasını sıfırla", - "ui.space_mouse_step_lift": "Başlığı yukarı kaldırın, tutun, sonra bırakın.", - "ui.space_mouse_step_press": "Başlığı aşağı bastırın, tutun, sonra bırakın.", - "ui.space_mouse_step_pull": "Başlığı kendinize doğru çekin, tutun, sonra bırakın.", - "ui.space_mouse_step_push": "Başlığı sizden uzağa itin, tutun, sonra bırakın.", - "ui.space_mouse_step_rest": "Elinizi başlıktan çekin ve hareketsiz bırakın.", - "ui.space_mouse_step_slide_left": "Başlığı sola kaydırın, tutun, sonra bırakın.", - "ui.space_mouse_step_slide_right": "Başlığı sağa kaydırın, tutun, sonra bırakın.", - "ui.space_mouse_step_tilt_back": "Başlığı geriye eğin (ön kenar yukarı), tutun, sonra bırakın.", - "ui.space_mouse_step_tilt_forward": "Başlığı öne eğin (ön kenar aşağı), tutun, sonra bırakın.", - "ui.space_mouse_step_tilt_left": "Başlığı sola eğin (sol kenar aşağı), tutun, sonra bırakın.", - "ui.space_mouse_step_tilt_right": "Başlığı sağa eğin (sağ kenar aşağı), tutun, sonra bırakın.", - "ui.space_mouse_step_twist_clockwise": "Başlığı saat yönünde çevirin, tutun, sonra bırakın.", - "ui.space_mouse_step_twist_counterclockwise": "Başlığı saat yönünün tersine çevirin, tutun, sonra bırakın.", - "ui.space_mouse_view_axis": "Görünüm ekseni", - "ui.space_mouse_view_off": "Kapalı", - "ui.space_mouse_view_off_tip": "3D fare sürücüsünün profili bu ekseni kapatıyor, bu yüzden hareketi görünümü oynatmaz. Burada başka bir eksenin hareketini seçerseniz ikisi yer değiştirir ve o hareket görünümü oynatmayı bırakır.", - "ui.space_mouse_view_orbit_sideways": "Sola / sağa yörünge", - "ui.space_mouse_view_orbit_up_down": "Yukarı / aşağı yörünge", - "ui.space_mouse_view_pan_sideways": "Sola / sağa kaydır", - "ui.space_mouse_view_pan_up_down": "Yukarı / aşağı kaydır", - "ui.space_mouse_view_roll": "Yuvarlanma", - "ui.space_mouse_view_unmapped": "Eksen {n}", - "ui.space_mouse_view_zoom": "Yakınlaştır", "ui.spacing": "Aralık", "ui.spacing_2": "Aralık 2", "ui.sphere": "Küre", diff --git a/assets/locales/uk.json b/assets/locales/uk.json index b42b8ae64f..8bd7f0867b 100644 --- a/assets/locales/uk.json +++ b/assets/locales/uk.json @@ -530,6 +530,10 @@ "cmd.file_insert_step": "Вставити STEP", "cmd.file_save_mesh": "Зберегти як сітку", "cmd.file_slicers": "Встановлені слайсери", + "cmd.frame_member": "Елемент рами", + "cmd.frame_profile_insert": "Вставити профіль", + "cmd.frame_profile_list": "Бібліотека профілів", + "cmd.frame_profile_properties": "Властивості профілю", "cmd.inspect_accessibility": "Аналіз доступності", "cmd.inspect_center_of_mass": "Центр мас", "cmd.inspect_clearance": "Зазор", @@ -1121,7 +1125,106 @@ "trace.stopped_by_contact": "Закінчується на відліку {sample}: {a} пройшов би крізь {b}", "trace.stopped_by_loop": "Закінчується на відліку {sample}: контур через {joint} далі не замикається", "ui.1_degree_of_freedom": "1 ступінь вільності", + "ui.3d_mouse_axes_reassigned": "Осі перепризначено в налаштуваннях, і це діє й поверх цього калібрування.", + "ui.3d_mouse_button": "Кнопка {number}", + "ui.3d_mouse_button_nothing": "Нічого", + "ui.3d_mouse_button_settings": "Налаштування 3D-миші", + "ui.3d_mouse_buttons": "Кнопки", + "ui.3d_mouse_buttons_hint": "Натисніть кнопку на 3D-миші, щоб знайти її тут, і виберіть, що вона робить.", + "ui.3d_mouse_buttons_no_device": "Під'єднайте 3D-мишу, щоб призначити її кнопки.", + "ui.3d_mouse_buttons_reset": "Повернути типові кнопки", + "ui.3d_mouse_calibrate": "Калібрувати…", + "ui.3d_mouse_calibrate_tip": "Рухайте ковпачок так, як просить вікно, щоб SolidCraft дізнався, куди й наскільки далеко рухається кожна вісь цієї 3D-миші. Зберігається для цієї 3D-миші.", + "ui.3d_mouse_calibrate_title": "Калібрування 3D-миші", + "ui.3d_mouse_calibrate_wait": "Один раз порухайте 3D-мишу, щоб SolidCraft її побачив.", + "ui.3d_mouse_calibrated": "Відкалібровано", + "ui.3d_mouse_calibration": "Калібрування 3D-миші", + "ui.3d_mouse_calibration_done": "Готово. Збережіть його, щоб воно застосовувалося щоразу, коли підключено цю 3D-мишу.", + "ui.3d_mouse_calibration_no_response": "Одна вісь майже не зрушила, тому її не можна відкалібрувати. Почніть спочатку й щоразу рухайте ковпачок далі.", + "ui.3d_mouse_circles_help": "Кола показують, що пристрій надсилає після калібрування. Тестова модель показує, що вигляд робить із цим після інверсії, швидкості та призначення осей.", + "ui.3d_mouse_dead_zone": "Мертва зона", + "ui.3d_mouse_dead_zone_tip": "Наскільки треба зсунути ковпачок, перш ніж зрушить вигляд, у частці від повного ходу. Збільште, якщо вигляд зміщується, коли ковпачок у спокої.", + "ui.3d_mouse_denied": "SolidCraft бачить 3D-мишу, але не має права її читати. Правило udev описано в нотатках із налаштування 3D-миші.", + "ui.3d_mouse_denied_short": "Знайдено, але не читається", + "ui.3d_mouse_driven_by": "Який відкалібрований рух пристрою керує цією віссю. Якщо вибрати рух, який уже використовує інша вісь, вони поміняються місцями.", + "ui.3d_mouse_driven_by_label": "керується", + "ui.3d_mouse_driver": "Драйвер 3D-миші", + "ui.3d_mouse_driver_auto": "Автоматично", + "ui.3d_mouse_driver_direct": "Напряму (hidraw)", + "ui.3d_mouse_driver_fallback": "Наразі читання йде через spacenavd: сам пристрій відкрити не вдалося.", + "ui.3d_mouse_driver_only_direct": "Читання напряму з пристрою; spacenavd не запущено", + "ui.3d_mouse_driver_only_spacenavd": "Читання через spacenavd, інших варіантів немає", + "ui.3d_mouse_forget": "Забути калібрування", + "ui.3d_mouse_ghost_left_viewport": "Ліворуч у вікні перегляду", + "ui.3d_mouse_ghost_left_window": "Ліворуч у вікні", + "ui.3d_mouse_ghost_position": "Розташування", + "ui.3d_mouse_ghost_right_viewport": "Праворуч у вікні перегляду", + "ui.3d_mouse_hint": "Переміщуйте й обертайте вид разом; в ескізах доступні лише панорамування та масштаб.", + "ui.3d_mouse_kind_cap": "Повернення до центру", + "ui.3d_mouse_kind_question": "Яка у вас 3D-миша?", + "ui.3d_mouse_kind_trackball": "Трекбол", + "ui.3d_mouse_looks_like_cap": "Значення тримається, поки ви утримуєте, — як у пристрою типу «Повернення до центру».", + "ui.3d_mouse_looks_like_trackball": "Значення повертається, щойно ви зупиняєтеся, — як у трекбола.", + "ui.3d_mouse_motion_rotation_x": "Обертання X", + "ui.3d_mouse_motion_rotation_y": "Обертання Y", + "ui.3d_mouse_motion_rotation_z": "Обертання Z", + "ui.3d_mouse_motion_translation_x": "Зміщення X", + "ui.3d_mouse_motion_translation_y": "Зміщення Y", + "ui.3d_mouse_motion_translation_z": "Зміщення Z", + "ui.3d_mouse_navigate": "Навігація за допомогою 3D-миші", + "ui.3d_mouse_none": "3D-мишу не знайдено", + "ui.3d_mouse_not_calibrated": "Не відкалібровано", + "ui.3d_mouse_offer": "Калібрування 3D-миші виправляє переплутані або обернені осі та налаштовує швидкість і відчуття.", + "ui.3d_mouse_offer_never": "Більше не показувати", + "ui.3d_mouse_offer_swap": "Перемкнути на Hidraw (прямий драйвер)", + "ui.3d_mouse_reading": "Показники", + "ui.3d_mouse_reset_axes": "Скинути призначення осей", + "ui.3d_mouse_reset_view": "Скинути вигляд", + "ui.3d_mouse_restore": "Відновити налаштування 3D-миші", + "ui.3d_mouse_restore_ask": "Повернути налаштування 3D-миші до типових? Калібрування і призначення кнопок залишаться.", + "ui.3d_mouse_section": "3D-миша", + "ui.3d_mouse_show_in_view": "Показувати 3D-мишу в 3D-вигляді", + "ui.3d_mouse_show_in_view_tip": "Зображення вашої 3D-миші в лівому нижньому куті, що рухається разом із нею.", + "ui.3d_mouse_skip": "Мій пристрій так не вміє", + "ui.3d_mouse_skip_tip": "Пропускає цей рух в обидва боки; калібрування його не враховує.", "ui.3d_mouse_speed": "Швидкість 3D-миші", + "ui.3d_mouse_step_lift": "Підніміть ковпачок угору, утримайте й відпустіть.", + "ui.3d_mouse_step_press": "Натисніть ковпачок униз, утримайте й відпустіть.", + "ui.3d_mouse_step_pull": "Потягніть ковпачок до себе, утримайте й відпустіть.", + "ui.3d_mouse_step_push": "Штовхніть ковпачок від себе, утримайте й відпустіть.", + "ui.3d_mouse_step_rest": "Приберіть руку з ковпачка й не торкайтеся його.", + "ui.3d_mouse_step_rest_trackball": "Приберіть руку з кулі й не торкайтеся її.", + "ui.3d_mouse_step_ring_clockwise": "Поверніть кільце прокручування на кілька клацань за годинниковою стрілкою.", + "ui.3d_mouse_step_ring_counterclockwise": "Поверніть кільце прокручування на кілька клацань проти годинникової стрілки.", + "ui.3d_mouse_step_roll_away": "Прокотіть кулю від себе кілька разів і дайте їй зупинитися.", + "ui.3d_mouse_step_roll_left": "Прокотіть кулю ліворуч кілька разів і дайте їй зупинитися.", + "ui.3d_mouse_step_roll_right": "Прокотіть кулю праворуч кілька разів і дайте їй зупинитися.", + "ui.3d_mouse_step_roll_toward": "Прокотіть кулю до себе кілька разів і дайте їй зупинитися.", + "ui.3d_mouse_step_slide_left": "Зсуньте ковпачок ліворуч, утримайте й відпустіть.", + "ui.3d_mouse_step_slide_right": "Зсуньте ковпачок праворуч, утримайте й відпустіть.", + "ui.3d_mouse_step_spin_clockwise": "Покрутіть кулю за годинниковою стрілкою кілька разів і дайте їй зупинитися.", + "ui.3d_mouse_step_spin_counterclockwise": "Покрутіть кулю проти годинникової стрілки кілька разів і дайте їй зупинитися.", + "ui.3d_mouse_step_tilt_away": "Нахиліть верх ковпачка від себе (ближній край піднімається), утримайте й відпустіть.", + "ui.3d_mouse_step_tilt_left": "Нахиліть ковпачок ліворуч (лівий край униз), утримайте й відпустіть.", + "ui.3d_mouse_step_tilt_right": "Нахиліть ковпачок праворуч (правий край униз), утримайте й відпустіть.", + "ui.3d_mouse_step_tilt_toward": "Нахиліть верх ковпачка до себе (ближній край опускається), утримайте й відпустіть.", + "ui.3d_mouse_step_touch": "Покладіть руку на ковпачок, не натискаючи на нього.", + "ui.3d_mouse_step_twist_clockwise": "Поверніть ковпачок за годинниковою стрілкою, утримайте й відпустіть.", + "ui.3d_mouse_step_twist_counterclockwise": "Поверніть ковпачок проти годинникової стрілки, утримайте й відпустіть.", + "ui.3d_mouse_switch_cap": "Калібрувати як повернення до центру", + "ui.3d_mouse_switch_trackball": "Калібрувати як трекбол", + "ui.3d_mouse_view_axis": "Вісь вигляду", + "ui.3d_mouse_view_off": "Вимк.", + "ui.3d_mouse_view_off_tip": "Профіль драйвера 3D-миші вимикає цю вісь, тож її рух не рухає вигляд. Якщо вибрати тут рух іншої осі, вони поміняються місцями, і цей рух перестане рухати вигляд.", + "ui.3d_mouse_view_orbit_sideways": "Орбіта ліворуч / праворуч", + "ui.3d_mouse_view_orbit_up_down": "Орбіта вгору / вниз", + "ui.3d_mouse_view_pan_sideways": "Зсув ліворуч / праворуч", + "ui.3d_mouse_view_pan_up_down": "Зсув угору / вниз", + "ui.3d_mouse_view_roll": "Крен", + "ui.3d_mouse_view_unmapped": "Вісь {n}", + "ui.3d_mouse_view_zoom": "Масштаб", + "ui.3d_mouse_waiting": "Очікування руху", + "ui.3d_mouse_wiggle": "Якщо 3D-миша підключена, трохи порухайте нею.", "ui.3d_sketch": "3D-ескіз", "ui.activate": "Активувати", "ui.active_component": "Активний компонент", @@ -1351,6 +1454,7 @@ "ui.blocked": "Заблоковані", "ui.bodies": "Тіла", "ui.bodies_all_if_none": "Тіла (усі, якщо не вибрано)", + "ui.bottom": "Знизу", "ui.box": "Куб", "ui.break": "Розірвати", "ui.browser_bodies_one": "{count} тіло", @@ -1794,59 +1898,6 @@ "ui.smooth_wheel_zoom": "Плавне масштабування колесом", "ui.smooth_wheel_zoom_tip": "Вимкніть, щоб застосовувати кожну подію колеса негайно, без плавного завершення", "ui.snap": "Прив'язка", - "ui.space_mouse_axes_reassigned": "Осі перепризначено в налаштуваннях, і це діє й поверх цього калібрування.", - "ui.space_mouse_calibrate": "Калібрувати…", - "ui.space_mouse_calibrate_tip": "Рухайте ковпачок так, як просить вікно, щоб SolidCraft дізнався, куди й наскільки далеко рухається кожна вісь цієї 3D-миші. Зберігається для цієї 3D-миші.", - "ui.space_mouse_calibrate_title": "Калібрування 3D-миші", - "ui.space_mouse_calibrated": "Відкалібровано", - "ui.space_mouse_calibration": "Калібрування 3D-миші", - "ui.space_mouse_calibration_done": "Готово. Збережіть його, щоб воно застосовувалося щоразу, коли підключено цю 3D-мишу.", - "ui.space_mouse_calibration_no_response": "Одна вісь майже не зрушила, тому її не можна відкалібрувати. Почніть спочатку й щоразу рухайте ковпачок далі.", - "ui.space_mouse_dead_zone": "Мертва зона", - "ui.space_mouse_dead_zone_tip": "Наскільки треба зсунути ковпачок, перш ніж зрушить вигляд, у частці від повного ходу. Збільште, якщо вигляд зміщується, коли ковпачок у спокої.", - "ui.space_mouse_driven_by": "Який відкалібрований рух пристрою керує цією віссю. Якщо вибрати рух, який уже використовує інша вісь, вони поміняються місцями.", - "ui.space_mouse_driven_by_label": "керується", - "ui.space_mouse_driver": "Драйвер 3D-миші", - "ui.space_mouse_driver_auto": "Автоматично", - "ui.space_mouse_driver_direct": "Напряму (hidraw)", - "ui.space_mouse_driver_only_direct": "Читання напряму з пристрою; spacenavd не запущено", - "ui.space_mouse_driver_only_spacenavd": "Читання через spacenavd, інших варіантів немає", - "ui.space_mouse_forget": "Забути калібрування", - "ui.space_mouse_hint": "Переміщуйте й обертайте вид разом; в ескізах доступні лише панорамування та масштаб.", - "ui.space_mouse_motion_rotation_x": "Обертання X", - "ui.space_mouse_motion_rotation_y": "Обертання Y", - "ui.space_mouse_motion_rotation_z": "Обертання Z", - "ui.space_mouse_motion_translation_x": "Зміщення X", - "ui.space_mouse_motion_translation_y": "Зміщення Y", - "ui.space_mouse_motion_translation_z": "Зміщення Z", - "ui.space_mouse_navigate": "Навігація за допомогою SpaceNavigator / SpaceMouse", - "ui.space_mouse_none": "3D-мишу не знайдено", - "ui.space_mouse_not_calibrated": "Не відкалібровано", - "ui.space_mouse_reading": "Показники", - "ui.space_mouse_reset_axes": "Скинути призначення осей", - "ui.space_mouse_step_lift": "Підніміть ковпачок угору, утримайте й відпустіть.", - "ui.space_mouse_step_press": "Натисніть ковпачок униз, утримайте й відпустіть.", - "ui.space_mouse_step_pull": "Потягніть ковпачок до себе, утримайте й відпустіть.", - "ui.space_mouse_step_push": "Штовхніть ковпачок від себе, утримайте й відпустіть.", - "ui.space_mouse_step_rest": "Приберіть руку з ковпачка й не торкайтеся його.", - "ui.space_mouse_step_slide_left": "Зсуньте ковпачок ліворуч, утримайте й відпустіть.", - "ui.space_mouse_step_slide_right": "Зсуньте ковпачок праворуч, утримайте й відпустіть.", - "ui.space_mouse_step_tilt_back": "Нахиліть ковпачок назад (передній край угору), утримайте й відпустіть.", - "ui.space_mouse_step_tilt_forward": "Нахиліть ковпачок уперед (передній край униз), утримайте й відпустіть.", - "ui.space_mouse_step_tilt_left": "Нахиліть ковпачок ліворуч (лівий край униз), утримайте й відпустіть.", - "ui.space_mouse_step_tilt_right": "Нахиліть ковпачок праворуч (правий край униз), утримайте й відпустіть.", - "ui.space_mouse_step_twist_clockwise": "Поверніть ковпачок за годинниковою стрілкою, утримайте й відпустіть.", - "ui.space_mouse_step_twist_counterclockwise": "Поверніть ковпачок проти годинникової стрілки, утримайте й відпустіть.", - "ui.space_mouse_view_axis": "Вісь вигляду", - "ui.space_mouse_view_off": "Вимк.", - "ui.space_mouse_view_off_tip": "Профіль драйвера 3D-миші вимикає цю вісь, тож її рух не рухає вигляд. Якщо вибрати тут рух іншої осі, вони поміняються місцями, і цей рух перестане рухати вигляд.", - "ui.space_mouse_view_orbit_sideways": "Орбіта ліворуч / праворуч", - "ui.space_mouse_view_orbit_up_down": "Орбіта вгору / вниз", - "ui.space_mouse_view_pan_sideways": "Зсув ліворуч / праворуч", - "ui.space_mouse_view_pan_up_down": "Зсув угору / вниз", - "ui.space_mouse_view_roll": "Крен", - "ui.space_mouse_view_unmapped": "Вісь {n}", - "ui.space_mouse_view_zoom": "Масштаб", "ui.spacing": "Крок", "ui.spacing_2": "Крок 2", "ui.sphere": "Куля", diff --git a/assets/locales/vi.json b/assets/locales/vi.json index 2b9b225181..db6fb2570a 100644 --- a/assets/locales/vi.json +++ b/assets/locales/vi.json @@ -530,6 +530,10 @@ "cmd.file_insert_step": "Chèn tệp STEP", "cmd.file_save_mesh": "Lưu dưới dạng lưới đa giác", "cmd.file_slicers": "Phần mềm cắt lớp đã cài đặt", + "cmd.frame_member": "Thanh khung", + "cmd.frame_profile_insert": "Chèn tiết diện", + "cmd.frame_profile_list": "Thư viện tiết diện", + "cmd.frame_profile_properties": "Thuộc tính tiết diện", "cmd.inspect_accessibility": "Phân tích khả năng gia công tiếp cận", "cmd.inspect_center_of_mass": "Trọng tâm", "cmd.inspect_clearance": "Khoảng cách an toàn", @@ -1121,7 +1125,106 @@ "trace.stopped_by_contact": "Kết thúc tại mẫu {sample}: {a} sẽ xuyên qua {b}", "trace.stopped_by_loop": "Kết thúc tại mẫu {sample}: vòng lặp qua {joint} không thể khép kín thêm được nữa", "ui.1_degree_of_freedom": "1 bậc tự do", + "ui.3d_mouse_axes_reassigned": "Các trục được gán lại trong Tùy chọn, và điều đó vẫn áp dụng cùng với hiệu chuẩn này.", + "ui.3d_mouse_button": "Nút {number}", + "ui.3d_mouse_button_nothing": "Không có", + "ui.3d_mouse_button_settings": "Cài đặt chuột 3D", + "ui.3d_mouse_buttons": "Nút", + "ui.3d_mouse_buttons_hint": "Nhấn một nút trên chuột 3D để tìm nó ở đây, rồi chọn chức năng.", + "ui.3d_mouse_buttons_no_device": "Kết nối chuột 3D để chọn chức năng cho các nút của nó.", + "ui.3d_mouse_buttons_reset": "Khôi phục nút mặc định", + "ui.3d_mouse_calibrate": "Hiệu chuẩn…", + "ui.3d_mouse_calibrate_tip": "Di chuyển núm điều khiển theo yêu cầu của cửa sổ để SolidCraft biết mỗi trục của chuột 3D này đi theo hướng nào và xa bao nhiêu. Được lưu cho chuột 3D này.", + "ui.3d_mouse_calibrate_title": "Hiệu chuẩn chuột 3D", + "ui.3d_mouse_calibrate_wait": "Hãy di chuyển chuột 3D một lần để SolidCraft nhận ra nó.", + "ui.3d_mouse_calibrated": "Đã hiệu chuẩn", + "ui.3d_mouse_calibration": "Hiệu chuẩn chuột 3D", + "ui.3d_mouse_calibration_done": "Xong. Hãy lưu để dùng mỗi khi chuột 3D này được kết nối.", + "ui.3d_mouse_calibration_no_response": "Một trục hầu như không di chuyển nên không thể hiệu chuẩn. Hãy bắt đầu lại và di chuyển núm điều khiển xa hơn mỗi lần.", + "ui.3d_mouse_circles_help": "Các vòng tròn cho thấy thiết bị gửi gì sau khi hiệu chuẩn. Mô hình thử cho thấy khung nhìn làm gì với nó, sau đảo chiều, tốc độ và gán trục.", + "ui.3d_mouse_dead_zone": "Vùng chết", + "ui.3d_mouse_dead_zone_tip": "Núm điều khiển phải di chuyển bao xa thì khung nhìn mới di chuyển, tính theo tỷ lệ hành trình tối đa. Hãy tăng giá trị này nếu khung nhìn bị trôi khi núm đang đứng yên.", + "ui.3d_mouse_denied": "SolidCraft thấy một chuột 3D nhưng không được phép đọc nó. Xem ghi chú thiết lập chuột 3D để biết quy tắc udev.", + "ui.3d_mouse_denied_short": "Đã thấy, nhưng không đọc được", + "ui.3d_mouse_driven_by": "Chuyển động đã hiệu chuẩn nào của thiết bị điều khiển trục này. Chọn chuyển động mà trục khác đang dùng sẽ hoán đổi hai trục.", + "ui.3d_mouse_driven_by_label": "điều khiển bởi", + "ui.3d_mouse_driver": "Trình điều khiển chuột 3D", + "ui.3d_mouse_driver_auto": "Tự động", + "ui.3d_mouse_driver_direct": "Trực tiếp (hidraw)", + "ui.3d_mouse_driver_fallback": "Tạm thời đọc qua spacenavd: không mở được chính thiết bị.", + "ui.3d_mouse_driver_only_direct": "Đang đọc thiết bị trực tiếp; spacenavd không chạy", + "ui.3d_mouse_driver_only_spacenavd": "Đang đọc qua spacenavd, cách duy nhất có sẵn", + "ui.3d_mouse_forget": "Xóa hiệu chuẩn", + "ui.3d_mouse_ghost_left_viewport": "Khung nhìn bên trái", + "ui.3d_mouse_ghost_left_window": "Cửa sổ bên trái", + "ui.3d_mouse_ghost_position": "Vị trí", + "ui.3d_mouse_ghost_right_viewport": "Khung nhìn bên phải", + "ui.3d_mouse_hint": "Di chuyển và xoay khung nhìn đồng thời; môi trường phác thảo chỉ sử dụng dời khung nhìn và thu phóng.", + "ui.3d_mouse_kind_cap": "Trở về giữa", + "ui.3d_mouse_kind_question": "Đây là loại chuột 3D nào?", + "ui.3d_mouse_kind_trackball": "Bi xoay", + "ui.3d_mouse_looks_like_cap": "Giá trị giữ nguyên khi bạn giữ, giống thiết bị kiểu “Trở về giữa”.", + "ui.3d_mouse_looks_like_trackball": "Giá trị trở về ngay khi bạn ngừng di chuyển, giống bi xoay.", + "ui.3d_mouse_motion_rotation_x": "Xoay X", + "ui.3d_mouse_motion_rotation_y": "Xoay Y", + "ui.3d_mouse_motion_rotation_z": "Xoay Z", + "ui.3d_mouse_motion_translation_x": "Tịnh tiến X", + "ui.3d_mouse_motion_translation_y": "Tịnh tiến Y", + "ui.3d_mouse_motion_translation_z": "Tịnh tiến Z", + "ui.3d_mouse_navigate": "Điều hướng bằng chuột 3D", + "ui.3d_mouse_none": "Không tìm thấy chuột 3D", + "ui.3d_mouse_not_calibrated": "Chưa hiệu chuẩn", + "ui.3d_mouse_offer": "Hiệu chuẩn chuột 3D sẽ sửa các trục bị hoán đổi hoặc đảo ngược và chỉnh tốc độ cũng như cảm giác.", + "ui.3d_mouse_offer_never": "Không hiện lại", + "ui.3d_mouse_offer_swap": "Chuyển sang Hidraw (điều khiển trực tiếp)", + "ui.3d_mouse_reading": "Giá trị đọc", + "ui.3d_mouse_reset_axes": "Đặt lại phép gán trục", + "ui.3d_mouse_reset_view": "Đặt lại khung nhìn", + "ui.3d_mouse_restore": "Khôi phục mặc định chuột 3D", + "ui.3d_mouse_restore_ask": "Đặt cài đặt chuột 3D về mặc định? Hiệu chuẩn và gán nút được giữ nguyên.", + "ui.3d_mouse_section": "Chuột 3D", + "ui.3d_mouse_show_in_view": "Hiện chuột 3D trong khung nhìn 3D", + "ui.3d_mouse_show_in_view_tip": "Hình chuột 3D của bạn ở góc dưới bên trái, chuyển động theo bạn.", + "ui.3d_mouse_skip": "Thiết bị của tôi không làm được điều này", + "ui.3d_mouse_skip_tip": "Bỏ qua chuyển động này theo cả hai chiều; việc hiệu chuẩn sẽ không dùng nó.", "ui.3d_mouse_speed": "Tốc độ chuột 3D", + "ui.3d_mouse_step_lift": "Nâng núm điều khiển lên, giữ nguyên, rồi thả ra.", + "ui.3d_mouse_step_press": "Nhấn núm điều khiển xuống, giữ nguyên, rồi thả ra.", + "ui.3d_mouse_step_pull": "Kéo núm điều khiển về phía bạn, giữ nguyên, rồi thả ra.", + "ui.3d_mouse_step_push": "Đẩy núm điều khiển ra xa bạn, giữ nguyên, rồi thả ra.", + "ui.3d_mouse_step_rest": "Bỏ tay khỏi núm điều khiển và để yên.", + "ui.3d_mouse_step_rest_trackball": "Bỏ tay khỏi viên bi và để yên.", + "ui.3d_mouse_step_ring_clockwise": "Xoay vòng cuộn theo chiều kim đồng hồ vài nấc.", + "ui.3d_mouse_step_ring_counterclockwise": "Xoay vòng cuộn ngược chiều kim đồng hồ vài nấc.", + "ui.3d_mouse_step_roll_away": "Lăn viên bi ra xa bạn vài lần, rồi để nó dừng lại.", + "ui.3d_mouse_step_roll_left": "Lăn viên bi sang trái vài lần, rồi để nó dừng lại.", + "ui.3d_mouse_step_roll_right": "Lăn viên bi sang phải vài lần, rồi để nó dừng lại.", + "ui.3d_mouse_step_roll_toward": "Lăn viên bi về phía bạn vài lần, rồi để nó dừng lại.", + "ui.3d_mouse_step_slide_left": "Trượt núm điều khiển sang trái, giữ nguyên, rồi thả ra.", + "ui.3d_mouse_step_slide_right": "Trượt núm điều khiển sang phải, giữ nguyên, rồi thả ra.", + "ui.3d_mouse_step_spin_clockwise": "Xoay viên bi theo chiều kim đồng hồ vài lần, rồi để nó dừng lại.", + "ui.3d_mouse_step_spin_counterclockwise": "Xoay viên bi ngược chiều kim đồng hồ vài lần, rồi để nó dừng lại.", + "ui.3d_mouse_step_tilt_away": "Nghiêng phần trên núm điều khiển ra xa bạn (cạnh gần bạn nhất nâng lên), giữ nguyên, rồi thả ra.", + "ui.3d_mouse_step_tilt_left": "Nghiêng núm điều khiển sang trái (cạnh trái hạ xuống), giữ nguyên, rồi thả ra.", + "ui.3d_mouse_step_tilt_right": "Nghiêng núm điều khiển sang phải (cạnh phải hạ xuống), giữ nguyên, rồi thả ra.", + "ui.3d_mouse_step_tilt_toward": "Nghiêng phần trên núm điều khiển về phía bạn (cạnh gần bạn nhất hạ xuống), giữ nguyên, rồi thả ra.", + "ui.3d_mouse_step_touch": "Đặt tay lên núm điều khiển nhưng không đẩy.", + "ui.3d_mouse_step_twist_clockwise": "Xoay núm điều khiển theo chiều kim đồng hồ, giữ nguyên, rồi thả ra.", + "ui.3d_mouse_step_twist_counterclockwise": "Xoay núm điều khiển ngược chiều kim đồng hồ, giữ nguyên, rồi thả ra.", + "ui.3d_mouse_switch_cap": "Hiệu chuẩn theo kiểu trở về giữa", + "ui.3d_mouse_switch_trackball": "Hiệu chuẩn như bi xoay", + "ui.3d_mouse_view_axis": "Trục khung nhìn", + "ui.3d_mouse_view_off": "Tắt", + "ui.3d_mouse_view_off_tip": "Hồ sơ của trình điều khiển chuột 3D tắt trục này, nên chuyển động của nó không làm khung nhìn di chuyển. Chọn chuyển động của trục khác ở đây sẽ hoán đổi hai trục, và chuyển động đó không còn di chuyển khung nhìn.", + "ui.3d_mouse_view_orbit_sideways": "Xoay quanh trái / phải", + "ui.3d_mouse_view_orbit_up_down": "Xoay quanh lên / xuống", + "ui.3d_mouse_view_pan_sideways": "Kéo trái / phải", + "ui.3d_mouse_view_pan_up_down": "Kéo lên / xuống", + "ui.3d_mouse_view_roll": "Lăn", + "ui.3d_mouse_view_unmapped": "Trục {n}", + "ui.3d_mouse_view_zoom": "Thu phóng", + "ui.3d_mouse_waiting": "Đang chờ chuyển động", + "ui.3d_mouse_wiggle": "Nếu bạn đã cắm một chiếc, hãy lắc nhẹ nó.", "ui.3d_sketch": "3D Phác thảo", "ui.activate": "Kích hoạt", "ui.active_component": "Cụm chi tiết đang kích hoạt", @@ -1351,6 +1454,7 @@ "ui.blocked": "Bị chặn", "ui.bodies": "Các thân khối", "ui.bodies_all_if_none": "Thân khối (tất cả nếu không chọn)", + "ui.bottom": "Dưới", "ui.box": "Khối hộp", "ui.break": "Ngắt", "ui.browser_bodies_one": "{count} thân khối", @@ -1794,34 +1898,6 @@ "ui.smooth_wheel_zoom": "Thu phóng mượt bằng con lăn", "ui.smooth_wheel_zoom_tip": "Tắt tùy chọn này để áp dụng từng sự kiện con lăn ngay lập tức mà không có hiệu ứng làm mượt chuyển động", "ui.snap": "Bắt điểm", - "ui.space_mouse_calibrate": "Hiệu chuẩn…", - "ui.space_mouse_calibrate_tip": "Di chuyển núm điều khiển theo yêu cầu của cửa sổ để SolidCraft biết mỗi trục của chuột 3D này đi theo hướng nào và xa bao nhiêu. Được lưu cho chuột 3D này.", - "ui.space_mouse_calibrate_title": "Hiệu chuẩn chuột 3D", - "ui.space_mouse_calibrated": "Đã hiệu chuẩn", - "ui.space_mouse_calibration": "Hiệu chuẩn chuột 3D", - "ui.space_mouse_calibration_done": "Xong. Hãy lưu để dùng mỗi khi chuột 3D này được kết nối.", - "ui.space_mouse_calibration_no_response": "Một trục hầu như không di chuyển nên không thể hiệu chuẩn. Hãy bắt đầu lại và di chuyển núm điều khiển xa hơn mỗi lần.", - "ui.space_mouse_dead_zone": "Vùng chết", - "ui.space_mouse_dead_zone_tip": "Núm điều khiển phải di chuyển bao xa thì góc nhìn mới di chuyển, tính theo tỷ lệ hành trình tối đa. Hãy tăng giá trị này nếu góc nhìn bị trôi khi núm đang đứng yên.", - "ui.space_mouse_forget": "Xóa hiệu chuẩn", - "ui.space_mouse_hint": "Di chuyển và xoay khung nhìn đồng thời; môi trường phác thảo chỉ sử dụng dời góc nhìn và thu phóng.", - "ui.space_mouse_navigate": "Điều hướng bằng SpaceNavigator / SpaceMouse", - "ui.space_mouse_none": "Không tìm thấy chuột 3D", - "ui.space_mouse_not_calibrated": "Chưa hiệu chuẩn", - "ui.space_mouse_reading": "Giá trị đọc", - "ui.space_mouse_step_lift": "Nâng núm điều khiển lên, giữ nguyên, rồi thả ra.", - "ui.space_mouse_step_press": "Nhấn núm điều khiển xuống, giữ nguyên, rồi thả ra.", - "ui.space_mouse_step_pull": "Kéo núm điều khiển về phía bạn, giữ nguyên, rồi thả ra.", - "ui.space_mouse_step_push": "Đẩy núm điều khiển ra xa bạn, giữ nguyên, rồi thả ra.", - "ui.space_mouse_step_rest": "Bỏ tay khỏi núm điều khiển và để yên.", - "ui.space_mouse_step_slide_left": "Trượt núm điều khiển sang trái, giữ nguyên, rồi thả ra.", - "ui.space_mouse_step_slide_right": "Trượt núm điều khiển sang phải, giữ nguyên, rồi thả ra.", - "ui.space_mouse_step_tilt_back": "Nghiêng núm điều khiển về sau (cạnh trước nâng lên), giữ nguyên, rồi thả ra.", - "ui.space_mouse_step_tilt_forward": "Nghiêng núm điều khiển về trước (cạnh trước hạ xuống), giữ nguyên, rồi thả ra.", - "ui.space_mouse_step_tilt_left": "Nghiêng núm điều khiển sang trái (cạnh trái hạ xuống), giữ nguyên, rồi thả ra.", - "ui.space_mouse_step_tilt_right": "Nghiêng núm điều khiển sang phải (cạnh phải hạ xuống), giữ nguyên, rồi thả ra.", - "ui.space_mouse_step_twist_clockwise": "Xoay núm điều khiển theo chiều kim đồng hồ, giữ nguyên, rồi thả ra.", - "ui.space_mouse_step_twist_counterclockwise": "Xoay núm điều khiển ngược chiều kim đồng hồ, giữ nguyên, rồi thả ra.", "ui.spacing": "Khoảng cách", "ui.spacing_2": "Khoảng cách 2", "ui.sphere": "Hình cầu", diff --git a/assets/locales/zh-TW.json b/assets/locales/zh-TW.json index abe0c3e19a..f704a81b52 100644 --- a/assets/locales/zh-TW.json +++ b/assets/locales/zh-TW.json @@ -529,6 +529,10 @@ "cmd.file_insert_step": "插入 STEP", "cmd.file_save_mesh": "另存為網格", "cmd.file_slicers": "已安裝的切片軟體", + "cmd.frame_member": "框架構件", + "cmd.frame_profile_insert": "插入型材", + "cmd.frame_profile_list": "型材庫", + "cmd.frame_profile_properties": "型材屬性", "cmd.inspect_accessibility": "可達性分析", "cmd.inspect_center_of_mass": "質心", "cmd.inspect_clearance": "間隙", @@ -1120,7 +1124,50 @@ "trace.stopped_by_contact": "在取樣 {sample} 處結束:{a} 會穿過 {b}", "trace.stopped_by_loop": "在取樣 {sample} 處結束:經過 {joint} 的環路無法再閉合", "ui.1_degree_of_freedom": "1 個自由度", + "ui.3d_mouse_calibrate": "校準…", + "ui.3d_mouse_calibrate_tip": "按視窗提示移動搖桿帽,SolidCraft 會記住這個 3D 滑鼠每個軸的方向和行程。校準結果按此 3D 滑鼠儲存。", + "ui.3d_mouse_calibrate_title": "校準 3D 滑鼠", + "ui.3d_mouse_calibrate_wait": "請移動一次 3D 滑鼠,讓 SolidCraft 能夠辨識它。", + "ui.3d_mouse_calibrated": "已校準", + "ui.3d_mouse_calibration": "3D 滑鼠校準", + "ui.3d_mouse_calibration_done": "完成。儲存後,每次連線此 3D 滑鼠都會使用它。", + "ui.3d_mouse_calibration_no_response": "有一個軸幾乎沒有移動,因此無法校準。請重新開始,並且每次把搖桿帽移得更遠。", + "ui.3d_mouse_circles_help": "圓圈顯示裝置在校準後送出的內容。測試模型顯示檢視在反向、速度與軸指定之後如何處理它。", + "ui.3d_mouse_dead_zone": "死區", + "ui.3d_mouse_dead_zone_tip": "檢視開始移動前搖桿帽需要移動的距離,以其全行程的比例表示。如果搖桿帽靜止時檢視仍在漂移,請調大。", + "ui.3d_mouse_denied": "SolidCraft 看得到 3D 滑鼠,但沒有權限讀取。udev 規則請見 3D 滑鼠的設定說明。", + "ui.3d_mouse_denied_short": "已找到,但無法讀取", + "ui.3d_mouse_driver_fallback": "目前改透過 spacenavd 讀取:無法開啟裝置本身。", + "ui.3d_mouse_forget": "清除校準", + "ui.3d_mouse_ghost_left_viewport": "視埠左側", + "ui.3d_mouse_ghost_left_window": "視窗左側", + "ui.3d_mouse_ghost_position": "位置", + "ui.3d_mouse_ghost_right_viewport": "視埠右側", + "ui.3d_mouse_hint": "同時移動和旋轉檢視;草圖中僅支援平移和縮放。", + "ui.3d_mouse_navigate": "使用 3D 滑鼠導覽", + "ui.3d_mouse_none": "未找到 3D 滑鼠", + "ui.3d_mouse_not_calibrated": "未校準", + "ui.3d_mouse_offer": "校準 3D 滑鼠可以修正互換或反向的軸,並調整它的速度和手感。", + "ui.3d_mouse_offer_never": "不再顯示", + "ui.3d_mouse_offer_swap": "切換到 Hidraw(直接驅動)", + "ui.3d_mouse_reading": "讀值", + "ui.3d_mouse_reset_view": "重設檢視", + "ui.3d_mouse_restore": "還原 3D 滑鼠預設值", + "ui.3d_mouse_restore_ask": "要將 3D 滑鼠設定還原為預設值嗎?校準和按鈕對應會保留。", "ui.3d_mouse_speed": "3D 滑鼠速度", + "ui.3d_mouse_step_lift": "向上提起搖桿帽,保持住,然後鬆開。", + "ui.3d_mouse_step_press": "向下按壓搖桿帽,保持住,然後鬆開。", + "ui.3d_mouse_step_pull": "把搖桿帽拉向自己,保持住,然後鬆開。", + "ui.3d_mouse_step_push": "把搖桿帽推離自己,保持住,然後鬆開。", + "ui.3d_mouse_step_rest": "請把手從搖桿帽上移開,讓它保持靜止。", + "ui.3d_mouse_step_slide_left": "向左推動搖桿帽,保持住,然後鬆開。", + "ui.3d_mouse_step_slide_right": "向右推動搖桿帽,保持住,然後鬆開。", + "ui.3d_mouse_step_tilt_left": "向左傾斜搖桿帽(左緣向下),保持住,然後鬆開。", + "ui.3d_mouse_step_tilt_right": "向右傾斜搖桿帽(右緣向下),保持住,然後鬆開。", + "ui.3d_mouse_step_twist_clockwise": "順時針扭轉搖桿帽,保持住,然後鬆開。", + "ui.3d_mouse_step_twist_counterclockwise": "逆時針扭轉搖桿帽,保持住,然後鬆開。", + "ui.3d_mouse_waiting": "等待移動", + "ui.3d_mouse_wiggle": "如果已插上,請輕輕晃動一下。", "ui.3d_sketch": "3D 草圖", "ui.activate": "啟用", "ui.active_component": "作用中元件", @@ -1793,34 +1840,6 @@ "ui.smooth_wheel_zoom": "平滑滾輪縮放", "ui.smooth_wheel_zoom_tip": "關閉後每個滾輪事件立即生效,不做平滑延遲", "ui.snap": "鎖點", - "ui.space_mouse_calibrate": "校準…", - "ui.space_mouse_calibrate_tip": "按視窗提示移動搖桿帽,SolidCraft 會記住這個 3D 滑鼠每個軸的方向和行程。校準結果按此 3D 滑鼠儲存。", - "ui.space_mouse_calibrate_title": "校準 3D 滑鼠", - "ui.space_mouse_calibrated": "已校準", - "ui.space_mouse_calibration": "3D 滑鼠校準", - "ui.space_mouse_calibration_done": "完成。儲存後,每次連線此 3D 滑鼠都會使用它。", - "ui.space_mouse_calibration_no_response": "有一個軸幾乎沒有移動,因此無法校準。請重新開始,並且每次把搖桿帽移得更遠。", - "ui.space_mouse_dead_zone": "死區", - "ui.space_mouse_dead_zone_tip": "檢視開始移動前搖桿帽需要移動的距離,以其全行程的比例表示。如果搖桿帽靜止時檢視仍在漂移,請調大。", - "ui.space_mouse_forget": "清除校準", - "ui.space_mouse_hint": "同時移動和旋轉檢視;草圖中僅支援平移和縮放。", - "ui.space_mouse_navigate": "使用 SpaceNavigator / SpaceMouse 導覽", - "ui.space_mouse_none": "未找到 3D 滑鼠", - "ui.space_mouse_not_calibrated": "未校準", - "ui.space_mouse_reading": "讀值", - "ui.space_mouse_step_lift": "向上提起搖桿帽,保持住,然後鬆開。", - "ui.space_mouse_step_press": "向下按壓搖桿帽,保持住,然後鬆開。", - "ui.space_mouse_step_pull": "把搖桿帽拉向自己,保持住,然後鬆開。", - "ui.space_mouse_step_push": "把搖桿帽推離自己,保持住,然後鬆開。", - "ui.space_mouse_step_rest": "請把手從搖桿帽上移開,讓它保持靜止。", - "ui.space_mouse_step_slide_left": "向左推動搖桿帽,保持住,然後鬆開。", - "ui.space_mouse_step_slide_right": "向右推動搖桿帽,保持住,然後鬆開。", - "ui.space_mouse_step_tilt_back": "向後傾斜搖桿帽(前緣向上),保持住,然後鬆開。", - "ui.space_mouse_step_tilt_forward": "向前傾斜搖桿帽(前緣向下),保持住,然後鬆開。", - "ui.space_mouse_step_tilt_left": "向左傾斜搖桿帽(左緣向下),保持住,然後鬆開。", - "ui.space_mouse_step_tilt_right": "向右傾斜搖桿帽(右緣向下),保持住,然後鬆開。", - "ui.space_mouse_step_twist_clockwise": "順時針扭轉搖桿帽,保持住,然後鬆開。", - "ui.space_mouse_step_twist_counterclockwise": "逆時針扭轉搖桿帽,保持住,然後鬆開。", "ui.spacing": "間距", "ui.spacing_2": "間距 2", "ui.sphere": "球體", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index cedcf9e51a..242a7aa6d4 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -5,7 +5,9 @@ "2. Attach your screenshot: copy and paste it, or save and upload the PNG.": "2. 附上截图:复制并粘贴,或保存为 PNG 后上传。", "2D": "2D", "3. Click Submit new issue on GitHub to send your report.": "3. 在 GitHub 上点击 Submit new issue 发送报告。", + "A Single hole snaps to the middle of its face and to the centres of its round edges": "单个孔捕捉到其所在面的中心和圆形边的中心", "A new configuration, a copy of the active one": "新建配置,为当前配置的副本", + "A placed Single hole has a handle to drag it, and picking its face again moves the hole there instead of taking it out": "已放置的单个孔带有可拖动的控点,再次选择该面会将孔移到该处而不是删除它", "ARRANGE": "排列", "About SolidCraft": "关于 SolidCraft", "Acceleration": "加速度", @@ -25,11 +27,13 @@ "Advanced": "高级", "Against": "对照", "Alignment": "对齐", + "All Lines": "所有直线", "All Sketch Points": "所有草图点", "All components": "所有零部件", "All open designs": "所有打开的设计", "Allowed expressions, separated by semicolons": "允许的表达式,以分号分隔", "Also deletes": "同时删除", + "Anchor": "基准点", "Angle 2": "角度 2", "Animate": "动画", "App/system information and command settings are included. Review private details before sharing.": "报告包含应用和系统信息以及命令设置。共享前请检查私人信息。", @@ -47,6 +51,7 @@ "BLEND SURFACE": "混合曲面", "BOUNDARY SURFACE": "边界曲面", "BROWSER": "浏览器", + "Bar": "扁钢", "Bend Edges": "折弯边", "Bend Line Position": "弯折线位置", "Bend Position": "折弯位置", @@ -59,6 +64,9 @@ "Body (last if none)": "实体(未指定时为最后一个)", "Body to split": "要分割的实体", "Body, or faces to take out": "实体,或要移除的面", + "Bottom Left": "左下", + "Bottom Middle": "下中", + "Bottom Right": "右下", "Boundary": "边界", "Bounding box": "边界框", "CHECK": "检查", @@ -78,12 +86,15 @@ "Center Y": "中心 Y", "Center of mass": "质心", "Centre to centre": "中心距", + "Centroid": "形心", "Chamfer Type": "倒角类型", + "Channel": "槽钢", "Check collisions": "检查碰撞", "Checked": "已检查", "Choose Variable radius or Spline in Type.": "请在“类型”中选择 Variable radius 或样条曲线。", "Choose a new plane. Sketch geometry and dimensions stay unchanged in its two-dimensional coordinates.": "选择一个新平面。草图几何和尺寸在其二维坐标中保持不变。", "Choose…": "选择…", + "Circle center": "圆心", "Circular": "圆周", "Circumference": "周长", "Close draft": "关闭草稿", @@ -113,10 +124,12 @@ "Copy screenshot": "复制截图", "Corner Relief": "拐角释放", "Corner Relief Shape": "拐角释放形状", + "Corner radius": "圆角半径", "Counterbore depth": "沉孔深度", "Counterbore Ø": "柱形沉孔 Ø", "Countersink angle": "沉头角度", "Countersink Ø": "锥形沉孔 Ø", + "Create Hole": "创建孔", "Create Issue — review draft": "创建 Issue——审阅草稿", "Create Issue…": "创建 Issue…", "Cross curves": "交叉曲线", @@ -152,6 +165,7 @@ "Don't ask again": "不再询问", "Double-click to rename": "双击重命名", "Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing": "在视图中双击中键:主视图(主视图,框住所显示的内容)、适合窗口(框住所显示的内容,保持方向)或无动作", + "Drag to move the hole; click to remove it": "拖动以移动孔,单击以删除", "Drive Joint": "驱动接头", "Duration": "时长", "Edges": "边", @@ -185,6 +199,7 @@ "Face": "面", "Face Appearance": "面外观", "Face attachment lost": "面附着丢失", + "Face center": "面中心", "Faces": "面", "Faces / edges / vertices": "面 / 边 / 顶点", "Faces to split": "要分割的面", @@ -200,6 +215,7 @@ "First plane": "第一个平面", "Fit and G2 corners require Constant radius.": "拟合和 G2 拐角需要 Constant radius。", "Fixed Face": "固定面", + "Flange thickness": "翼缘厚度", "Flat Pattern": "展开图", "Flat size": "平板尺寸", "Follow camera (Look At sketches)": "跟随相机(注视草图)", @@ -222,6 +238,7 @@ "Holes (edges on them)": "孔(孔上的边)", "Home view": "主视图", "Horiz./Vert.": "水平 / 垂直", + "I-Beam": "工字钢", "IMAGES": "图像", "Import…": "导入…", "In This Design": "在此设计中", @@ -242,10 +259,12 @@ "Keep of the other surface": "另一曲面保留的部分", "Key a joint's value at this step": "在此步骤为接头值设置关键帧", "Keys": "关键帧", + "L-Angle": "角钢", "LIBRARY": "库", "LINKS": "链接", "Later": "稍后", "Leave it": "保持原样", + "Left Middle": "左中", "Legacy face attachment. Rebinding to a certified face creates an exact attachment.": "旧版面附着。重新绑定到经验证的面可创建精确附着。", "Length": "长度", "Length units": "长度单位", @@ -255,6 +274,7 @@ "Limits": "限制", "Line Spacing": "行间距", "Line Type": "线型", + "Lines": "直线", "Listing the installed fonts…": "正在列出已安装字体…", "Load library": "加载库", "Local library": "本地库", @@ -288,6 +308,7 @@ "Move Type": "移动类型", "Move down": "下移", "Move earlier": "向前移动", + "Move holes by clicking or dragging": "通过单击或拖动移动孔", "Move later": "向后移动", "Move up": "上移", "Name": "名称", @@ -319,6 +340,7 @@ "Orbit (right drag)": "旋转(右键拖动)", "Other side": "另一侧", "Other surface (mutual trim)": "另一曲面(相互修剪)", + "Outer radius": "外圆角半径", "Outside": "外侧", "Overall length": "总长", "Param. Graph": "参数图", @@ -361,6 +383,7 @@ "Recover": "恢复", "Recover unsaved design?": "恢复未保存的设计?", "Rectangular": "矩形", + "Rectangular Tube": "矩形管", "Reference face": "参考面", "Remove Tools": "移除工具", "Remove condition": "删除条件", @@ -398,11 +421,15 @@ "Rib Offset": "筋板偏移", "Rib Thickness": "筋板厚度", "Ribs": "筋板", + "Right Middle": "右中", "Rim Edge": "轮缘边", "Ring of tools on the right button": "右键工具环", "Roll Back Here": "回滚到此处", "Roll History Marker Here": "将历史标记滚动到此处", + "Root radius": "根部圆角半径", "Round": "圆角", + "Round Bar": "圆钢", + "Round Tube": "圆管", "Rule": "规则", "Rule name": "规则名称", "Rules run from top to bottom. Matched drives change effective values.": "规则从上到下依次运行。满足条件的驱动规则更改有效值。", @@ -421,6 +448,7 @@ "Search this library": "搜索此库", "Second plane": "第二个平面", "Section": "剖面", + "Section (optional)": "截面(可选)", "Sections (one sketch each)": "截面(每个一个草图)", "Select Other": "选择其他", "Select Other: hold the left button still on the model (or right-click › Select Other…) to list everything under the cursor; click again on the same spot to select the next thing there. Double-click the middle button to go Home (Preferences › Navigation).": "“选择其他”:在模型上按住左键(或右键单击 › 选择其他…)列出光标下的所有对象;在同一位置再次单击可选择该处的下一个对象。双击中键转到主视图(首选项 › 导航)。", @@ -445,6 +473,7 @@ "Sketch plane has no body-face attachment.": "草图平面没有实体面附着。", "Sketch points": "草图点", "Slot width": "槽宽", + "Snap to face and edge centres": "捕捉到面和边的中心", "SolidCraft closed before these designs were saved. Their last autosave can be opened.": "SolidCraft 在这些设计保存前已关闭。可以打开它们的上次自动保存。", "Specialise": "专用操作", "Speed": "速度", @@ -492,11 +521,15 @@ "To": "终点", "To Offset": "偏移", "To edges": "终止边", + "Toe radius": "肢端圆角半径", "Tolerance": "公差", "Too close": "过近", "Tool Bodies": "工具实体", "Tools": "工具", "Tools, clockwise from the top": "工具,从顶部顺时针排列", + "Top Left": "左上", + "Top Middle": "上中", + "Top Right": "右上", "Trimming tool (plane, face or curves)": "修剪工具(平面、面或曲线)", "Turn off section analysis before exporting the view.": "请关闭截面分析后再导出视图。", "Type": "类型", @@ -521,6 +554,7 @@ "Wall": "壁厚", "Wall Location": "壁位置", "Wall Thickness": "壁厚度", + "Web thickness": "腹板厚度", "What did you do just before the problem? A short list is enough": "问题发生前您进行了哪些操作?简要列出即可", "What did you expect to see?": "您期望看到什么结果?", "What happened?": "发生了什么?", @@ -542,6 +576,8 @@ "Z angle": "Z 角度", "Z distance": "Z 距离", "Zebra": "斑马纹", + "ai.prefs.allow_control": "允许 AI 代理连接(控制通道 / MCP)", + "ai.prefs.allow_control_note": "仅在本机开放控制通道(127.0.0.1,端口 27878 或下一个空闲端口)。连接的程序还必须出示一个每次启动时新生成、只有你的用户账户能读取的秘密令牌。下次启动 SolidCraft 时生效。关闭:除非用 --control 启动,否则不监听任何端口。", "ai.prefs.availability": "助手控制", "ai.prefs.background_commands": "在后台执行代理命令", "ai.prefs.follow_camera": "将相机转向草图", @@ -689,6 +725,10 @@ "cmd.file_insert_step": "插入 STEP", "cmd.file_save_mesh": "另存为网格", "cmd.file_slicers": "已安装的切片软件", + "cmd.frame_member": "框架构件", + "cmd.frame_profile_insert": "插入型材", + "cmd.frame_profile_list": "型材库", + "cmd.frame_profile_properties": "型材属性", "cmd.inspect_accessibility": "可达性分析", "cmd.inspect_center_of_mass": "质心", "cmd.inspect_clearance": "间隙", @@ -747,6 +787,7 @@ "cmd.motion_trace_edit": "编辑运动轨迹", "cmd.motion_trace_keep": "保留运动轨迹", "cmd.occurrence_copy": "粘贴(实例)", + "cmd.occurrence_drag": "拖动实例", "cmd.occurrence_ground": "固定", "cmd.occurrence_move": "移动实例", "cmd.occurrence_properties": "实例物理属性", @@ -804,6 +845,7 @@ "cmd.sketch_auto_constrain_from_datum": "基于基准自动约束", "cmd.sketch_auto_project": "自动投影边到参考", "cmd.sketch_blend_curve": "混合曲线", + "cmd.sketch_cancel": "取消草图", "cmd.sketch_chain": "连续链", "cmd.sketch_chamfer_distance_angle": "距离和角度倒角", "cmd.sketch_chamfer_equal_distance": "等距倒角", @@ -1008,6 +1050,7 @@ "file_load.stopping": "正在等待上一次操作停止...", "file_load.waiting": "正在等待文件操作", "fits the face, e.g. M8": "适配该面,例如 M8", + "from the profile": "取自型材", "from the rule": "来自规则", "help.apache": "Apache 许可证 2.0", "help.attributions": "署名信息 (ATTRIBUTION.md)", @@ -1363,6 +1406,7 @@ "search.suggest": "您是否想找 {label}?", "search.try": "输入命令名称、此设计中的特征或参数。要带值运行命令,请输入其ID和JSON对象:{example}", "search.valid": "有效", + "section.off_hint": "未选择平面:按“确定”将移除剖面。", "settings.display_quality_note": "仅影响显示,不影响设计和导出。", "settings.frame_rate_note": "在视图右下角显示每秒帧数。", "settings.measure_axis_distances": "显示 X/Y/Z 距离", @@ -1370,6 +1414,8 @@ "settings.msaa_applies_on_restart": "抗锯齿将在下次启动 SolidCraft 时生效", "settings.msaa_reset": "{setting}: {requested}× 不受此图形设备支持;已重置为 {reset}× (支持: {supported})。", "settings.msaa_unsupported": "此图形设备不支持", + "settings.reference_geometry_through_bodies": "在实体后方显示参考几何体", + "settings.reference_geometry_through_bodies_help": "将非活动草图轮廓和基准平面绘制在着色实体上方", "settings.reset_intro": "这些设置在此计算机上无法正常工作,会导致 SolidCraft 无法启动,因此已更改:", "settings.reset_title": "设置已更改", "settings.supersampling_note": "以更高分辨率绘制视图再缩小:在低于 4K 的屏幕上边、线和网格更平滑。会消耗 GPU 时间:1.5× 时约 2.25 倍像素,2× 时 4 倍。", @@ -1429,7 +1475,106 @@ "trace.stopped_by_contact": "在采样 {sample} 处结束:{a} 会穿过 {b}", "trace.stopped_by_loop": "在采样 {sample} 处结束:经过 {joint} 的环路无法再闭合", "ui.1_degree_of_freedom": "1 个自由度", + "ui.3d_mouse_axes_reassigned": "轴已在偏好设置中重新分配,在此次校准之上仍然生效。", + "ui.3d_mouse_button": "按钮 {number}", + "ui.3d_mouse_button_nothing": "无", + "ui.3d_mouse_button_settings": "3D 鼠标设置", + "ui.3d_mouse_buttons": "按钮", + "ui.3d_mouse_buttons_hint": "按下 3D 鼠标上的按钮即可在此找到它,然后选择它的功能。", + "ui.3d_mouse_buttons_no_device": "连接 3D 鼠标后即可设置其按钮功能。", + "ui.3d_mouse_buttons_reset": "恢复默认按钮", + "ui.3d_mouse_calibrate": "校准…", + "ui.3d_mouse_calibrate_tip": "按窗口提示移动摇杆帽,SolidCraft 会记住这个 3D 鼠标每个轴的方向和行程。校准结果按此 3D 鼠标保存。", + "ui.3d_mouse_calibrate_title": "校准 3D 鼠标", + "ui.3d_mouse_calibrate_wait": "请移动一次 3D 鼠标,让 SolidCraft 能够识别它。", + "ui.3d_mouse_calibrated": "已校准", + "ui.3d_mouse_calibration": "3D 鼠标校准", + "ui.3d_mouse_calibration_done": "完成。保存后,每次连接此 3D 鼠标都会使用它。", + "ui.3d_mouse_calibration_no_response": "有一个轴几乎没有移动,因此无法校准。请重新开始,并且每次把摇杆帽移得更远。", + "ui.3d_mouse_circles_help": "圆圈显示设备在校准后发送的内容。测试模型显示视图在反向、速度和轴分配之后如何处理它。", + "ui.3d_mouse_dead_zone": "死区", + "ui.3d_mouse_dead_zone_tip": "视图开始移动前摇杆帽需要移动的距离,以其全行程的比例表示。如果摇杆帽静止时视图仍在漂移,请调大。", + "ui.3d_mouse_denied": "SolidCraft 能看到 3D 鼠标,但没有权限读取。udev 规则请见 3D 鼠标的设置说明。", + "ui.3d_mouse_denied_short": "已找到,但无法读取", + "ui.3d_mouse_driven_by": "由设备的哪个校准动作控制此轴。选择另一个轴已在使用的动作时,两者会互换。", + "ui.3d_mouse_driven_by_label": "控制来源", + "ui.3d_mouse_driver": "3D 鼠标驱动", + "ui.3d_mouse_driver_auto": "自动", + "ui.3d_mouse_driver_direct": "直接 (hidraw)", + "ui.3d_mouse_driver_fallback": "目前改通过 spacenavd 读取:无法打开设备本身。", + "ui.3d_mouse_driver_only_direct": "直接读取设备(spacenavd 未运行)", + "ui.3d_mouse_driver_only_spacenavd": "通过 spacenavd 读取(没有其他方式)", + "ui.3d_mouse_forget": "清除校准", + "ui.3d_mouse_ghost_left_viewport": "视口左侧", + "ui.3d_mouse_ghost_left_window": "窗口左侧", + "ui.3d_mouse_ghost_position": "位置", + "ui.3d_mouse_ghost_right_viewport": "视口右侧", + "ui.3d_mouse_hint": "同时移动和旋转视图;草图中仅支持平移和缩放。", + "ui.3d_mouse_kind_cap": "回中", + "ui.3d_mouse_kind_question": "这是哪种 3D 鼠标?", + "ui.3d_mouse_kind_trackball": "轨迹球", + "ui.3d_mouse_looks_like_cap": "按住时读数保持不变,就像“回中”型设备那样。", + "ui.3d_mouse_looks_like_trackball": "一停下来读数就回落,这是轨迹球的特征。", + "ui.3d_mouse_motion_rotation_x": "旋转 X", + "ui.3d_mouse_motion_rotation_y": "旋转 Y", + "ui.3d_mouse_motion_rotation_z": "旋转 Z", + "ui.3d_mouse_motion_translation_x": "平移 X", + "ui.3d_mouse_motion_translation_y": "平移 Y", + "ui.3d_mouse_motion_translation_z": "平移 Z", + "ui.3d_mouse_navigate": "使用 3D 鼠标导航", + "ui.3d_mouse_none": "未找到 3D 鼠标", + "ui.3d_mouse_not_calibrated": "未校准", + "ui.3d_mouse_offer": "校准 3D 鼠标可以修正互换或反向的轴,并调整它的速度和手感。", + "ui.3d_mouse_offer_never": "不再显示", + "ui.3d_mouse_offer_swap": "切换到 Hidraw(直接驱动)", + "ui.3d_mouse_reading": "读数", + "ui.3d_mouse_reset_axes": "重置轴分配", + "ui.3d_mouse_reset_view": "重置视图", + "ui.3d_mouse_restore": "恢复 3D 鼠标默认设置", + "ui.3d_mouse_restore_ask": "要将 3D 鼠标设置恢复为默认值吗?校准和按钮映射会保留。", + "ui.3d_mouse_section": "3D 鼠标", + "ui.3d_mouse_show_in_view": "在 3D 视图中显示 3D 鼠标", + "ui.3d_mouse_show_in_view_tip": "在左下角显示你的 3D 鼠标图示,随你的操作而动。", + "ui.3d_mouse_skip": "我的设备做不到", + "ui.3d_mouse_skip_tip": "在两个方向上都跳过这个动作,校准时不包含它。", "ui.3d_mouse_speed": "3D 鼠标速度", + "ui.3d_mouse_step_lift": "向上提起摇杆帽,保持住,然后松开。", + "ui.3d_mouse_step_press": "向下按压摇杆帽,保持住,然后松开。", + "ui.3d_mouse_step_pull": "把摇杆帽拉向自己,保持住,然后松开。", + "ui.3d_mouse_step_push": "把摇杆帽推离自己,保持住,然后松开。", + "ui.3d_mouse_step_rest": "请把手从摇杆帽上移开,让它保持静止。", + "ui.3d_mouse_step_rest_trackball": "请把手从球上移开,让它保持静止。", + "ui.3d_mouse_step_ring_clockwise": "把滚轮环顺时针转几格。", + "ui.3d_mouse_step_ring_counterclockwise": "把滚轮环逆时针转几格。", + "ui.3d_mouse_step_roll_away": "把球向远离你的方向滚动几次,然后让它停下。", + "ui.3d_mouse_step_roll_left": "把球向左滚动几次,然后让它停下。", + "ui.3d_mouse_step_roll_right": "把球向右滚动几次,然后让它停下。", + "ui.3d_mouse_step_roll_toward": "把球朝你滚动几次,然后让它停下。", + "ui.3d_mouse_step_slide_left": "向左推动摇杆帽,保持住,然后松开。", + "ui.3d_mouse_step_slide_right": "向右推动摇杆帽,保持住,然后松开。", + "ui.3d_mouse_step_spin_clockwise": "把球顺时针旋转几次,然后让它停下。", + "ui.3d_mouse_step_spin_counterclockwise": "把球逆时针旋转几次,然后让它停下。", + "ui.3d_mouse_step_tilt_away": "把摇杆帽顶部向远离你的方向倾斜(离你最近的边向上),保持住,然后松开。", + "ui.3d_mouse_step_tilt_left": "向左倾斜摇杆帽(左缘向下),保持住,然后松开。", + "ui.3d_mouse_step_tilt_right": "向右倾斜摇杆帽(右缘向下),保持住,然后松开。", + "ui.3d_mouse_step_tilt_toward": "把摇杆帽顶部朝你倾斜(离你最近的边向下),保持住,然后松开。", + "ui.3d_mouse_step_touch": "把手放在摇杆帽上,不要用力推。", + "ui.3d_mouse_step_twist_clockwise": "顺时针扭转摇杆帽,保持住,然后松开。", + "ui.3d_mouse_step_twist_counterclockwise": "逆时针扭转摇杆帽,保持住,然后松开。", + "ui.3d_mouse_switch_cap": "按回中类型校准", + "ui.3d_mouse_switch_trackball": "按轨迹球校准", + "ui.3d_mouse_view_axis": "视图轴", + "ui.3d_mouse_view_off": "关闭", + "ui.3d_mouse_view_off_tip": "3D 鼠标驱动程序的配置文件关闭了此轴,因此它的动作不会移动视图。在此选择另一个轴的动作会将两者互换,该动作将不再移动视图。", + "ui.3d_mouse_view_orbit_sideways": "左右环绕", + "ui.3d_mouse_view_orbit_up_down": "上下环绕", + "ui.3d_mouse_view_pan_sideways": "左右平移", + "ui.3d_mouse_view_pan_up_down": "上下平移", + "ui.3d_mouse_view_roll": "滚转", + "ui.3d_mouse_view_unmapped": "轴 {n}", + "ui.3d_mouse_view_zoom": "缩放", + "ui.3d_mouse_waiting": "等待移动", + "ui.3d_mouse_wiggle": "如果已插上,请轻轻晃动一下。", "ui.3d_sketch": "3D 草图", "ui.activate": "激活", "ui.active_component": "活动组件", @@ -1667,6 +1812,7 @@ "ui.blocked": "被遮挡", "ui.bodies": "实体", "ui.bodies_all_if_none": "实体(未选择则全部)", + "ui.bottom": "下视", "ui.box": "长方体", "ui.break": "打断", "ui.browser_bodies_one": "{count} 个实体", @@ -1792,6 +1938,8 @@ "ui.extract_design_changed": "选择文件时设计已更改。请重新开始提取到文件。", "ui.extract_done": "该组件现在是独立的设计,并已在此处链接。", "ui.extrude": "拉伸", + "ui.extrude_auto_to_object": "拉伸到所选目标", + "ui.extrude_auto_to_object_tip": "在拉伸中选择轮廓后,选取其平面外的面、顶点或平行平面会将延伸类型切换为“到对象”", "ui.factor": "系数", "ui.feature_history": "特征历史", "ui.feature_options": "特征选项", @@ -2047,6 +2195,9 @@ "ui.rectangle": "矩形", "ui.rectangular_pattern": "矩形阵列", "ui.redo": "重做", + "ui.reference_1": "参考 1", + "ui.reference_2": "参考 2", + "ui.reference_distance": "参考距离", "ui.relink_design_changed": "选择源文件时装配已更改。请重新开始链接。", "ui.relink_done": "已为所有实例重新链接源文件。", "ui.relink_not_linked": "此组件未链接到源设计。", @@ -2096,6 +2247,12 @@ "ui.sent_to_slicer": "已发送到 {slicer}", "ui.sheet_gap": "间隙", "ui.shell": "抽壳", + "ui.shortcut_capture_add_key": "请再按一个普通键。按 Esc 取消。", + "ui.shortcut_capture_hint": "点击快捷键,然后按下修饰键和一个普通键。在一秒内添加修饰键,松开所有键以确认。按 Esc 取消。", + "ui.shortcut_capture_press": "请按修饰键和一个普通键。按 Esc 取消。", + "ui.shortcut_capture_release": "松开所有键以确认。按 Esc 取消。", + "ui.shortcut_capture_settling": "正在确认快捷键… 按 Esc 取消。", + "ui.shortcut_multiple_keys": "请使用修饰键和一个普通键。点击快捷键重试。", "ui.show": "显示", "ui.showAll": "显示全部", "ui.showAllBodies": "显示所有实体", @@ -2125,59 +2282,6 @@ "ui.smooth_wheel_zoom": "平滑滚轮缩放", "ui.smooth_wheel_zoom_tip": "关闭后每个滚轮事件立即生效,不做平滑延迟", "ui.snap": "捕捉", - "ui.space_mouse_axes_reassigned": "轴已在偏好设置中重新分配,在此次校准之上仍然生效。", - "ui.space_mouse_calibrate": "校准…", - "ui.space_mouse_calibrate_tip": "按窗口提示移动摇杆帽,SolidCraft 会记住这个 3D 鼠标每个轴的方向和行程。校准结果按此 3D 鼠标保存。", - "ui.space_mouse_calibrate_title": "校准 3D 鼠标", - "ui.space_mouse_calibrated": "已校准", - "ui.space_mouse_calibration": "3D 鼠标校准", - "ui.space_mouse_calibration_done": "完成。保存后,每次连接此 3D 鼠标都会使用它。", - "ui.space_mouse_calibration_no_response": "有一个轴几乎没有移动,因此无法校准。请重新开始,并且每次把摇杆帽移得更远。", - "ui.space_mouse_dead_zone": "死区", - "ui.space_mouse_dead_zone_tip": "视图开始移动前摇杆帽需要移动的距离,以其全行程的比例表示。如果摇杆帽静止时视图仍在漂移,请调大。", - "ui.space_mouse_driven_by": "由设备的哪个校准动作控制此轴。选择另一个轴已在使用的动作时,两者会互换。", - "ui.space_mouse_driven_by_label": "控制来源", - "ui.space_mouse_driver": "3D 鼠标驱动", - "ui.space_mouse_driver_auto": "自动", - "ui.space_mouse_driver_direct": "直接 (hidraw)", - "ui.space_mouse_driver_only_direct": "直接读取设备(spacenavd 未运行)", - "ui.space_mouse_driver_only_spacenavd": "通过 spacenavd 读取(没有其他方式)", - "ui.space_mouse_forget": "清除校准", - "ui.space_mouse_hint": "同时移动和旋转视图;草图中仅支持平移和缩放。", - "ui.space_mouse_motion_rotation_x": "旋转 X", - "ui.space_mouse_motion_rotation_y": "旋转 Y", - "ui.space_mouse_motion_rotation_z": "旋转 Z", - "ui.space_mouse_motion_translation_x": "平移 X", - "ui.space_mouse_motion_translation_y": "平移 Y", - "ui.space_mouse_motion_translation_z": "平移 Z", - "ui.space_mouse_navigate": "使用 SpaceNavigator / SpaceMouse 导航", - "ui.space_mouse_none": "未找到 3D 鼠标", - "ui.space_mouse_not_calibrated": "未校准", - "ui.space_mouse_reading": "读数", - "ui.space_mouse_reset_axes": "重置轴分配", - "ui.space_mouse_step_lift": "向上提起摇杆帽,保持住,然后松开。", - "ui.space_mouse_step_press": "向下按压摇杆帽,保持住,然后松开。", - "ui.space_mouse_step_pull": "把摇杆帽拉向自己,保持住,然后松开。", - "ui.space_mouse_step_push": "把摇杆帽推离自己,保持住,然后松开。", - "ui.space_mouse_step_rest": "请把手从摇杆帽上移开,让它保持静止。", - "ui.space_mouse_step_slide_left": "向左推动摇杆帽,保持住,然后松开。", - "ui.space_mouse_step_slide_right": "向右推动摇杆帽,保持住,然后松开。", - "ui.space_mouse_step_tilt_back": "向后倾斜摇杆帽(前缘向上),保持住,然后松开。", - "ui.space_mouse_step_tilt_forward": "向前倾斜摇杆帽(前缘向下),保持住,然后松开。", - "ui.space_mouse_step_tilt_left": "向左倾斜摇杆帽(左缘向下),保持住,然后松开。", - "ui.space_mouse_step_tilt_right": "向右倾斜摇杆帽(右缘向下),保持住,然后松开。", - "ui.space_mouse_step_twist_clockwise": "顺时针扭转摇杆帽,保持住,然后松开。", - "ui.space_mouse_step_twist_counterclockwise": "逆时针扭转摇杆帽,保持住,然后松开。", - "ui.space_mouse_view_axis": "视图轴", - "ui.space_mouse_view_off": "关闭", - "ui.space_mouse_view_off_tip": "3D 鼠标驱动程序的配置文件关闭了此轴,因此它的动作不会移动视图。在此选择另一个轴的动作会将两者互换,该动作将不再移动视图。", - "ui.space_mouse_view_orbit_sideways": "左右环绕", - "ui.space_mouse_view_orbit_up_down": "上下环绕", - "ui.space_mouse_view_pan_sideways": "左右平移", - "ui.space_mouse_view_pan_up_down": "上下平移", - "ui.space_mouse_view_roll": "滚转", - "ui.space_mouse_view_unmapped": "轴 {n}", - "ui.space_mouse_view_zoom": "缩放", "ui.spacing": "间距", "ui.spacing_2": "间距 2", "ui.sphere": "球体", @@ -2219,7 +2323,7 @@ "ui.top": "上视", "ui.torus": "圆环体", "ui.total_angle": "总角度", - "ui.trackpad_pan_tip": "触控板手势用于平移(关闭:像滚轮一样缩放);鼠标滚轮缩放、拇指滚轮横向平移、双指捏合始终缩放", + "ui.trackpad_pan_tip": "双指滚动平移;Fusion 预设下,Shift + 双指滚动旋转视图。关闭时滚动缩放。鼠标滚轮和双指捏合缩放,拇指滚轮横向平移。", "ui.trim": "修剪", "ui.twist": "扭转", "ui.two_finger_scroll_pans": "双指滚动平移", diff --git a/assets/models/make_test_boat.py b/assets/models/make_test_boat.py new file mode 100644 index 0000000000..528fd4ca88 --- /dev/null +++ b/assets/models/make_test_boat.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Packs the boat for the 3D mouse test model in Preferences into assets/models/test_boat.bin, from a +binary STL of the low-poly boat (about 850 triangles). Needs numpy. + + python3 assets/models/make_test_boat.py SOURCE.stl assets/models/test_boat.bin + +No reduction happens here: the vertices are welded, and each welded vertex is split by the angle +between the faces that meet there (more than 40 degrees apart is a crease), so smooth surfaces share +a vertex normal and edges stay sharp. The model is centred and scaled into a unit box, then +quantised. File layout, little-endian: u32 vertex count, u32 triangle count, f32 centre (x, y, z), +f32 half size of the longest side; then per vertex its position as i16 x 3 (32767 is the half size) +and its normal as i8 x 3 (127 is a unit); then per triangle three u16 vertex indices. The model +sits with z up. +""" +import struct +import sys + +import numpy as np + +CREASE_DEGREES = 40.0 + + +def main() -> None: + source, target = sys.argv[1], sys.argv[2] + data = open(source, "rb").read() + count = struct.unpack_from(" 1e-9 + faces, cross, area = faces[ok], cross[ok], area[ok] + face_normal = cross / area[:, None] + # Split each welded vertex into one vertex per group of faces that agree (within the crease angle). + cos_limit = np.cos(np.radians(CREASE_DEGREES)) + corners = {} + for f, tri in enumerate(faces): + for k in range(3): + corners.setdefault(int(tri[k]), []).append((f, k)) + out_points, out_normals, index = [], [], np.zeros(faces.shape, dtype=np.int64) + for v, incident in corners.items(): + groups = [] # each: [summed normal, [(face, corner)]] + for f, k in incident: + n = face_normal[f] + for g in groups: + mean = g[0] / np.linalg.norm(g[0]) + if mean @ n > cos_limit: + g[0] = g[0] + n * area[f] + g[1].append((f, k)) + break + else: + groups.append([n * area[f], [(f, k)]]) + for total, members in groups: + length = np.linalg.norm(total) + out_normals.append(total / length if length > 0 else np.array([0.0, 0.0, 1.0])) + out_points.append(points[v]) + for f, k in members: + index[f, k] = len(out_points) - 1 + out_points, out_normals = np.array(out_points), np.array(out_normals) + low, high = out_points.min(axis=0), out_points.max(axis=0) + centre, half = (low + high) / 2.0, float((high - low).max() / 2.0) + positions = np.clip(np.round((out_points - centre) / half * 32767.0), -32767, 32767).astype(" Color32 { impl Tokens { pub const fn light() -> Tokens { Tokens { + borders: crate::theme::Borders::LEGACY, + icon_palette: None, file_dim: Color32::from_rgb(58, 70, 86), file_recovery_well: Color32::from_rgb(247, 226, 184), file_action_height: 32.0, diff --git a/assets/styles/ui/themes/amber-phosphor.json b/assets/styles/ui/themes/amber-phosphor.json index cd998a058a..22dba6d227 100644 --- a/assets/styles/ui/themes/amber-phosphor.json +++ b/assets/styles/ui/themes/amber-phosphor.json @@ -30,7 +30,7 @@ "box_crossing": "#d4902f", "box_window": "#ffad38", "chip": "#4a3210", - "construction_geom": "#ad7626", + "construction_geom": "#dd9a2f", "construction_plane": "#c6872c18", "continuity": [ "#c5862b", @@ -54,25 +54,25 @@ "dimension_driven": "#9b6922", "error": "#fff0ca", "field": "#100b04", - "form_accent": "#d6922f", + "form_accent": "#ca892b", "form_dim": "#9b6922", "form_error": "#fff0ca", "form_error_fill": "#46300f", - "form_fill": "#7e561c", + "form_fill": "#e19e45", "form_ink": "#f3ca87", "form_primary": "#ffad38", - "form_soft": "#291c09", + "form_soft": "#271b09", "form_success": "#d4902f", "form_success_fill": "#3b280d", - "form_tab_ink": "#f0a335", + "form_tab_ink": "#cf8d31", "gizmo": [ "#f3a535", "#ffad38", "#9b6922" ], "glyph_bg": "#201607", - "glyph_edge": "#bd812a", - "glyph_text": "#fff0ca", + "glyph_edge": "#e0d1ac", + "glyph_text": "#e0d1ac", "grid": "#21170b", "grid_major": "#362511", "heading_bg": "#080602", @@ -129,10 +129,10 @@ "#855a1d" ], "manipulator": "#ffad38", - "mesh_accent": "#d6922f", - "mesh_fill": "#7e561c", - "mesh_ink": "#f0a335", - "mesh_soft": "#291c09", + "mesh_accent": "#e6be68", + "mesh_fill": "#fed57f", + "mesh_ink": "#ffd680", + "mesh_soft": "#2d2313", "on_accent": "#170d03", "origin_plane": "#c6872c20", "origin_plane_edge": "#c6872c80", @@ -146,10 +146,10 @@ "preview_new": "#9f6c28", "preview_sketch": "#d4902f", "profile_fill": "#be812f20", - "render_accent": "#d6922f", - "render_fill": "#7e561c", - "render_ink": "#f0a335", - "render_soft": "#291c09", + "render_accent": "#bb7a29", + "render_fill": "#d28f42", + "render_ink": "#b97827", + "render_soft": "#241808", "rubber_band": "#ffad38", "section_cap": "#ba7e29", "sel_edge": "#ffbc48", @@ -160,26 +160,26 @@ "sel_vertex": "#ffbc48", "selected_bg": "#3c290d", "selected_ink": "#ffe8b3", - "sheet_fill": "#7e561c", - "sheet_ink": "#f0a335", - "sheet_soft": "#291c09", + "sheet_fill": "#f7c769", + "sheet_ink": "#f3c365", + "sheet_soft": "#2b2110", "sketch": "#ffbc48", - "sketch_accent": "#d6922f", + "sketch_accent": "#e0b873", "sketch_centerline": "#ba7e29", "sketch_construction": "#ad7626", "sketch_fixed": "#c68b2f", - "sketch_ink": "#f0a335", + "sketch_ink": "#f7ce88", "sketch_locked": "#d4902f", "sketch_lost": "#fff0ca", "sketch_point": "#ffbc48", "sketch_point_free": "#ffbc48", "sketch_projected": "#cb8a2d", - "sketch_soft": "#291c09", + "sketch_soft": "#2c2214", "subheading_bg": "#0c0803", - "surface_accent": "#d6922f", - "surface_fill": "#7e561c", - "surface_ink": "#f0a335", - "surface_soft": "#291c09", + "surface_accent": "#dd9a2f", + "surface_fill": "#f5b04a", + "surface_ink": "#eaa63e", + "surface_soft": "#2a1e0b", "tab_on_fill_dark": "#170d03", "text": "#f3ca87", "text_dim": "#9b6922", @@ -198,5 +198,56 @@ "warning": "#ffd274" }, "format": "solidcraft-theme/1", - "name": "Amber Phosphor" + "name": "Amber Phosphor", + "radii": { + "radius_sm": 0, + "radius_md": 0, + "radius_lg": 0, + "radius_xl": 0, + "control_radius": 0, + "sketch_mode_radius": 0 + }, + "icon_palette": { + "ink": "#090602", + "line": "#f3ca87", + "passive-1": "#d3bda4", + "passive-2": "#a69179", + "passive-3": "#7c6851", + "guide": "#9b6922", + "solid": [ + "#fde0b5", + "#fec25f", + "#eba930" + ], + "surface": [ + "#ffcc88", + "#f5b04a", + "#dd9a2f" + ], + "mesh": [ + "#feedca", + "#fed57f", + "#e6be68" + ], + "form": [ + "#f9b55d", + "#e19e45", + "#ca892b" + ], + "sketch": [ + "#fdeac9", + "#f8cf89", + "#e0b873" + ], + "constraint": [ + "#fceec8", + "#f7e8c2", + "#e0d1ac" + ], + "construction": [ + "#ffcc88", + "#f5b04a", + "#dd9a2f" + ] + } } diff --git a/assets/styles/ui/themes/blueprint.json b/assets/styles/ui/themes/blueprint.json index 58e6ed6912..a04a3fe723 100644 --- a/assets/styles/ui/themes/blueprint.json +++ b/assets/styles/ui/themes/blueprint.json @@ -30,7 +30,7 @@ "box_crossing": "#6ee0bb", "box_window": "#66ddff", "chip": "#1f5278", - "construction_geom": "#78b8dc", + "construction_geom": "#daaf75", "construction_plane": "#b7dcf01c", "continuity": [ "#6ee0bb", @@ -54,25 +54,25 @@ "dimension_driven": "#94b2cb", "error": "#ff968d", "field": "#071d35", - "form_accent": "#78c6e8", + "form_accent": "#baade8", "form_dim": "#94b2cb", "form_error": "#ff968d", "form_error_fill": "#422d40", - "form_fill": "#4386ac", + "form_fill": "#d0c5fb", "form_ink": "#c3e6f6", "form_primary": "#66ddff", - "form_soft": "#132f4c", + "form_soft": "#203250", "form_success": "#6ee0bb", "form_success_fill": "#153e47", - "form_tab_ink": "#c3e6f6", + "form_tab_ink": "#b6a9e4", "gizmo": [ "#ec9e99", "#9ddab7", "#91c8ff" ], "glyph_bg": "#0a233e", - "glyph_edge": "#e6b3a6", - "glyph_text": "#e6b3a6", + "glyph_edge": "#ff968d", + "glyph_text": "#ff968d", "grid": "#214975", "grid_major": "#315d89", "heading_bg": "#06111e", @@ -129,10 +129,10 @@ "#4386ac" ], "manipulator": "#66ddff", - "mesh_accent": "#78c6e8", - "mesh_fill": "#4386ac", - "mesh_ink": "#c3e6f6", - "mesh_soft": "#132f4c", + "mesh_accent": "#c7b96a", + "mesh_fill": "#decf80", + "mesh_ink": "#e7d889", + "mesh_soft": "#263845", "on_accent": "#07192f", "origin_plane": "#b7dcf024", "origin_plane_edge": "#a3cce480", @@ -146,10 +146,10 @@ "preview_new": "#d2e8f7", "preview_sketch": "#6ee0bb", "profile_fill": "#c9eaff12", - "render_accent": "#78c6e8", - "render_fill": "#4386ac", - "render_ink": "#c3e6f6", - "render_soft": "#132f4c", + "render_accent": "#e2a2b9", + "render_fill": "#fab8d0", + "render_ink": "#e6a6bd", + "render_soft": "#26324b", "rubber_band": "#66ddff", "section_cap": "#e7b374", "sel_edge": "#66ddff", @@ -160,26 +160,26 @@ "sel_vertex": "#66ddff", "selected_bg": "#153c60", "selected_ink": "#b6f2ff", - "sheet_fill": "#4386ac", - "sheet_ink": "#c3e6f6", - "sheet_soft": "#132f4c", + "sheet_fill": "#a5d9dc", + "sheet_ink": "#a4d8db", + "sheet_soft": "#1e384f", "sketch": "#eaf6ff", - "sketch_accent": "#78c6e8", + "sketch_accent": "#8bc7a5", "sketch_centerline": "#e7b374", "sketch_construction": "#78b8dc", "sketch_fixed": "#e3f0fa", - "sketch_ink": "#c3e6f6", + "sketch_ink": "#9ddab7", "sketch_locked": "#6ee0bb", "sketch_lost": "#ff968d", "sketch_point": "#e3f0fa", "sketch_point_free": "#66ddff", "sketch_projected": "#9cd6ef", - "sketch_soft": "#132f4c", + "sketch_soft": "#1d384b", "subheading_bg": "#091a2d", - "surface_accent": "#78c6e8", - "surface_fill": "#4386ac", - "surface_ink": "#c3e6f6", - "surface_soft": "#132f4c", + "surface_accent": "#daaf75", + "surface_fill": "#f1c58a", + "surface_ink": "#f1c58a", + "surface_soft": "#273645", "tab_on_fill_dark": "#07192f", "text": "#e3f0fa", "text_dim": "#94b2cb", @@ -198,5 +198,79 @@ "warning": "#f0d18c" }, "format": "solidcraft-theme/1", - "name": "Blueprint" + "name": "Blueprint", + "radii": { + "radius_sm": 4, + "radius_md": 6, + "radius_lg": 8, + "radius_xl": 10, + "control_radius": 6, + "sketch_mode_radius": 7 + }, + "borders": { + "primary": { + "width": 1, + "color": "#ffffff" + }, + "secondary": { + "width": 1, + "color": "#e5efff", + "pattern": "dotted" + }, + "tertiary": { + "width": 1, + "color": "#102748" + }, + "container": { + "width": 1, + "color": "#a8c8ed" + }, + "control": { + "width": 1, + "color": "#a8c8ed" + } + }, + "icon_palette": { + "ink": "#07192f", + "line": "#e3f0fa", + "passive-1": "#b8c2cc", + "passive-2": "#8d97a0", + "passive-3": "#646d76", + "guide": "#94b2cb", + "solid": [ + "#bceefe", + "#77defd", + "#4fc9ea" + ], + "surface": [ + "#fde0b9", + "#f1c58a", + "#daaf75" + ], + "mesh": [ + "#f5e697", + "#decf80", + "#c7b96a" + ], + "form": [ + "#e6e0fe", + "#d0c5fb", + "#baade8" + ], + "sketch": [ + "#b8f5d2", + "#a1debb", + "#8bc7a5" + ], + "constraint": [ + "#ffdbd7", + "#fcbbb5", + "#ff968d" + ], + "construction": [ + "#fde0b9", + "#f1c58a", + "#daaf75" + ] + } } diff --git a/assets/styles/ui/themes/catppuccin-mocha.json b/assets/styles/ui/themes/catppuccin-mocha.json index 523c6171a7..7e2a2f61ac 100644 --- a/assets/styles/ui/themes/catppuccin-mocha.json +++ b/assets/styles/ui/themes/catppuccin-mocha.json @@ -30,7 +30,7 @@ "box_crossing": "#a6e3a1", "box_window": "#cba6f7", "chip": "#cba6f7", - "construction_geom": "#f9e2af", + "construction_geom": "#eba57a", "construction_plane": "#f9e2af22", "continuity": [ "#a6e3a1", @@ -54,25 +54,25 @@ "dimension_driven": "#a6adc8", "error": "#f38ba8", "field": "#181825", - "form_accent": "#cba6f7", + "form_accent": "#a9b2f2", "form_dim": "#a6adc8", "form_error": "#f38ba8", "form_error_fill": "#493446", - "form_fill": "#477dc8", + "form_fill": "#c1cafe", "form_ink": "#cdd6f4", "form_primary": "#cba6f7", - "form_soft": "#302b46", + "form_soft": "#303147", "form_success": "#a6e3a1", "form_success_fill": "#353f42", - "form_tab_ink": "#cdd6f4", + "form_tab_ink": "#b4befe", "gizmo": [ "#f38ba8", "#a6e3a1", "#477dc8" ], "glyph_bg": "#313244", - "glyph_edge": "#f38ba8", - "glyph_text": "#f38ba8", + "glyph_edge": "#fc93b0", + "glyph_text": "#fc93b0", "grid": "#242531", "grid_major": "#363846", "heading_bg": "#0f0f17", @@ -129,10 +129,10 @@ "#586382" ], "manipulator": "#cba6f7", - "mesh_accent": "#cba6f7", - "mesh_fill": "#477dc8", - "mesh_ink": "#cdd6f4", - "mesh_soft": "#302b46", + "mesh_accent": "#cbb583", + "mesh_fill": "#e2cb99", + "mesh_ink": "#f9e2af", + "mesh_soft": "#38363d", "on_accent": "#06121b", "origin_plane": "#f9e2af28", "origin_plane_edge": "#f9e2af88", @@ -146,10 +146,10 @@ "preview_new": "#f9e2af", "preview_sketch": "#a6e3a1", "profile_fill": "#477dc822", - "render_accent": "#cba6f7", - "render_fill": "#477dc8", - "render_ink": "#cdd6f4", - "render_soft": "#302b46", + "render_accent": "#fc93b0", + "render_fill": "#feb7c8", + "render_ink": "#f38ba8", + "render_soft": "#382b3d", "rubber_band": "#cba6f7", "section_cap": "#f9e2af", "sel_edge": "#477dc8", @@ -160,26 +160,26 @@ "sel_vertex": "#477dc8", "selected_bg": "#365280", "selected_ink": "#cdd6f4", - "sheet_fill": "#477dc8", - "sheet_ink": "#cdd6f4", - "sheet_soft": "#302b46", + "sheet_fill": "#8addec", + "sheet_ink": "#89dceb", + "sheet_soft": "#2b3545", "sketch": "#477dc8", - "sketch_accent": "#cba6f7", + "sketch_accent": "#8eca89", "sketch_centerline": "#f9e2af", "sketch_construction": "#f9e2af", "sketch_fixed": "#cdd6f4", - "sketch_ink": "#cdd6f4", + "sketch_ink": "#a6e3a1", "sketch_locked": "#a6e3a1", "sketch_lost": "#f38ba8", "sketch_point": "#cdd6f4", "sketch_point_free": "#477dc8", "sketch_projected": "#cba6f7", - "sketch_soft": "#302b46", + "sketch_soft": "#2e363c", "subheading_bg": "#161622", - "surface_accent": "#cba6f7", - "surface_fill": "#477dc8", - "surface_ink": "#cdd6f4", - "surface_soft": "#302b46", + "surface_accent": "#eba57a", + "surface_fill": "#febe96", + "surface_ink": "#fab387", + "surface_soft": "#383039", "tab_on_fill_dark": "#181825", "text": "#cdd6f4", "text_dim": "#a6adc8", @@ -198,5 +198,48 @@ "warning": "#f9e2af" }, "format": "solidcraft-theme/1", - "name": "Catppuccin Mocha" + "name": "Catppuccin Mocha", + "icon_palette": { + "ink": "#181825", + "line": "#cdd6f4", + "passive-1": "#bec0ca", + "passive-2": "#92959e", + "passive-3": "#696b74", + "guide": "#a6adc8", + "solid": [ + "#ebdefe", + "#dbc0fc", + "#c9a4f5" + ], + "surface": [ + "#fdddca", + "#febe96", + "#eba57a" + ], + "mesh": [ + "#f9e2af", + "#e2cb99", + "#cbb583" + ], + "form": [ + "#dee3fd", + "#c1cafe", + "#a9b2f2" + ], + "sketch": [ + "#baf8b5", + "#a4e09f", + "#8eca89" + ], + "constraint": [ + "#fddae2", + "#feb7c8", + "#fc93b0" + ], + "construction": [ + "#fdddca", + "#febe96", + "#eba57a" + ] + } } diff --git a/assets/styles/ui/themes/cupertino.json b/assets/styles/ui/themes/cupertino.json index dacbd49d22..8a1a5d983e 100644 --- a/assets/styles/ui/themes/cupertino.json +++ b/assets/styles/ui/themes/cupertino.json @@ -30,7 +30,7 @@ "box_crossing": "#39a75b", "box_window": "#007aff", "chip": "#007aff", - "construction_geom": "#9a712b", + "construction_geom": "#a06f28", "construction_plane": "#efd3a146", "continuity": [ "#009e73", @@ -54,25 +54,25 @@ "dimension_driven": "#6e6e73", "error": "#d53137", "field": "#ffffff", - "form_accent": "#8055be", + "form_accent": "#8b62bf", "form_dim": "#6e6e73", "form_error": "#c02b31", "form_error_fill": "#fcebea", - "form_fill": "#946bc9", + "form_fill": "#c39afc", "form_ink": "#262628", "form_primary": "#0071e3", - "form_soft": "#eee8f7", + "form_soft": "#e5e2e9", "form_success": "#237b38", "form_success_fill": "#e6f3e8", - "form_tab_ink": "#7445af", + "form_tab_ink": "#75559f", "gizmo": [ "#ed514b", "#36ad59", "#2184e9" ], "glyph_bg": "#ffffff", - "glyph_edge": "#ce8d85", - "glyph_text": "#c34136", + "glyph_edge": "#d73339", + "glyph_text": "#d73339", "grid": "#d0d2d6", "grid_major": "#b5b8be", "heading_bg": "#e2e2e2", @@ -129,10 +129,10 @@ "#2b71be" ], "manipulator": "#007aff", - "mesh_accent": "#927300", - "mesh_fill": "#d0ac35", - "mesh_ink": "#856700", - "mesh_soft": "#fbf5df", + "mesh_accent": "#937717", + "mesh_fill": "#d1ad37", + "mesh_ink": "#79641f", + "mesh_soft": "#eae7dd", "on_accent": "#ffffff", "origin_plane": "#f0c58a55", "origin_plane_edge": "#bf915ac8", @@ -146,10 +146,10 @@ "preview_new": "#e9d875", "preview_sketch": "#3ab86b", "profile_fill": "#f3c88a3d", - "render_accent": "#af4083", - "render_fill": "#bd5b92", - "render_ink": "#9f3676", - "render_soft": "#f6e8f0", + "render_accent": "#b4538a", + "render_fill": "#f18ac2", + "render_ink": "#974975", + "render_soft": "#e8e0e5", "rubber_band": "#007aff", "section_cap": "#c79769", "sel_edge": "#007aff", @@ -160,26 +160,26 @@ "sel_vertex": "#316cae", "selected_bg": "#dcecff", "selected_ink": "#004b9b", - "sheet_fill": "#3884a5", - "sheet_ink": "#226b89", - "sheet_soft": "#e6f1f6", + "sheet_fill": "#71bcdf", + "sheet_ink": "#2d6a84", + "sheet_soft": "#dee4e6", "sketch": "#1264bf", - "sketch_accent": "#288049", + "sketch_accent": "#388e56", "sketch_centerline": "#b77c47", "sketch_construction": "#cb8b37", "sketch_fixed": "#262628", - "sketch_ink": "#23723f", + "sketch_ink": "#236f40", "sketch_locked": "#318348", "sketch_lost": "#c94f68", "sketch_point": "#262628", "sketch_point_free": "#1264bf", "sketch_projected": "#9258b7", - "sketch_soft": "#eaf4ed", + "sketch_soft": "#dce3df", "subheading_bg": "#e8e8e8", - "surface_accent": "#996a28", - "surface_fill": "#b5833e", - "surface_ink": "#8b5c1d", - "surface_soft": "#f8efdf", + "surface_accent": "#a06f28", + "surface_fill": "#daa662", + "surface_ink": "#825e2d", + "surface_soft": "#e8e4de", "tab_on_fill_dark": "#242426", "text": "#262628", "text_dim": "#6e6e73", @@ -198,5 +198,56 @@ "warning": "#ad7400" }, "format": "solidcraft-theme/1", - "name": "Cupertino" + "name": "Cupertino", + "radii": { + "radius_sm": 6, + "radius_md": 8, + "radius_lg": 12, + "radius_xl": 16, + "control_radius": 8, + "sketch_mode_radius": 9 + }, + "icon_palette": { + "ink": "#262628", + "line": "#505054", + "passive-1": "#dee5ee", + "passive-2": "#aeb5bd", + "passive-3": "#81878f", + "guide": "#6e6e73", + "solid": [ + "#dbe9fe", + "#81b3fa", + "#1875eb" + ], + "surface": [ + "#fde3c4", + "#daa662", + "#a06f28" + ], + "mesh": [ + "#fee7a2", + "#d1ad37", + "#937717" + ], + "form": [ + "#ede2ff", + "#c39afc", + "#8b62bf" + ], + "sketch": [ + "#b1fdc5", + "#72c78b", + "#388e56" + ], + "constraint": [ + "#fee0dd", + "#f98e87", + "#d73339" + ], + "construction": [ + "#fde3c4", + "#daa662", + "#a06f28" + ] + } } diff --git a/assets/styles/ui/themes/graphite.json b/assets/styles/ui/themes/graphite.json index 5a6a8fe057..ca1bc3dbbd 100644 --- a/assets/styles/ui/themes/graphite.json +++ b/assets/styles/ui/themes/graphite.json @@ -30,7 +30,7 @@ "box_crossing": "#83c69b", "box_window": "#4aa3d8", "chip": "#4aa3d8", - "construction_geom": "#dbb879", + "construction_geom": "#c38c59", "construction_plane": "#dbb87922", "continuity": [ "#83c69b", @@ -54,25 +54,25 @@ "dimension_driven": "#a0a6b0", "error": "#ed7777", "field": "#1c1e21", - "form_accent": "#4aa3d8", + "form_accent": "#a08fc4", "form_dim": "#a0a6b0", "form_error": "#ed7777", "form_error_fill": "#4d373a", - "form_fill": "#2878a9", + "form_fill": "#b9a7df", "form_ink": "#e0e2e5", "form_primary": "#4aa3d8", - "form_soft": "#293c49", + "form_soft": "#34343e", "form_success": "#83c69b", "form_success_fill": "#35423e", - "form_tab_ink": "#e0e2e5", + "form_tab_ink": "#a796cb", "gizmo": [ "#ed7777", "#83c69b", "#2878a9" ], "glyph_bg": "#303338", - "glyph_edge": "#ed7777", - "glyph_text": "#ed7777", + "glyph_edge": "#e57071", + "glyph_text": "#e57071", "grid": "#292b30", "grid_major": "#393c42", "heading_bg": "#121416", @@ -129,10 +129,10 @@ "#68717d" ], "manipulator": "#4aa3d8", - "mesh_accent": "#4aa3d8", - "mesh_fill": "#2878a9", - "mesh_ink": "#e0e2e5", - "mesh_soft": "#293c49", + "mesh_accent": "#b19747", + "mesh_fill": "#cab060", + "mesh_ink": "#cab060", + "mesh_soft": "#393731", "on_accent": "#06121b", "origin_plane": "#dbb87928", "origin_plane_edge": "#dbb87988", @@ -146,10 +146,10 @@ "preview_new": "#dbb879", "preview_sketch": "#83c69b", "profile_fill": "#2878a922", - "render_accent": "#4aa3d8", - "render_fill": "#2878a9", - "render_ink": "#e0e2e5", - "render_soft": "#293c49", + "render_accent": "#c0869b", + "render_fill": "#db9eb3", + "render_ink": "#c78fa3", + "render_soft": "#383339", "rubber_band": "#4aa3d8", "section_cap": "#dbb879", "sel_edge": "#2878a9", @@ -160,26 +160,26 @@ "sel_vertex": "#2878a9", "selected_bg": "#245674", "selected_ink": "#e0e2e5", - "sheet_fill": "#2878a9", - "sheet_ink": "#e0e2e5", - "sheet_soft": "#293c49", + "sheet_fill": "#76c1c7", + "sheet_ink": "#6cb7bd", + "sheet_soft": "#2e383d", "sketch": "#2878a9", - "sketch_accent": "#4aa3d8", + "sketch_accent": "#7ba58b", "sketch_centerline": "#dbb879", "sketch_construction": "#dbb879", "sketch_fixed": "#e0e2e5", - "sketch_ink": "#e0e2e5", + "sketch_ink": "#88b298", "sketch_locked": "#83c69b", "sketch_lost": "#ed7777", "sketch_point": "#e0e2e5", "sketch_point_free": "#2878a9", "sketch_projected": "#ad9cce", - "sketch_soft": "#293c49", + "sketch_soft": "#313838", "subheading_bg": "#1c1d20", - "surface_accent": "#4aa3d8", - "surface_fill": "#2878a9", - "surface_ink": "#e0e2e5", - "surface_soft": "#293c49", + "surface_accent": "#c38c59", + "surface_fill": "#dea572", + "surface_ink": "#d39b68", + "surface_soft": "#3a3532", "tab_on_fill_dark": "#1c1e21", "text": "#e0e2e5", "text_dim": "#a0a6b0", @@ -198,5 +198,48 @@ "warning": "#dbb879" }, "format": "solidcraft-theme/1", - "name": "Graphite" + "name": "Graphite", + "icon_palette": { + "ink": "#1c1e21", + "line": "#e0e2e5", + "passive-1": "#bfc1c5", + "passive-2": "#939599", + "passive-3": "#6a6c6f", + "guide": "#a0a6b0", + "solid": [ + "#a0d7fb", + "#65bdf3", + "#4aa3d8" + ], + "surface": [ + "#fcc28e", + "#dea572", + "#c38c59" + ], + "mesh": [ + "#e8cd7d", + "#cab060", + "#b19747" + ], + "form": [ + "#d5c4fd", + "#b9a7df", + "#a08fc4" + ], + "sketch": [ + "#b0dbc0", + "#94bea4", + "#7ba58b" + ], + "constraint": [ + "#febbb9", + "#fb8e8d", + "#e57071" + ], + "construction": [ + "#fcc28e", + "#dea572", + "#c38c59" + ] + } } diff --git a/assets/styles/ui/themes/green-phosphor.json b/assets/styles/ui/themes/green-phosphor.json index cd031de290..221de93f6d 100644 --- a/assets/styles/ui/themes/green-phosphor.json +++ b/assets/styles/ui/themes/green-phosphor.json @@ -30,7 +30,7 @@ "box_crossing": "#39d45c", "box_window": "#4dff79", "chip": "#144a20", - "construction_geom": "#2fad4a", + "construction_geom": "#17c053", "construction_plane": "#72c68b18", "continuity": [ "#35c555", @@ -54,25 +54,25 @@ "dimension_driven": "#5b9b6a", "error": "#d8ffdf", "field": "#061009", - "form_accent": "#59d67b", + "form_accent": "#1faa4e", "form_dim": "#5b9b6a", "form_error": "#d8ffdf", "form_error_fill": "#21462a", - "form_fill": "#287e43", + "form_fill": "#40c163", "form_ink": "#9bf0b1", "form_primary": "#4dff79", - "form_soft": "#0e2918", + "form_soft": "#0c2413", "form_success": "#39d45c", "form_success_fill": "#133b20", - "form_tab_ink": "#9bf0b1", + "form_tab_ink": "#36b95c", "gizmo": [ "#c1f3ba", "#4dff79", "#599b70" ], "glyph_bg": "#09200e", - "glyph_edge": "#33bd51", - "glyph_text": "#d8ffdf", + "glyph_edge": "#afd5b6", + "glyph_text": "#afd5b6", "grid": "#091b0f", "grid_major": "#122d1a", "heading_bg": "#030804", @@ -129,10 +129,10 @@ "#34854b" ], "manipulator": "#4dff79", - "mesh_accent": "#59d67b", - "mesh_fill": "#287e43", - "mesh_ink": "#9bf0b1", - "mesh_soft": "#0e2918", + "mesh_accent": "#75d989", + "mesh_fill": "#8cf09f", + "mesh_ink": "#9affad", + "mesh_soft": "#182d1d", "on_accent": "#031207", "origin_plane": "#72c68b20", "origin_plane_edge": "#72c68b80", @@ -146,10 +146,10 @@ "preview_new": "#319f4b", "preview_sketch": "#39d45c", "profile_fill": "#2fbe5320", - "render_accent": "#59d67b", - "render_fill": "#287e43", - "render_ink": "#9bf0b1", - "render_soft": "#0e2918", + "render_accent": "#259548", + "render_fill": "#40ab5c", + "render_ink": "#2e9c4e", + "render_soft": "#0b2111", "rubber_band": "#4dff79", "section_cap": "#32ba50", "sel_edge": "#4dff79", @@ -160,26 +160,26 @@ "sel_vertex": "#4dff79", "selected_bg": "#123c20", "selected_ink": "#b3ffc4", - "sheet_fill": "#287e43", - "sheet_ink": "#9bf0b1", - "sheet_soft": "#0e2918", + "sheet_fill": "#6ee08e", + "sheet_ink": "#74e694", + "sheet_soft": "#132a1a", "sketch": "#4dff79", - "sketch_accent": "#59d67b", + "sketch_accent": "#94d7a4", "sketch_centerline": "#32ba50", "sketch_construction": "#2fad4a", "sketch_fixed": "#8def9f", - "sketch_ink": "#9bf0b1", + "sketch_ink": "#bbffca", "sketch_locked": "#39d45c", "sketch_lost": "#d8ffdf", "sketch_point": "#8def9f", "sketch_point_free": "#4dff79", "sketch_projected": "#37cb57", - "sketch_soft": "#0e2918", + "sketch_soft": "#1c2d20", "subheading_bg": "#040c07", - "surface_accent": "#59d67b", - "surface_fill": "#287e43", - "surface_ink": "#9bf0b1", - "surface_soft": "#0e2918", + "surface_accent": "#17c053", + "surface_fill": "#3fd86a", + "surface_ink": "#40d86a", + "surface_soft": "#0d2815", "tab_on_fill_dark": "#031207", "text": "#8def9f", "text_dim": "#5b9b6a", @@ -198,5 +198,56 @@ "warning": "#d1ffad" }, "format": "solidcraft-theme/1", - "name": "Green Phosphor" + "name": "Green Phosphor", + "radii": { + "radius_sm": 0, + "radius_md": 0, + "radius_lg": 0, + "radius_xl": 0, + "control_radius": 0, + "sketch_mode_radius": 0 + }, + "icon_palette": { + "ink": "#020904", + "line": "#8def9f", + "passive-1": "#abcab2", + "passive-2": "#809e87", + "passive-3": "#58745e", + "guide": "#5b9b6a", + "solid": [ + "#a5fdb1", + "#3df46e", + "#3bd964" + ], + "surface": [ + "#5cef80", + "#3fd86a", + "#17c053" + ], + "mesh": [ + "#c5fecd", + "#8cf09f", + "#75d989" + ], + "form": [ + "#5ad879", + "#40c163", + "#1faa4e" + ], + "sketch": [ + "#c8ffd3", + "#abeeba", + "#94d7a4" + ], + "constraint": [ + "#d3fada", + "#c5eccc", + "#afd5b6" + ], + "construction": [ + "#5cef80", + "#3fd86a", + "#17c053" + ] + } } diff --git a/assets/styles/ui/themes/gruvbox-dark-hard.json b/assets/styles/ui/themes/gruvbox-dark-hard.json index 646057927f..1fac3d49f6 100644 --- a/assets/styles/ui/themes/gruvbox-dark-hard.json +++ b/assets/styles/ui/themes/gruvbox-dark-hard.json @@ -12,7 +12,7 @@ "text_dim": "#a89984", "accent": "#83a598", "accent_soft": "#414f4b", - "sketch_accent": "#83a598", + "sketch_accent": "#a1a322", "error": "#fb4934", "warning": "#fabd2f", "icon": "#d5c4a1", @@ -34,7 +34,7 @@ "axis_y": "#b8bb26", "axis_z": "#83a598", "construction_plane": "#fe801940", - "construction_geom": "#fe8019", + "construction_geom": "#e97926", "body": "#a89984", "body_edge": "#141617", "manipulator": "#83a598", @@ -64,8 +64,8 @@ "section_cap": "#d65d0e", "profile_fill": "#83a59828", "glyph_bg": "#2c2c2c", - "glyph_edge": "#d65d0e", - "glyph_text": "#fe8019", + "glyph_edge": "#fe614c", + "glyph_text": "#fe614c", "overlay": "#1d2021f0", "rubber_band": "#83a598", "app_bar_text": "#ebdbb2", @@ -93,6 +93,86 @@ "#b8bb26", "#83a598" ], - "icons.agent": "#d3869b" + "icons.agent": "#d3869b", + "surface_fill": "#fe9755", + "surface_soft": "#382c20", + "surface_ink": "#fe8019", + "surface_accent": "#e97926", + "mesh_fill": "#e1ab32", + "mesh_soft": "#383323", + "mesh_ink": "#fabd2f", + "mesh_accent": "#c39429", + "form_fill": "#ea9bb0", + "form_soft": "#332c30", + "form_tab_ink": "#d3869b", + "form_accent": "#cf8297", + "sheet_fill": "#93c681", + "sheet_soft": "#2b332c", + "sheet_ink": "#8ec07c", + "render_fill": "#fc9483", + "render_soft": "#382523", + "render_ink": "#fb5d4a", + "render_accent": "#fe614c", + "sketch_soft": "#303322", + "sketch_ink": "#b8bb26", + "form_ink": "#ebdbb2", + "form_dim": "#bdae93", + "form_primary": "#83a598", + "form_error": "#fb4934", + "form_error_fill": "#63221f", + "form_success": "#b8bb26", + "form_success_fill": "#343a28", + "dialog_body": "#1d2021", + "dialog_footer": "#3c3836", + "dialog_rule": "#504945", + "control_fill": "#141617", + "control_border": "#7c6f64", + "dialog_label": "#ebdbb2", + "selected_ink": "#ebdbb2", + "selected_bg": "#414f4b", + "on_accent": "#1d2021" + }, + "icon_palette": { + "ink": "#141617", + "line": "#d5c4a1", + "passive-1": "#c5c0b8", + "passive-2": "#9a948d", + "passive-3": "#706b64", + "guide": "#a89984", + "solid": [ + "#b8dbcd", + "#9bbeb1", + "#83a598" + ], + "surface": [ + "#fcc4a3", + "#fe9755", + "#e97926" + ], + "mesh": [ + "#fbca67", + "#e1ab32", + "#c39429" + ], + "form": [ + "#fbbfce", + "#ea9bb0", + "#cf8297" + ], + "sketch": [ + "#d7db50", + "#babd2a", + "#a1a322" + ], + "constraint": [ + "#fcc2b7", + "#fc9483", + "#fe614c" + ], + "construction": [ + "#fcc4a3", + "#fe9755", + "#e97926" + ] } } diff --git a/assets/styles/ui/themes/gruvbox-dark-soft.json b/assets/styles/ui/themes/gruvbox-dark-soft.json index 34a895cc9c..6a5daef40b 100644 --- a/assets/styles/ui/themes/gruvbox-dark-soft.json +++ b/assets/styles/ui/themes/gruvbox-dark-soft.json @@ -12,7 +12,7 @@ "text_dim": "#a89984", "accent": "#83a598", "accent_soft": "#4e5954", - "sketch_accent": "#83a598", + "sketch_accent": "#a1a322", "error": "#fb4934", "warning": "#fabd2f", "icon": "#d5c4a1", @@ -34,7 +34,7 @@ "axis_y": "#b8bb26", "axis_z": "#83a598", "construction_plane": "#fe801940", - "construction_geom": "#fe8019", + "construction_geom": "#e97926", "body": "#a89984", "body_edge": "#1d2021", "manipulator": "#83a598", @@ -64,8 +64,8 @@ "section_cap": "#d65d0e", "profile_fill": "#83a59828", "glyph_bg": "#373432", - "glyph_edge": "#d65d0e", - "glyph_text": "#fe8019", + "glyph_edge": "#fe614c", + "glyph_text": "#fe614c", "overlay": "#32302ff0", "rubber_band": "#83a598", "app_bar_text": "#ebdbb2", @@ -93,6 +93,86 @@ "#b8bb26", "#83a598" ], - "icons.agent": "#d3869b" + "icons.agent": "#d3869b", + "surface_fill": "#fe9755", + "surface_soft": "#4a3a2c", + "surface_ink": "#fe8b2e", + "surface_accent": "#e97926", + "mesh_fill": "#e1ab32", + "mesh_soft": "#4a412f", + "mesh_ink": "#fabd2f", + "mesh_accent": "#c39429", + "form_fill": "#ea9bb0", + "form_soft": "#453a3c", + "form_tab_ink": "#d996a8", + "form_accent": "#cf8297", + "sheet_fill": "#93c681", + "sheet_soft": "#3d4138", + "sheet_ink": "#8ec07c", + "render_fill": "#fc9483", + "render_soft": "#4a3330", + "render_ink": "#fc7e6f", + "render_accent": "#fe614c", + "sketch_soft": "#42412e", + "sketch_ink": "#b8bb26", + "form_ink": "#ebdbb2", + "form_dim": "#bdae93", + "form_primary": "#83a598", + "form_error": "#fb4934", + "form_error_fill": "#702b28", + "form_success": "#b8bb26", + "form_success_fill": "#343a28", + "dialog_body": "#32302f", + "dialog_footer": "#3c3836", + "dialog_rule": "#504945", + "control_fill": "#1d2021", + "control_border": "#7c6f64", + "dialog_label": "#ebdbb2", + "selected_ink": "#ebdbb2", + "selected_bg": "#4e5954", + "on_accent": "#1d2021" + }, + "icon_palette": { + "ink": "#1d2021", + "line": "#d5c4a1", + "passive-1": "#c5c0b8", + "passive-2": "#9a948d", + "passive-3": "#706b64", + "guide": "#a89984", + "solid": [ + "#b8dbcd", + "#9bbeb1", + "#83a598" + ], + "surface": [ + "#fcc4a3", + "#fe9755", + "#e97926" + ], + "mesh": [ + "#fbca67", + "#e1ab32", + "#c39429" + ], + "form": [ + "#fbbfce", + "#ea9bb0", + "#cf8297" + ], + "sketch": [ + "#d7db50", + "#babd2a", + "#a1a322" + ], + "constraint": [ + "#fcc2b7", + "#fc9483", + "#fe614c" + ], + "construction": [ + "#fcc4a3", + "#fe9755", + "#e97926" + ] } } diff --git a/assets/styles/ui/themes/gruvbox-dark.json b/assets/styles/ui/themes/gruvbox-dark.json index 320f9ed96a..2224cf99d7 100644 --- a/assets/styles/ui/themes/gruvbox-dark.json +++ b/assets/styles/ui/themes/gruvbox-dark.json @@ -12,7 +12,7 @@ "text_dim": "#a89984", "accent": "#83a598", "accent_soft": "#48544f", - "sketch_accent": "#83a598", + "sketch_accent": "#a1a322", "error": "#fb4934", "warning": "#fabd2f", "icon": "#d5c4a1", @@ -34,7 +34,7 @@ "axis_y": "#b8bb26", "axis_z": "#83a598", "construction_plane": "#fe801940", - "construction_geom": "#fe8019", + "construction_geom": "#e97926", "body": "#a89984", "body_edge": "#1d2021", "manipulator": "#83a598", @@ -64,8 +64,8 @@ "section_cap": "#d65d0e", "profile_fill": "#83a59828", "glyph_bg": "#32302f", - "glyph_edge": "#d65d0e", - "glyph_text": "#fe8019", + "glyph_edge": "#fe614c", + "glyph_text": "#fe614c", "overlay": "#282828f0", "rubber_band": "#83a598", "app_bar_text": "#ebdbb2", @@ -93,6 +93,86 @@ "#b8bb26", "#83a598" ], - "icons.agent": "#d3869b" + "icons.agent": "#d3869b", + "surface_fill": "#fe9755", + "surface_soft": "#423326", + "surface_ink": "#fe8019", + "surface_accent": "#e97926", + "mesh_fill": "#e1ab32", + "mesh_soft": "#413a29", + "mesh_ink": "#fabd2f", + "mesh_accent": "#c39429", + "form_fill": "#ea9bb0", + "form_soft": "#3d3336", + "form_tab_ink": "#d58b9f", + "form_accent": "#cf8297", + "sheet_fill": "#93c681", + "sheet_soft": "#343a32", + "sheet_ink": "#8ec07c", + "render_fill": "#fc9483", + "render_soft": "#412c29", + "render_ink": "#fc6d5d", + "render_accent": "#fe614c", + "sketch_soft": "#393a28", + "sketch_ink": "#b8bb26", + "form_ink": "#ebdbb2", + "form_dim": "#bdae93", + "form_primary": "#83a598", + "form_error": "#fb4934", + "form_error_fill": "#6a2624", + "form_success": "#b8bb26", + "form_success_fill": "#343a28", + "dialog_body": "#282828", + "dialog_footer": "#3c3836", + "dialog_rule": "#504945", + "control_fill": "#1d2021", + "control_border": "#7c6f64", + "dialog_label": "#ebdbb2", + "selected_ink": "#ebdbb2", + "selected_bg": "#48544f", + "on_accent": "#1d2021" + }, + "icon_palette": { + "ink": "#1d2021", + "line": "#d5c4a1", + "passive-1": "#c5c0b8", + "passive-2": "#9a948d", + "passive-3": "#706b64", + "guide": "#a89984", + "solid": [ + "#b8dbcd", + "#9bbeb1", + "#83a598" + ], + "surface": [ + "#fcc4a3", + "#fe9755", + "#e97926" + ], + "mesh": [ + "#fbca67", + "#e1ab32", + "#c39429" + ], + "form": [ + "#fbbfce", + "#ea9bb0", + "#cf8297" + ], + "sketch": [ + "#d7db50", + "#babd2a", + "#a1a322" + ], + "constraint": [ + "#fcc2b7", + "#fc9483", + "#fe614c" + ], + "construction": [ + "#fcc4a3", + "#fe9755", + "#e97926" + ] } } diff --git a/assets/styles/ui/themes/gruvbox-light-hard.json b/assets/styles/ui/themes/gruvbox-light-hard.json index 902121fb66..690bced1a1 100644 --- a/assets/styles/ui/themes/gruvbox-light-hard.json +++ b/assets/styles/ui/themes/gruvbox-light-hard.json @@ -12,7 +12,7 @@ "text_dim": "#7c6f64", "accent": "#076678", "accent_soft": "#c9d8c4", - "sketch_accent": "#076678", + "sketch_accent": "#625e15", "error": "#9d0006", "warning": "#b57614", "icon": "#504945", @@ -34,7 +34,7 @@ "axis_y": "#98971a", "axis_z": "#458588", "construction_plane": "#fe801946", - "construction_geom": "#af3a03", + "construction_geom": "#9b3407", "body": "#bdae93", "body_edge": "#3c3836", "manipulator": "#076678", @@ -64,8 +64,8 @@ "section_cap": "#d65d0e", "profile_fill": "#fabd2f46", "glyph_bg": "#fcfaeb", - "glyph_edge": "#af3a03", - "glyph_text": "#af3a03", + "glyph_edge": "#a91714", + "glyph_text": "#a91714", "overlay": "#f9f5d7eb", "rubber_band": "#076678", "app_bar_text": "#fbf1c7", @@ -93,6 +93,86 @@ "#98971a", "#458588" ], - "icons.agent": "#8f3f71" + "icons.agent": "#8f3f71", + "surface_fill": "#df663c", + "surface_soft": "#f3e6c6", + "surface_ink": "#af3a03", + "surface_accent": "#9b3407", + "mesh_fill": "#c08024", + "mesh_soft": "#f4ebc7", + "mesh_ink": "#915e10", + "mesh_accent": "#7b5112", + "form_fill": "#c46fa2", + "form_soft": "#f1e6cf", + "form_tab_ink": "#8f3f71", + "form_accent": "#893a6c", + "sheet_fill": "#659f7a", + "sheet_soft": "#eaebcd", + "sheet_ink": "#3d7252", + "render_fill": "#e85a4d", + "render_soft": "#f2e1c6", + "render_ink": "#9d0006", + "render_accent": "#a91714", + "sketch_soft": "#efebc7", + "sketch_ink": "#6e6a0d", + "form_ink": "#3c3836", + "form_dim": "#665c54", + "form_primary": "#076678", + "form_error": "#9d0006", + "form_error_fill": "#f0cbb2", + "form_success": "#427b58", + "form_success_fill": "#dae3b6", + "dialog_body": "#f9f5d7", + "dialog_footer": "#ebdbb2", + "dialog_rule": "#d5c4a1", + "control_fill": "#fcfaeb", + "control_border": "#a89984", + "dialog_label": "#3c3836", + "selected_ink": "#3c3836", + "selected_bg": "#c9d8c4", + "on_accent": "#fbf1c7" + }, + "icon_palette": { + "ink": "#3c3836", + "line": "#504945", + "passive-1": "#e9e3df", + "passive-2": "#b8b3af", + "passive-3": "#8a8581", + "guide": "#7c6f64", + "solid": [ + "#87d5e9", + "#4e9caf", + "#076678" + ], + "surface": [ + "#feb299", + "#df663c", + "#9b3407" + ], + "mesh": [ + "#fcb864", + "#c08024", + "#7b5112" + ], + "form": [ + "#fbaad9", + "#c46fa2", + "#893a6c" + ], + "sketch": [ + "#d0cc73", + "#989439", + "#625e15" + ], + "constraint": [ + "#fab3a8", + "#e85a4d", + "#a91714" + ], + "construction": [ + "#feb299", + "#df663c", + "#9b3407" + ] } } diff --git a/assets/styles/ui/themes/gruvbox-light-soft.json b/assets/styles/ui/themes/gruvbox-light-soft.json index d8c1c97832..7128bdd9c0 100644 --- a/assets/styles/ui/themes/gruvbox-light-soft.json +++ b/assets/styles/ui/themes/gruvbox-light-soft.json @@ -12,7 +12,7 @@ "text_dim": "#7c6f64", "accent": "#076678", "accent_soft": "#c3ccae", - "sketch_accent": "#076678", + "sketch_accent": "#625e15", "error": "#9d0006", "warning": "#b57614", "icon": "#504945", @@ -34,7 +34,7 @@ "axis_y": "#98971a", "axis_z": "#458588", "construction_plane": "#fe801946", - "construction_geom": "#af3a03", + "construction_geom": "#9b3407", "body": "#bdae93", "body_edge": "#3c3836", "manipulator": "#076678", @@ -64,8 +64,8 @@ "section_cap": "#d65d0e", "profile_fill": "#fabd2f46", "glyph_bg": "#f8f2de", - "glyph_edge": "#af3a03", - "glyph_text": "#af3a03", + "glyph_edge": "#a91714", + "glyph_text": "#a91714", "overlay": "#f2e5bceb", "rubber_band": "#076678", "app_bar_text": "#fbf1c7", @@ -93,6 +93,86 @@ "#98971a", "#458588" ], - "icons.agent": "#8f3f71" + "icons.agent": "#8f3f71", + "surface_fill": "#df663c", + "surface_soft": "#edd7ad", + "surface_ink": "#a83803", + "surface_accent": "#9b3407", + "mesh_fill": "#c08024", + "mesh_soft": "#eddcaf", + "mesh_ink": "#84560f", + "mesh_accent": "#7b5112", + "form_fill": "#c46fa2", + "form_soft": "#ead8b6", + "form_tab_ink": "#8f3f71", + "form_accent": "#893a6c", + "sheet_fill": "#659f7a", + "sheet_soft": "#e4ddb4", + "sheet_ink": "#38694b", + "render_fill": "#e85a4d", + "render_soft": "#ebd3ad", + "render_ink": "#9d0006", + "render_accent": "#a91714", + "sketch_soft": "#e8dcae", + "sketch_ink": "#66610c", + "form_ink": "#3c3836", + "form_dim": "#665c54", + "form_primary": "#076678", + "form_error": "#9d0006", + "form_error_fill": "#eabe9c", + "form_success": "#427b58", + "form_success_fill": "#dae3b6", + "dialog_body": "#f2e5bc", + "dialog_footer": "#ebdbb2", + "dialog_rule": "#d5c4a1", + "control_fill": "#f8f2de", + "control_border": "#a89984", + "dialog_label": "#3c3836", + "selected_ink": "#3c3836", + "selected_bg": "#c3ccae", + "on_accent": "#fbf1c7" + }, + "icon_palette": { + "ink": "#3c3836", + "line": "#504945", + "passive-1": "#e9e3df", + "passive-2": "#b8b3af", + "passive-3": "#8a8581", + "guide": "#7c6f64", + "solid": [ + "#87d5e9", + "#4e9caf", + "#076678" + ], + "surface": [ + "#feb299", + "#df663c", + "#9b3407" + ], + "mesh": [ + "#fcb864", + "#c08024", + "#7b5112" + ], + "form": [ + "#fbaad9", + "#c46fa2", + "#893a6c" + ], + "sketch": [ + "#d0cc73", + "#989439", + "#625e15" + ], + "constraint": [ + "#fab3a8", + "#e85a4d", + "#a91714" + ], + "construction": [ + "#feb299", + "#df663c", + "#9b3407" + ] } } diff --git a/assets/styles/ui/themes/gruvbox-light.json b/assets/styles/ui/themes/gruvbox-light.json index c3789e80d0..7e750545b5 100644 --- a/assets/styles/ui/themes/gruvbox-light.json +++ b/assets/styles/ui/themes/gruvbox-light.json @@ -12,7 +12,7 @@ "text_dim": "#7c6f64", "accent": "#076678", "accent_soft": "#cad5b7", - "sketch_accent": "#076678", + "sketch_accent": "#625e15", "error": "#9d0006", "warning": "#b57614", "icon": "#504945", @@ -34,7 +34,7 @@ "axis_y": "#98971a", "axis_z": "#458588", "construction_plane": "#fe801946", - "construction_geom": "#af3a03", + "construction_geom": "#9b3407", "body": "#bdae93", "body_edge": "#3c3836", "manipulator": "#076678", @@ -64,8 +64,8 @@ "section_cap": "#d65d0e", "profile_fill": "#fabd2f46", "glyph_bg": "#fdf8e3", - "glyph_edge": "#af3a03", - "glyph_text": "#af3a03", + "glyph_edge": "#a91714", + "glyph_text": "#a91714", "overlay": "#fbf1c7eb", "rubber_band": "#076678", "app_bar_text": "#fbf1c7", @@ -93,6 +93,86 @@ "#98971a", "#458588" ], - "icons.agent": "#8f3f71" + "icons.agent": "#8f3f71", + "surface_fill": "#df663c", + "surface_soft": "#f5e2b7", + "surface_ink": "#af3a03", + "surface_accent": "#9b3407", + "mesh_fill": "#c08024", + "mesh_soft": "#f5e7b9", + "mesh_ink": "#8d5c10", + "mesh_accent": "#7b5112", + "form_fill": "#c46fa2", + "form_soft": "#f2e3c0", + "form_tab_ink": "#8f3f71", + "form_accent": "#893a6c", + "sheet_fill": "#659f7a", + "sheet_soft": "#ece8be", + "sheet_ink": "#3c7050", + "render_fill": "#e85a4d", + "render_soft": "#f3deb8", + "render_ink": "#9d0006", + "render_accent": "#a91714", + "sketch_soft": "#f1e7b8", + "sketch_ink": "#6d680d", + "form_ink": "#3c3836", + "form_dim": "#665c54", + "form_primary": "#076678", + "form_error": "#9d0006", + "form_error_fill": "#f2c8a5", + "form_success": "#427b58", + "form_success_fill": "#dae3b6", + "dialog_body": "#fbf1c7", + "dialog_footer": "#ebdbb2", + "dialog_rule": "#d5c4a1", + "control_fill": "#fdf8e3", + "control_border": "#a89984", + "dialog_label": "#3c3836", + "selected_ink": "#3c3836", + "selected_bg": "#cad5b7", + "on_accent": "#fbf1c7" + }, + "icon_palette": { + "ink": "#3c3836", + "line": "#504945", + "passive-1": "#e9e3df", + "passive-2": "#b8b3af", + "passive-3": "#8a8581", + "guide": "#7c6f64", + "solid": [ + "#87d5e9", + "#4e9caf", + "#076678" + ], + "surface": [ + "#feb299", + "#df663c", + "#9b3407" + ], + "mesh": [ + "#fcb864", + "#c08024", + "#7b5112" + ], + "form": [ + "#fbaad9", + "#c46fa2", + "#893a6c" + ], + "sketch": [ + "#d0cc73", + "#989439", + "#625e15" + ], + "constraint": [ + "#fab3a8", + "#e85a4d", + "#a91714" + ], + "construction": [ + "#feb299", + "#df663c", + "#9b3407" + ] } } diff --git a/assets/styles/ui/themes/high-contrast.json b/assets/styles/ui/themes/high-contrast.json index 9614ca3cec..f7b40c8aa6 100644 --- a/assets/styles/ui/themes/high-contrast.json +++ b/assets/styles/ui/themes/high-contrast.json @@ -30,7 +30,7 @@ "box_crossing": "#40e080", "box_window": "#00bfff", "chip": "#00bfff", - "construction_geom": "#ffcc00", + "construction_geom": "#e6a019", "construction_plane": "#ffcc0022", "continuity": [ "#40e080", @@ -54,25 +54,25 @@ "dimension_driven": "#cccccc", "error": "#ff6666", "field": "#000000", - "form_accent": "#00bfff", + "form_accent": "#d98af9", "form_dim": "#cccccc", "form_error": "#ff6666", "form_error_fill": "#331414", - "form_fill": "#0070d0", + "form_fill": "#e6b2fc", "form_ink": "#ffffff", "form_primary": "#00bfff", - "form_soft": "#002c45", + "form_soft": "#1b111f", "form_success": "#40e080", "form_success_fill": "#0b2616", - "form_tab_ink": "#ffffff", + "form_tab_ink": "#df8fff", "gizmo": [ "#ff6666", "#40e080", "#0070d0" ], "glyph_bg": "#151515", - "glyph_edge": "#ff6666", - "glyph_text": "#ff6666", + "glyph_edge": "#fa8a86", + "glyph_text": "#fa8a86", "grid": "#10202b", "grid_major": "#234054", "heading_bg": "#000000", @@ -129,10 +129,10 @@ "#555555" ], "manipulator": "#00bfff", - "mesh_accent": "#00bfff", - "mesh_fill": "#0070d0", - "mesh_ink": "#ffffff", - "mesh_soft": "#002c45", + "mesh_accent": "#b7b728", + "mesh_fill": "#d1d12d", + "mesh_ink": "#ffff00", + "mesh_soft": "#1f1f00", "on_accent": "#06121b", "origin_plane": "#ffcc0028", "origin_plane_edge": "#ffcc0088", @@ -146,10 +146,10 @@ "preview_new": "#ffcc00", "preview_sketch": "#40e080", "profile_fill": "#0070d022", - "render_accent": "#00bfff", - "render_fill": "#0070d0", - "render_ink": "#ffffff", - "render_soft": "#002c45", + "render_accent": "#fd7ebe", + "render_fill": "#fbaed2", + "render_ink": "#ff80c0", + "render_soft": "#1f0f17", "rubber_band": "#00bfff", "section_cap": "#ffcc00", "sel_edge": "#00bfff", @@ -160,26 +160,26 @@ "sel_vertex": "#0070d0", "selected_bg": "#004c80", "selected_ink": "#ffffff", - "sheet_fill": "#0070d0", - "sheet_ink": "#ffffff", - "sheet_soft": "#002c45", + "sheet_fill": "#1fe5e5", + "sheet_ink": "#00ffff", + "sheet_soft": "#001f1f", "sketch": "#0070d0", - "sketch_accent": "#00bfff", + "sketch_accent": "#2cd06d", "sketch_centerline": "#ffcc00", "sketch_construction": "#ffcc00", "sketch_fixed": "#ffffff", - "sketch_ink": "#ffffff", + "sketch_ink": "#00ff80", "sketch_locked": "#40e080", "sketch_lost": "#ff6666", "sketch_point": "#ffffff", "sketch_point_free": "#0070d0", "sketch_projected": "#c080ff", - "sketch_soft": "#002c45", + "sketch_soft": "#001f0f", "subheading_bg": "#060606", - "surface_accent": "#00bfff", - "surface_fill": "#0070d0", - "surface_ink": "#ffffff", - "surface_soft": "#002c45", + "surface_accent": "#e6a019", + "surface_fill": "#fcbc56", + "surface_ink": "#ffb000", + "surface_soft": "#1f1500", "tab_on_fill_dark": "#000000", "text": "#ffffff", "text_dim": "#cccccc", @@ -198,5 +198,48 @@ "warning": "#ffcc00" }, "format": "solidcraft-theme/1", - "name": "High Contrast" + "name": "High Contrast", + "icon_palette": { + "ink": "#000000", + "line": "#ffffff", + "passive-1": "#ffffff", + "passive-2": "#c0c0c0", + "passive-3": "#808080", + "guide": "#cccccc", + "solid": [ + "#c9eafd", + "#8ad4fa", + "#00bfff" + ], + "surface": [ + "#fedfb4", + "#fcbc56", + "#e6a019" + ], + "mesh": [ + "#eeee31", + "#d1d12d", + "#b7b728" + ], + "form": [ + "#f3dafe", + "#e6b2fc", + "#d98af9" + ], + "sketch": [ + "#a4fdba", + "#32ee7e", + "#2cd06d" + ], + "constraint": [ + "#fedbd9", + "#feb2ae", + "#fa8a86" + ], + "construction": [ + "#fedfb4", + "#fcbc56", + "#e6a019" + ] + } } diff --git a/assets/styles/ui/themes/nord.json b/assets/styles/ui/themes/nord.json index 3b0d1b817f..b9f056660a 100644 --- a/assets/styles/ui/themes/nord.json +++ b/assets/styles/ui/themes/nord.json @@ -30,7 +30,7 @@ "box_crossing": "#a3be8c", "box_window": "#88c0d0", "chip": "#88c0d0", - "construction_geom": "#ebcb8b", + "construction_geom": "#eca089", "construction_plane": "#ebcb8b22", "continuity": [ "#a3be8c", @@ -54,25 +54,25 @@ "dimension_driven": "#a4b0c2", "error": "#bf616a", "field": "#242933", - "form_accent": "#88c0d0", + "form_accent": "#cfa7c7", "form_dim": "#a4b0c2", "form_error": "#bf616a", "form_error_fill": "#4b3d48", - "form_fill": "#5e81ac", + "form_fill": "#e7bfe0", "form_ink": "#eceff4", "form_primary": "#88c0d0", - "form_soft": "#35495b", + "form_soft": "#3e3f4d", "form_success": "#a3be8c", "form_success_fill": "#424b4d", - "form_tab_ink": "#eceff4", + "form_tab_ink": "#c3a5bd", "gizmo": [ "#bf616a", "#a3be8c", "#5e81ac" ], "glyph_bg": "#3b4252", - "glyph_edge": "#bf616a", - "glyph_text": "#bf616a", + "glyph_edge": "#f9959c", + "glyph_text": "#f9959c", "grid": "#354051", "grid_major": "#444f60", "heading_bg": "#171a20", @@ -129,10 +129,10 @@ "#657991" ], "manipulator": "#88c0d0", - "mesh_accent": "#88c0d0", - "mesh_fill": "#5e81ac", - "mesh_ink": "#eceff4", - "mesh_soft": "#35495b", + "mesh_accent": "#d1b172", + "mesh_fill": "#e9c989", + "mesh_ink": "#ebcb8b", + "mesh_soft": "#454649", "on_accent": "#06121b", "origin_plane": "#ebcb8b28", "origin_plane_edge": "#ebcb8b88", @@ -146,10 +146,10 @@ "preview_new": "#ebcb8b", "preview_sketch": "#a3be8c", "profile_fill": "#5e81ac22", - "render_accent": "#88c0d0", - "render_fill": "#5e81ac", - "render_ink": "#eceff4", - "render_soft": "#35495b", + "render_accent": "#f9959c", + "render_fill": "#fdb9bd", + "render_ink": "#d5979d", + "render_soft": "#3f3945", "rubber_band": "#88c0d0", "section_cap": "#ebcb8b", "sel_edge": "#5e81ac", @@ -160,26 +160,26 @@ "sel_vertex": "#5e81ac", "selected_bg": "#34607a", "selected_ink": "#eceff4", - "sheet_fill": "#5e81ac", - "sheet_ink": "#eceff4", - "sheet_soft": "#35495b", + "sheet_fill": "#aad8d7", + "sheet_ink": "#8fbcbb", + "sheet_soft": "#3a444f", "sketch": "#5e81ac", - "sketch_accent": "#88c0d0", + "sketch_accent": "#a5c08e", "sketch_centerline": "#ebcb8b", "sketch_construction": "#ebcb8b", "sketch_fixed": "#eceff4", - "sketch_ink": "#eceff4", + "sketch_ink": "#a3be8c", "sketch_locked": "#a3be8c", "sketch_lost": "#bf616a", "sketch_point": "#eceff4", "sketch_point_free": "#5e81ac", "sketch_projected": "#b48ead", - "sketch_soft": "#35495b", + "sketch_soft": "#3c4549", "subheading_bg": "#222730", - "surface_accent": "#88c0d0", - "surface_fill": "#5e81ac", - "surface_ink": "#eceff4", - "surface_soft": "#35495b", + "surface_accent": "#eca089", + "surface_fill": "#fcbda9", + "surface_ink": "#d99e8b", + "surface_soft": "#413e46", "tab_on_fill_dark": "#242933", "text": "#eceff4", "text_dim": "#a4b0c2", @@ -198,5 +198,48 @@ "warning": "#ebcb8b" }, "format": "solidcraft-theme/1", - "name": "Nord" + "name": "Nord", + "icon_palette": { + "ink": "#242933", + "line": "#eceff4", + "passive-1": "#bdc1c8", + "passive-2": "#91959c", + "passive-3": "#686c72", + "guide": "#a4b0c2", + "solid": [ + "#bbeefd", + "#a0d8e9", + "#88c0d0" + ], + "surface": [ + "#fedcd1", + "#fcbda9", + "#eca089" + ], + "mesh": [ + "#fee1a7", + "#e9c989", + "#d1b172" + ], + "form": [ + "#ffd6f7", + "#e7bfe0", + "#cfa7c7" + ], + "sketch": [ + "#d3f0bc", + "#bdd8a5", + "#a5c08e" + ], + "constraint": [ + "#fddbdc", + "#fdb9bd", + "#f9959c" + ], + "construction": [ + "#fedcd1", + "#fcbda9", + "#eca089" + ] + } } diff --git a/assets/styles/ui/themes/nosferatu.json b/assets/styles/ui/themes/nosferatu.json index 634e78b2a8..5331931369 100644 --- a/assets/styles/ui/themes/nosferatu.json +++ b/assets/styles/ui/themes/nosferatu.json @@ -30,7 +30,7 @@ "box_crossing": "#50fa7b", "box_window": "#bd93f9", "chip": "#bd93f9", - "construction_geom": "#f1fa8c", + "construction_geom": "#e09b4e", "construction_plane": "#f1fa8c22", "continuity": [ "#50fa7b", @@ -54,25 +54,25 @@ "dimension_driven": "#a1a4c4", "error": "#ff5555", "field": "#21222c", - "form_accent": "#bd93f9", + "form_accent": "#c096e8", "form_dim": "#a1a4c4", "form_error": "#ff5555", "form_error_fill": "#53333c", - "form_fill": "#8456b8", + "form_fill": "#d7b2fd", "form_ink": "#f8f8f2", "form_primary": "#bd93f9", - "form_soft": "#393149", + "form_soft": "#3d3a4e", "form_success": "#50fa7b", "form_success_fill": "#2f4d42", - "form_tab_ink": "#f8f8f2", + "form_tab_ink": "#d6acff", "gizmo": [ "#ff5555", "#50fa7b", "#8456b8" ], "glyph_bg": "#343746", - "glyph_edge": "#ff5555", - "glyph_text": "#ff5555", + "glyph_edge": "#fc827b", + "glyph_text": "#fc827b", "grid": "#2e2f3d", "grid_major": "#3e4050", "heading_bg": "#14151b", @@ -129,10 +129,10 @@ "#656581" ], "manipulator": "#bd93f9", - "mesh_accent": "#bd93f9", - "mesh_fill": "#8456b8", - "mesh_ink": "#f8f8f2", - "mesh_soft": "#393149", + "mesh_accent": "#adb445", + "mesh_fill": "#c6ce60", + "mesh_ink": "#f1fa8c", + "mesh_soft": "#404340", "on_accent": "#06121b", "origin_plane": "#f1fa8c28", "origin_plane_edge": "#f1fa8c88", @@ -146,10 +146,10 @@ "preview_new": "#f1fa8c", "preview_sketch": "#50fa7b", "profile_fill": "#8456b822", - "render_accent": "#bd93f9", - "render_fill": "#8456b8", - "render_ink": "#f8f8f2", - "render_soft": "#393149", + "render_accent": "#fb75c2", + "render_fill": "#fea4d4", + "render_ink": "#ff79c6", + "render_soft": "#423347", "rubber_band": "#bd93f9", "section_cap": "#f1fa8c", "sel_edge": "#8456b8", @@ -160,26 +160,26 @@ "sel_vertex": "#8456b8", "selected_bg": "#563c79", "selected_ink": "#f8f8f2", - "sheet_fill": "#8456b8", - "sheet_ink": "#f8f8f2", - "sheet_soft": "#393149", + "sheet_fill": "#77d5e9", + "sheet_ink": "#8be9fd", + "sheet_soft": "#34414e", "sketch": "#8456b8", - "sketch_accent": "#bd93f9", + "sketch_accent": "#31cc5d", "sketch_centerline": "#f1fa8c", "sketch_construction": "#f1fa8c", "sketch_fixed": "#f8f8f2", - "sketch_ink": "#f8f8f2", + "sketch_ink": "#50fa7b", "sketch_locked": "#50fa7b", "sketch_lost": "#ff5555", "sketch_point": "#f8f8f2", "sketch_point_free": "#8456b8", "sketch_projected": "#bd93f9", - "sketch_soft": "#393149", + "sketch_soft": "#2d433e", "subheading_bg": "#1e2028", - "surface_accent": "#bd93f9", - "surface_fill": "#8456b8", - "surface_ink": "#f8f8f2", - "surface_soft": "#393149", + "surface_accent": "#e09b4e", + "surface_fill": "#fbb468", + "surface_ink": "#ffb86c", + "surface_soft": "#423b3c", "tab_on_fill_dark": "#21222c", "text": "#f8f8f2", "text_dim": "#a1a4c4", @@ -198,5 +198,48 @@ "warning": "#f1fa8c" }, "format": "solidcraft-theme/1", - "name": "Nosferatu" + "name": "Nosferatu", + "icon_palette": { + "ink": "#21222c", + "line": "#f8f8f2", + "passive-1": "#bfc0cb", + "passive-2": "#93949f", + "passive-3": "#6a6b75", + "guide": "#a1a4c4", + "solid": [ + "#e7dbfd", + "#d0b6fa", + "#bd93f9" + ], + "surface": [ + "#fedbb9", + "#fbb468", + "#e09b4e" + ], + "mesh": [ + "#e3eb7e", + "#c6ce60", + "#adb445" + ], + "form": [ + "#ebd9fe", + "#d7b2fd", + "#c096e8" + ], + "sketch": [ + "#92ffa4", + "#38e96b", + "#31cc5d" + ], + "constraint": [ + "#fdd8d4", + "#ffaca5", + "#fc827b" + ], + "construction": [ + "#fedbb9", + "#fbb468", + "#e09b4e" + ] + } } diff --git a/assets/styles/ui/themes/one-dark.json b/assets/styles/ui/themes/one-dark.json index 026fe1fcdf..bd8a2be038 100644 --- a/assets/styles/ui/themes/one-dark.json +++ b/assets/styles/ui/themes/one-dark.json @@ -30,7 +30,7 @@ "box_crossing": "#98c379", "box_window": "#61afef", "chip": "#344b65", - "construction_geom": "#d19a66", + "construction_geom": "#d29b67", "construction_plane": "#d0996646", "continuity": [ "#98c379", @@ -54,25 +54,25 @@ "dimension_driven": "#7f848e", "error": "#e06c75", "field": "#1e2227", - "form_accent": "#7b63a6", + "form_accent": "#d283e9", "form_dim": "#949ba8", "form_error": "#f06460", "form_error_fill": "#6e3236", - "form_fill": "#8a6fcc", + "form_fill": "#e9a1fe", "form_ink": "#dee2e8", "form_primary": "#3a88ee", - "form_soft": "#372f4f", + "form_soft": "#352f40", "form_success": "#8fdb9a", "form_success_fill": "#2c4a33", - "form_tab_ink": "#b39bee", + "form_tab_ink": "#c97fdf", "gizmo": [ "#e06c75", "#98c379", "#61afef" ], "glyph_bg": "#282c34", - "glyph_edge": "#e06c75", - "glyph_text": "#e06c75", + "glyph_edge": "#f57f87", + "glyph_text": "#f57f87", "grid": "#323842", "grid_major": "#3e4451", "heading_bg": "#14161a", @@ -129,10 +129,10 @@ "#c678dd" ], "manipulator": "#61afef", - "mesh_accent": "#857100", - "mesh_fill": "#c6a800", - "mesh_ink": "#d4b726", - "mesh_soft": "#41391a", + "mesh_accent": "#c6a25e", + "mesh_fill": "#e0bb76", + "mesh_ink": "#e5c07b", + "mesh_soft": "#393835", "on_accent": "#ffffff", "origin_plane": "#d19b675e", "origin_plane_edge": "#d19a66c8", @@ -146,10 +146,10 @@ "preview_new": "#e5c07b", "preview_sketch": "#98c379", "profile_fill": "#60acec28", - "render_accent": "#e074ae", - "render_fill": "#c8588f", - "render_ink": "#e074ae", - "render_soft": "#402336", + "render_accent": "#f57f87", + "render_fill": "#fea5a9", + "render_ink": "#e37b83", + "render_soft": "#382e34", "rubber_band": "#61afef", "section_cap": "#d19a66", "sel_edge": "#61afef", @@ -160,26 +160,26 @@ "sel_vertex": "#61afef", "selected_bg": "#2a466e", "selected_ink": "#9cc7f5", - "sheet_fill": "#2a7fa8", - "sheet_ink": "#4fb3d9", - "sheet_soft": "#1f3a47", + "sheet_fill": "#73d2de", + "sheet_ink": "#56b6c2", + "sheet_soft": "#27363d", "sketch": "#61afef", - "sketch_accent": "#56b6c2", + "sketch_accent": "#8cb76d", "sketch_centerline": "#d19a66", "sketch_construction": "#d19a66", "sketch_fixed": "#abb2bf", - "sketch_ink": "#46be78", + "sketch_ink": "#98c379", "sketch_locked": "#98c379", "sketch_lost": "#e06c75", "sketch_point": "#abb2bf", "sketch_point_free": "#61afef", "sketch_projected": "#c678dd", - "sketch_soft": "#1f3b2b", + "sketch_soft": "#2f3834", "subheading_bg": "#1e2127", - "surface_accent": "#95682d", - "surface_fill": "#b26a14", - "surface_ink": "#e0914a", - "surface_soft": "#45331e", + "surface_accent": "#d29b67", + "surface_fill": "#edb47f", + "surface_ink": "#d19a66", + "surface_soft": "#363332", "tab_on_fill_dark": "#000000", "text": "#abb2bf", "text_dim": "#7f848e", @@ -198,5 +198,48 @@ "warning": "#e5c07b" }, "format": "solidcraft-theme/1", - "name": "One Dark" + "name": "One Dark", + "icon_palette": { + "ink": "#21252b", + "line": "#abb2bf", + "passive-1": "#bec1c6", + "passive-2": "#93959a", + "passive-3": "#696c70", + "guide": "#7f848e", + "solid": [ + "#c5e2fc", + "#8ac8fd", + "#61afef" + ], + "surface": [ + "#fcd6b4", + "#edb47f", + "#d29b67" + ], + "mesh": [ + "#fed993", + "#e0bb76", + "#c6a25e" + ], + "form": [ + "#f2d0fd", + "#e9a1fe", + "#d283e9" + ], + "sketch": [ + "#c2eea2", + "#a5d186", + "#8cb76d" + ], + "constraint": [ + "#fed1d2", + "#fea5a9", + "#f57f87" + ], + "construction": [ + "#fcd6b4", + "#edb47f", + "#d29b67" + ] + } } diff --git a/assets/styles/ui/themes/platinum-9.json b/assets/styles/ui/themes/platinum-9.json index ab843ef51f..68bf60fae8 100644 --- a/assets/styles/ui/themes/platinum-9.json +++ b/assets/styles/ui/themes/platinum-9.json @@ -30,7 +30,7 @@ "box_crossing": "#28a050", "box_window": "#555599", "chip": "#555599", - "construction_geom": "#9c5600", + "construction_geom": "#785735", "construction_plane": "#ccccdd33", "continuity": [ "#009e73", @@ -54,25 +54,25 @@ "dimension_driven": "#666666", "error": "#bb3333", "field": "#ffffff", - "form_accent": "#666699", + "form_accent": "#704e81", "form_dim": "#666666", "form_error": "#aa2222", "form_error_fill": "#f2dddd", - "form_fill": "#8888bb", + "form_fill": "#a783b9", "form_ink": "#444477", "form_primary": "#555599", - "form_soft": "#e6e6f2", + "form_soft": "#d5d2d6", "form_success": "#336633", "form_success_fill": "#e2ecdf", - "form_tab_ink": "#555599", + "form_tab_ink": "#6d4e7d", "gizmo": [ "#e1463c", "#50be50", "#4682f0" ], "glyph_bg": "#ffffff", - "glyph_edge": "#be6e64", - "glyph_text": "#ca473d", + "glyph_edge": "#ab2125", + "glyph_text": "#ab2125", "grid": "#e2e2e2", "grid_major": "#c8c8c8", "heading_bg": "#cccccc", @@ -129,10 +129,10 @@ "#666699" ], "manipulator": "#555599", - "mesh_accent": "#666699", - "mesh_fill": "#8888bb", - "mesh_ink": "#444477", - "mesh_soft": "#e6e6f2", + "mesh_accent": "#6d5d2a", + "mesh_fill": "#a3925e", + "mesh_ink": "#675a34", + "mesh_soft": "#d6d5d1", "on_accent": "#ffffff", "origin_plane": "#ccccdd44", "origin_plane_edge": "#888899aa", @@ -146,10 +146,10 @@ "preview_new": "#f0e169", "preview_sketch": "#20be60", "profile_fill": "#ccccff33", - "render_accent": "#666699", - "render_fill": "#8888bb", - "render_ink": "#444477", - "render_soft": "#e6e6f2", + "render_accent": "#913d5f", + "render_fill": "#cc7294", + "render_ink": "#8e3f5f", + "render_soft": "#d8d1d3", "rubber_band": "#555599", "section_cap": "#d68048", "sel_edge": "#555599", @@ -160,26 +160,26 @@ "sel_vertex": "#555599", "selected_bg": "#ccccff", "selected_ink": "#333366", - "sheet_fill": "#8888bb", - "sheet_ink": "#444477", - "sheet_soft": "#e6e6f2", + "sheet_fill": "#6a9e9e", + "sheet_ink": "#376060", + "sheet_soft": "#d1d5d5", "sketch": "#444488", - "sketch_accent": "#666699", + "sketch_accent": "#476847", "sketch_centerline": "#c86e3c", "sketch_construction": "#e68228", "sketch_fixed": "#222222", - "sketch_ink": "#444477", + "sketch_ink": "#456045", "sketch_locked": "#447744", "sketch_lost": "#c83c5a", "sketch_point": "#222222", "sketch_point_free": "#444488", "sketch_projected": "#963cbe", - "sketch_soft": "#e6e6f2", + "sketch_soft": "#d2d5d2", "subheading_bg": "#d7d7d7", - "surface_accent": "#666699", - "surface_fill": "#8888bb", - "surface_ink": "#444477", - "surface_soft": "#e6e6f2", + "surface_accent": "#785735", + "surface_fill": "#af8b68", + "surface_ink": "#715538", + "surface_soft": "#d6d3d1", "tab_on_fill_dark": "#222222", "text": "#222222", "text_dim": "#666666", @@ -198,5 +198,56 @@ "warning": "#997722" }, "format": "solidcraft-theme/1", - "name": "Platinum 9" + "name": "Platinum 9", + "radii": { + "radius_sm": 0, + "radius_md": 0, + "radius_lg": 0, + "radius_xl": 0, + "control_radius": 0, + "sketch_mode_radius": 0 + }, + "icon_palette": { + "ink": "#222222", + "line": "#222222", + "passive-1": "#e2e3f7", + "passive-2": "#b1b2c6", + "passive-3": "#838497", + "guide": "#666666", + "solid": [ + "#bfc3fb", + "#888ad3", + "#555599" + ], + "surface": [ + "#e6c09c", + "#af8b68", + "#785735" + ], + "mesh": [ + "#d9c792", + "#a3925e", + "#6d5d2a" + ], + "form": [ + "#ddb7f1", + "#a783b9", + "#704e81" + ], + "sketch": [ + "#afd4ae", + "#7a9e7a", + "#476847" + ], + "constraint": [ + "#ffb1aa", + "#ea5f5a", + "#ab2125" + ], + "construction": [ + "#e6c09c", + "#af8b68", + "#785735" + ] + } } diff --git a/assets/styles/ui/themes/solarized-light.json b/assets/styles/ui/themes/solarized-light.json index eab4c8c5cb..87e696a8f0 100644 --- a/assets/styles/ui/themes/solarized-light.json +++ b/assets/styles/ui/themes/solarized-light.json @@ -30,7 +30,7 @@ "box_crossing": "#859900", "box_window": "#268bd2", "chip": "#268bd2", - "construction_geom": "#b58900", + "construction_geom": "#cb4b16", "construction_plane": "#b5890022", "continuity": [ "#859900", @@ -54,25 +54,25 @@ "dimension_driven": "#657b83", "error": "#dc322f", "field": "#fdf6e3", - "form_accent": "#268bd2", + "form_accent": "#6b70c3", "form_dim": "#657b83", "form_error": "#dc322f", "form_error_fill": "#eac4b4", - "form_fill": "#346e88", + "form_fill": "#a2a9f8", "form_ink": "#586e75", "form_primary": "#268bd2", - "form_soft": "#e1e8d9", + "form_soft": "#e4ded4", "form_success": "#859900", "form_success_fill": "#dcdbb1", - "form_tab_ink": "#346e88", + "form_tab_ink": "#565a9d", "gizmo": [ "#dc322f", "#859900", "#346e88" ], "glyph_bg": "#e3ddc9", - "glyph_edge": "#dc322f", - "glyph_text": "#dc322f", + "glyph_edge": "#da2f2d", + "glyph_text": "#da2f2d", "grid": "#dfddd0", "grid_major": "#c8cbc1", "heading_bg": "#e3ddc9", @@ -129,10 +129,10 @@ "#b5b6a0" ], "manipulator": "#268bd2", - "mesh_accent": "#268bd2", - "mesh_fill": "#346e88", - "mesh_ink": "#586e75", - "mesh_soft": "#e1e8d9", + "mesh_accent": "#97751d", + "mesh_fill": "#d7aa3c", + "mesh_ink": "#7b5d00", + "mesh_soft": "#e9e0c4", "on_accent": "#ffffff", "origin_plane": "#b5890028", "origin_plane_edge": "#b5890088", @@ -146,10 +146,10 @@ "preview_new": "#b58900", "preview_sketch": "#859900", "profile_fill": "#346e8822", - "render_accent": "#268bd2", - "render_fill": "#346e88", - "render_ink": "#586e75", - "render_soft": "#e1e8d9", + "render_accent": "#cf317e", + "render_fill": "#f986b4", + "render_ink": "#ad2c6b", + "render_soft": "#ecdace", "rubber_band": "#268bd2", "section_cap": "#b58900", "sel_edge": "#346e88", @@ -160,26 +160,26 @@ "sel_vertex": "#346e88", "selected_bg": "#bdd9da", "selected_ink": "#234a55", - "sheet_fill": "#346e88", - "sheet_ink": "#586e75", - "sheet_soft": "#e1e8d9", + "sheet_fill": "#58c6bc", + "sheet_ink": "#1d6d67", + "sheet_soft": "#dee2d0", "sketch": "#346e88", - "sketch_accent": "#268bd2", + "sketch_accent": "#738400", "sketch_centerline": "#b58900", "sketch_construction": "#b58900", "sketch_fixed": "#586e75", - "sketch_ink": "#586e75", + "sketch_ink": "#5a6800", "sketch_locked": "#859900", "sketch_lost": "#dc322f", "sketch_point": "#586e75", "sketch_point_free": "#346e88", "sketch_projected": "#6c71c4", - "sketch_soft": "#e1e8d9", + "sketch_soft": "#e6e2c4", "subheading_bg": "#eae4d1", - "surface_accent": "#268bd2", - "surface_fill": "#346e88", - "surface_ink": "#586e75", - "surface_soft": "#e1e8d9", + "surface_accent": "#cb4b16", + "surface_fill": "#fb906b", + "surface_ink": "#a83e12", + "surface_soft": "#ebdbc6", "tab_on_fill_dark": "#fdf6e3", "text": "#586e75", "text_dim": "#657b83", @@ -198,5 +198,48 @@ "warning": "#b58900" }, "format": "solidcraft-theme/1", - "name": "Solarized Light" + "name": "Solarized Light", + "icon_palette": { + "ink": "#151515", + "line": "#586e75", + "passive-1": "#dee6e9", + "passive-2": "#aeb6b9", + "passive-3": "#80888a", + "guide": "#657b83", + "solid": [ + "#d5ebff", + "#67b9fb", + "#1480c6" + ], + "surface": [ + "#fee1d7", + "#fb906b", + "#cb4b16" + ], + "mesh": [ + "#fde5b2", + "#d7aa3c", + "#97751d" + ], + "form": [ + "#e3e6fe", + "#a2a9f8", + "#6b70c3" + ], + "sketch": [ + "#def67b", + "#a8be40", + "#738400" + ], + "constraint": [ + "#fee0dc", + "#fb8d81", + "#da2f2d" + ], + "construction": [ + "#fee1d7", + "#fb906b", + "#cb4b16" + ] + } } diff --git a/assets/styles/ui/themes/solidcraft-95.json b/assets/styles/ui/themes/solidcraft-95.json index 29b442b382..f60abdc1e6 100644 --- a/assets/styles/ui/themes/solidcraft-95.json +++ b/assets/styles/ui/themes/solidcraft-95.json @@ -55,16 +55,16 @@ "error": "#ff0000", "field": "#ffffff", "form_accent": "#800080", - "form_dim": "#808080", + "form_dim": "#404040", "form_error": "#800000", "form_error_fill": "#ffffc0", "form_fill": "#800080", "form_ink": "#000000", "form_primary": "#000080", - "form_soft": "#c0c0c0", + "form_soft": "#bbb1bb", "form_success": "#008000", "form_success_fill": "#c0dcc0", - "form_tab_ink": "#800080", + "form_tab_ink": "#7d007d", "gizmo": [ "#ff0000", "#00ff00", @@ -75,8 +75,8 @@ "glyph_text": "#800000", "grid": "#40a0a0", "grid_major": "#808080", - "heading_bg": "#a0a0a0", - "hover": "#c0c0c0", + "heading_bg": "#c0c0c0", + "hover": "#dfdfdf", "hover_edge": "#000000", "hover_edge_halo": "#ffffff", "hover_profile": "#00008030", @@ -129,10 +129,10 @@ "#808080" ], "manipulator": "#000080", - "mesh_accent": "#000080", + "mesh_accent": "#808000", "mesh_fill": "#808000", - "mesh_ink": "#808000", - "mesh_soft": "#ffffc0", + "mesh_ink": "#4c4c00", + "mesh_soft": "#bbbbb1", "on_accent": "#ffffff", "origin_plane": "#ffff005e", "origin_plane_edge": "#808000c8", @@ -146,10 +146,10 @@ "preview_new": "#ffff00", "preview_sketch": "#008000", "profile_fill": "#ffff0033", - "render_accent": "#000080", - "render_fill": "#800080", - "render_ink": "#800080", - "render_soft": "#c0c0c0", + "render_accent": "#590905", + "render_fill": "#800000", + "render_ink": "#800000", + "render_soft": "#bbb1b1", "rubber_band": "#000080", "section_cap": "#808000", "sel_edge": "#0000ff", @@ -161,25 +161,25 @@ "selected_bg": "#000080", "selected_ink": "#ffffff", "sheet_fill": "#008080", - "sheet_ink": "#000080", - "sheet_soft": "#c0c0c0", + "sheet_ink": "#005151", + "sheet_soft": "#b1bbbb", "sketch": "#000080", "sketch_accent": "#008000", "sketch_centerline": "#808000", "sketch_construction": "#808000", "sketch_fixed": "#000000", - "sketch_ink": "#008000", + "sketch_ink": "#005400", "sketch_locked": "#008000", "sketch_lost": "#800080", "sketch_point": "#000000", "sketch_point_free": "#000080", "sketch_projected": "#800080", - "sketch_soft": "#c0dcc0", - "subheading_bg": "#b0b0b0", - "surface_accent": "#000080", - "surface_fill": "#808000", - "surface_ink": "#808000", - "surface_soft": "#ffffc0", + "sketch_soft": "#b1bbb1", + "subheading_bg": "#c0c0c0", + "surface_accent": "#804000", + "surface_fill": "#804000", + "surface_ink": "#713800", + "surface_soft": "#bbb6b1", "tab_on_fill_dark": "#000000", "text": "#000000", "text_dim": "#808080", @@ -198,5 +198,85 @@ "warning": "#ffff00" }, "format": "solidcraft-theme/1", - "name": "SolidCraft 95" + "name": "SolidCraft 95", + "radii": { + "radius_sm": 3, + "radius_md": 3, + "radius_lg": 4, + "radius_xl": 4, + "control_radius": 3, + "sketch_mode_radius": 3 + }, + "icon_palette": { + "ink": "#000000", + "line": "#000000", + "passive-1": "#ffffff", + "passive-2": "#c0c0c0", + "passive-3": "#808080", + "guide": "#696969", + "solid": [ + "#c0c0ff", + "#0000ff", + "#000080" + ], + "surface": [ + "#e0c0a0", + "#b07040", + "#804000" + ], + "mesh": [ + "#ffffc0", + "#c0c000", + "#808000" + ], + "form": [ + "#ffc0ff", + "#c000c0", + "#800080" + ], + "sketch": [ + "#c0ffc0", + "#00c000", + "#008000" + ], + "constraint": [ + "#ffc0c0", + "#ff0000", + "#800000" + ], + "construction": [ + "#ffffc0", + "#c0c000", + "#808000" + ] + }, + "borders": { + "primary": { + "width": 1, + "color": "#404040" + }, + "secondary": { + "width": 1, + "color": "#808080" + }, + "tertiary": { + "width": 1, + "color": "#a0a0a0" + }, + "container": { + "width": 1, + "color": "#808080" + }, + "control": { + "width": 1, + "color": "#808080" + }, + "bevel": { + "width": 0.5, + "highlight": "#ffffff", + "light": "#dfdfdf", + "shadow": "#808080", + "dark_shadow": "#000000" + } + } } diff --git a/assets/styles/ui/themes/tokyo-night.json b/assets/styles/ui/themes/tokyo-night.json index 7bff6a50a3..bc371b5d5e 100644 --- a/assets/styles/ui/themes/tokyo-night.json +++ b/assets/styles/ui/themes/tokyo-night.json @@ -30,7 +30,7 @@ "box_crossing": "#9ece6a", "box_window": "#7aa2f7", "chip": "#7aa2f7", - "construction_geom": "#e0af68", + "construction_geom": "#e7894e", "construction_plane": "#e0af6822", "continuity": [ "#9ece6a", @@ -54,25 +54,25 @@ "dimension_driven": "#9aa5ce", "error": "#f7768e", "field": "#16161e", - "form_accent": "#7aa2f7", + "form_accent": "#b190ec", "form_dim": "#9aa5ce", "form_error": "#f7768e", "form_error_fill": "#462d3b", - "form_fill": "#4775ce", + "form_fill": "#c7aefa", "form_ink": "#c0caf5", "form_primary": "#7aa2f7", - "form_soft": "#253452", + "form_soft": "#2d2a3f", "form_success": "#9ece6a", "form_success_fill": "#303932", - "form_tab_ink": "#c0caf5", + "form_tab_ink": "#bb9af7", "gizmo": [ "#f7768e", "#9ece6a", "#4775ce" ], "glyph_bg": "#24283b", - "glyph_edge": "#f7768e", - "glyph_text": "#f7768e", + "glyph_edge": "#f6758d", + "glyph_text": "#f6758d", "grid": "#262938", "grid_major": "#363a4d", "heading_bg": "#0d0e13", @@ -129,10 +129,10 @@ "#4f6089" ], "manipulator": "#7aa2f7", - "mesh_accent": "#7aa2f7", - "mesh_fill": "#4775ce", - "mesh_ink": "#c0caf5", - "mesh_soft": "#253452", + "mesh_accent": "#cb9b54", + "mesh_fill": "#e5b46d", + "mesh_ink": "#e0af68", + "mesh_soft": "#322d2e", "on_accent": "#06121b", "origin_plane": "#e0af6828", "origin_plane_edge": "#e0af6888", @@ -146,10 +146,10 @@ "preview_new": "#e0af68", "preview_sketch": "#9ece6a", "profile_fill": "#4775ce22", - "render_accent": "#7aa2f7", - "render_fill": "#4775ce", - "render_ink": "#c0caf5", - "render_soft": "#253452", + "render_accent": "#f6758d", + "render_fill": "#fe9dac", + "render_ink": "#f7768e", + "render_soft": "#352632", "rubber_band": "#7aa2f7", "section_cap": "#e0af68", "sel_edge": "#4775ce", @@ -160,26 +160,26 @@ "sel_vertex": "#4775ce", "selected_bg": "#304e7e", "selected_ink": "#c0caf5", - "sheet_fill": "#4775ce", - "sheet_ink": "#c0caf5", - "sheet_soft": "#253452", + "sheet_fill": "#76c8f8", + "sheet_ink": "#7dcfff", + "sheet_soft": "#263140", "sketch": "#4775ce", - "sketch_accent": "#7aa2f7", + "sketch_accent": "#87b552", "sketch_centerline": "#e0af68", "sketch_construction": "#e0af68", "sketch_fixed": "#c0caf5", - "sketch_ink": "#c0caf5", + "sketch_ink": "#9ece6a", "sketch_locked": "#9ece6a", "sketch_lost": "#f7768e", "sketch_point": "#c0caf5", "sketch_point_free": "#4775ce", "sketch_projected": "#bb9af7", - "sketch_soft": "#253452", + "sketch_soft": "#2a302e", "subheading_bg": "#14141c", - "surface_accent": "#7aa2f7", - "surface_fill": "#4775ce", - "surface_ink": "#c0caf5", - "surface_soft": "#253452", + "surface_accent": "#e7894e", + "surface_fill": "#fda572", + "surface_ink": "#ff9e64", + "surface_soft": "#352b2d", "tab_on_fill_dark": "#16161e", "text": "#c0caf5", "text_dim": "#9aa5ce", @@ -198,5 +198,48 @@ "warning": "#e0af68" }, "format": "solidcraft-theme/1", - "name": "Tokyo Night" + "name": "Tokyo Night", + "icon_palette": { + "ink": "#16161e", + "line": "#c0caf5", + "passive-1": "#bcc0cf", + "passive-2": "#9195a3", + "passive-3": "#676b79", + "guide": "#9aa5ce", + "solid": [ + "#cadbfd", + "#9ebdfc", + "#7aa2f7" + ], + "surface": [ + "#fcd0b8", + "#fda572", + "#e7894e" + ], + "mesh": [ + "#fcd39b", + "#e5b46d", + "#cb9b54" + ], + "form": [ + "#e0d3fc", + "#c7aefa", + "#b190ec" + ], + "sketch": [ + "#bced88", + "#9fcf6b", + "#87b552" + ], + "constraint": [ + "#fdccd2", + "#fe9dac", + "#f6758d" + ], + "construction": [ + "#fcd0b8", + "#fda572", + "#e7894e" + ] + } } diff --git a/crates/doc/src/clipboard.rs b/crates/doc/src/clipboard.rs index ee2d2b616a..99cab241c1 100644 --- a/crates/doc/src/clipboard.rs +++ b/crates/doc/src/clipboard.rs @@ -77,13 +77,7 @@ pub fn clone_expression(source: &str, parameters: &BTreeMap) -> result } -fn remap_geo( - g: &mut crate::construct::GeoRef, - ids: &BTreeMap, - names: &BTreeMap, - bodies: &BTreeMap, - depth: usize, -) -> crate::Result<()> { +fn remap_geo(g: &mut crate::construct::GeoRef, ids: &BTreeMap, bodies: &BTreeMap, depth: usize) -> crate::Result<()> { use crate::construct::GeoRef; if depth > 64 { return Err(crate::DocError::Invalid("clone plane reference depth exceeded".into())); @@ -100,28 +94,18 @@ fn remap_geo( *n = remap_topology(n, ids, bodies); } } - GeoRef::ConstructPoint { name } | GeoRef::Axis { name } => mapped_name(name, names), - GeoRef::Plane { plane } => remap_plane(plane, ids, names, bodies, depth + 1)?, + GeoRef::Plane { plane } => remap_plane(plane, ids, bodies, depth + 1)?, _ => {} } Ok(()) } -fn remap_plane( - p: &mut PlaneRef, - ids: &BTreeMap, - names: &BTreeMap, - bodies: &BTreeMap, - depth: usize, -) -> crate::Result<()> { +fn remap_plane(p: &mut PlaneRef, ids: &BTreeMap, bodies: &BTreeMap, depth: usize) -> crate::Result<()> { if depth > 64 { return Err(crate::DocError::Invalid("clone plane reference depth exceeded".into())); } match p { - PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::Flipped { base, .. } => { - remap_plane(base, ids, names, bodies, depth + 1)? - } - PlaneRef::Construction { name } => mapped_name(&mut name.0, names), + PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::Flipped { base, .. } => remap_plane(base, ids, bodies, depth + 1)?, PlaneRef::Face { name, anchor, .. } => { if let Some(name) = name { *name = remap_topology(name, ids, bodies); @@ -132,14 +116,14 @@ fn remap_plane( } _ => { for r in p.refs_mut() { - remap_geo(r, ids, names, bodies, depth + 1)?; + remap_geo(r, ids, bodies, depth + 1)?; } } } Ok(()) } -fn remap_link( +pub(crate) fn remap_link( source: &mut solidcraft_sketch::LinkSource, ids: &BTreeMap, names: &BTreeMap, @@ -214,6 +198,8 @@ impl crate::Feature { } _ => {} } + // Feature names are remapped once here; the geometry pass below handles only + // sketch ids and topology, so a copy whose new name is also an old key is not remapped twice. self.kind.remap_refs(ids, names); for n in &mut self.body_names { mapped_name(n, bodies); @@ -236,7 +222,7 @@ impl crate::Feature { self.kind.remap_bodies(bodies); match &mut self.kind { K::Sketch { plane, sketch } => { - remap_plane(plane, ids, names, bodies, 0)?; + remap_plane(plane, ids, bodies, 0)?; for c in &mut sketch.constraints { if let Some(n) = &mut c.param { mapped_name(n, parameters); @@ -256,18 +242,18 @@ impl crate::Feature { | K::Split { plane, .. } | K::SplitFace { plane, .. } | K::Mirror { plane, .. } - | K::SurfaceTrim { plane, .. } => remap_plane(plane, ids, names, bodies, 0)?, - K::Draft { neutral, .. } => remap_plane(neutral, ids, names, bodies, 0)?, - K::ReplaceFace { target, .. } => remap_plane(target, ids, names, bodies, 0)?, - K::SurfaceExtend { up_to: Some(crate::ExtendTo::Plane(plane)), .. } => remap_plane(plane, ids, names, bodies, 0)?, + | K::SurfaceTrim { plane, .. } => remap_plane(plane, ids, bodies, 0)?, + K::Draft { neutral, .. } => remap_plane(neutral, ids, bodies, 0)?, + K::ReplaceFace { target, .. } => remap_plane(target, ids, bodies, 0)?, + K::SurfaceExtend { up_to: Some(crate::ExtendTo::Plane(plane)), .. } => remap_plane(plane, ids, bodies, 0)?, K::ConstructionAxis { def } => { for r in def.refs_mut() { - remap_geo(r, ids, names, bodies, 0)?; + remap_geo(r, ids, bodies, 0)?; } } K::ConstructionPoint { def } => { for r in def.refs_mut() { - remap_geo(r, ids, names, bodies, 0)?; + remap_geo(r, ids, bodies, 0)?; } } _ => {} @@ -324,6 +310,28 @@ fn checked_link(source: &solidcraft_sketch::LinkSource, depth: usize) -> crate:: #[cfg(test)] mod clone_tests { use super::*; + + #[test] + fn construction_feature_names_are_remapped_only_once_when_copied() { + use serde_json::json; + + let names = BTreeMap::from([("Old".into(), "New".into()), ("New".into(), "Wrong".into())]); + let origin = json!({"type": "plane", "plane": {"type": "construction", "name": "Old"}}); + for kind in [ + json!({"type": "construction_axis", "def": {"type": "two_points", "a": {"type": "construct_point", "name": "Old"}, "b": {"type": "point", "p": [1, 2, 3]}}}), + json!({"type": "construction_point", "def": {"type": "edge_and_plane", "edge": {"type": "axis", "name": "Old"}, "plane": origin}}), + json!({"type": "construction_plane", "plane": {"type": "perpendicular", "plane": origin, "line": {"type": "axis", "name": "Old"}}}), + ] { + let mut value = kind; + value["id"] = json!(1); + value["name"] = json!("Copy"); + let mut feature: crate::Feature = serde_json::from_value(value).unwrap(); + let before = serde_json::to_string(&feature).unwrap(); + feature.remap_clone(&BTreeMap::new(), &names, &BTreeMap::new(), &BTreeMap::new()).unwrap(); + assert_eq!(serde_json::to_string(&feature).unwrap(), before.replace("\"Old\"", "\"New\"")); + } + } + #[test] fn imported_names_and_topology_tokens_have_separate_identity() { let ids = BTreeMap::from([(1, 2), (10, 20)]); diff --git a/crates/doc/src/component_removal.rs b/crates/doc/src/component_removal.rs new file mode 100644 index 0000000000..1e32566145 --- /dev/null +++ b/crates/doc/src/component_removal.rs @@ -0,0 +1,92 @@ +//! Removing a component tree: its components, the features made in them, their placements, and +//! every assembly record that refers to them. + +use std::collections::BTreeSet; + +use crate::{DocError, Document, FeatureKind, Result}; + +/// What [`Document::remove_component_tree`] removed. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct RemovedTree { + /// The removed components: the root and everything inside it. + pub components: Vec, + /// The features that were inside them, in timeline order. + pub features: Vec, + /// Every deleted feature: `features`, then features elsewhere built on their sketches. + pub deleted: Vec, + /// The removed occurrences: placements of the components and placements inside them. + pub occurrences: Vec, + /// The removed joints: those with either side on a removed occurrence. + pub joints: Vec, +} + +impl Document { + /// Did feature `f` make the component tree it sits at the top of: an inserted file + /// (`Feature::inserted`) or a linked design's bodies? Deleting it then removes that tree. + pub fn feature_owns_component(&self, f: &crate::Feature) -> bool { + f.component != 0 + && matches!(f.kind, FeatureKind::Import { .. } | FeatureKind::MeshImport { .. }) + && (f.inserted || self.links.get(&f.component).is_some_and(|link| link.feature == f.id)) + } + + /// Remove component `root` with every component inside it, the features made in them, their + /// occurrences, the joints, motion links, joint origins, contact sets, motion keys and + /// exploded moves that refer to those occurrences, the bodies' component assignments, and the + /// components' standard-part, link, appearance and configuration records. + /// + /// Refused (nothing changes) for the design itself, an unknown component, or a tree that + /// supplies a body transfer recorded outside it. + pub fn remove_component_tree(&mut self, root: u64) -> Result { + if root == 0 { + return Err(DocError::Invalid("the design itself can't be deleted".into())); + } + if !self.components.iter().any(|c| c.id == root) { + return Err(DocError::Unknown(format!("component {root}"))); + } + let components: Vec = self.components.iter().filter(|c| self.component_within(c.id, root)).map(|c| c.id).collect(); + let inside: BTreeSet = components.iter().copied().collect(); + if self.features.iter().any(|feature| { + !inside.contains(&feature.component) + && matches!(&feature.kind, FeatureKind::BodyTransfer { from, to, .. } if inside.contains(from) || inside.contains(to)) + }) { + return Err(DocError::Invalid( + "component supplies a recorded body transfer outside this subtree; remove that transfer history before deleting its source".into(), + )); + } + let features: Vec = self.features.iter().filter(|f| inside.contains(&f.component)).map(|f| f.id).collect(); + let deleted = if features.is_empty() { Vec::new() } else { self.delete_features(&features)? }; + + let occurrences: BTreeSet = + self.occurrences.iter().filter(|o| inside.contains(&o.component) || inside.contains(&o.parent)).map(|o| o.id).collect(); + let joints: BTreeSet = self + .assembly + .joints + .iter() + .filter(|j| occurrences.contains(&j.a.occurrence) || occurrences.contains(&j.b.occurrence)) + .map(|j| j.id) + .collect(); + let assembly = &mut self.assembly; + assembly.joints.retain(|j| !joints.contains(&j.id)); + assembly.links.retain(|l| !joints.contains(&l.a) && !joints.contains(&l.b)); + assembly.origins.retain(|o| !occurrences.contains(&o.origin.occurrence)); + for set in &mut assembly.contact_sets { + set.occurrences.retain(|o| !occurrences.contains(o)); + } + for study in &mut assembly.studies { + study.keys.retain(|k| !joints.contains(&k.joint)); + } + for view in &mut assembly.exploded { + view.moves.retain(|(o, _)| !occurrences.contains(o)); + } + self.occurrences.retain(|o| !occurrences.contains(&o.id)); + + let bodies: BTreeSet = self.body_components.iter().filter(|(_, c)| inside.contains(*c)).map(|(name, _)| name.clone()).collect(); + self.body_components.retain(|_, c| !inside.contains(c)); + self.body_offsets.retain(|name, _| !bodies.contains(name)); + self.components.retain(|c| !inside.contains(&c.id)); + self.parts.retain(|c, _| !inside.contains(c)); + self.links.retain(|c, _| !inside.contains(c)); + self.remove_configuration_components(&components); + Ok(RemovedTree { components, features, deleted, occurrences: occurrences.into_iter().collect(), joints: joints.into_iter().collect() }) + } +} diff --git a/crates/doc/src/construct.rs b/crates/doc/src/construct.rs index e5f589bb25..0f5a2d26b1 100644 --- a/crates/doc/src/construct.rs +++ b/crates/doc/src/construct.rs @@ -48,9 +48,9 @@ pub enum GeoRef { /// World X, Y or Z axis. World { axis: String }, /// A construction axis feature, by name. - Axis { name: String }, + Axis { name: crate::FeatureName }, /// A construction point feature, by name. - ConstructPoint { name: String }, + ConstructPoint { name: crate::FeatureName }, /// A sketch point (by sketch feature id and point id). SketchPoint { sketch: u64, point: String }, /// A sketch curve (by sketch feature id and curve id). diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index d88fd42061..5c7b2fe581 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -724,6 +724,11 @@ pub enum FeatureKind { /// To Object: drill down to the face (or point) here instead of `depth`. #[serde(default, skip_serializing_if = "Option::is_none")] to: Option, + /// Up to two straight edges of the face (#37): the hole's centre sits at each one's + /// distance from its edge, measured in the face's plane, and follows the edges when the + /// part changes. `position` keeps where it was placed. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + references: Vec, }, /// A thread on a cylindrical face. Cosmetic by default (no geometry change; drawn and /// exported as an annotation); `modeled` cuts the real helical ISO profile into the face. @@ -898,6 +903,34 @@ pub enum FeatureKind { #[serde(default, skip_serializing_if = "Vec::is_empty")] targets: Vec, }, + /// Structural members (#4003): one body per straight line of a frame sketch (2D sketch lines + /// and 3D sketch lines), its cross-section the profile, square to the line from end to end. + FrameMember { + path_sketch: SketchId, + /// The lines, by curve id; none: every straight line drawn in the sketch (not + /// construction, not projected or otherwise linked, not of no length). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + lines: Vec, + profile: crate::frame::MemberProfile, + /// The profile point that sits on the line. + #[serde(default, skip_serializing_if = "crate::frame::Anchor::is_origin")] + anchor: crate::frame::Anchor, + /// How far the profile turns about the line (see `frame::member_axes`). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[walk(angle = "Rotation")] + angle: Option, + /// The profile mirrored across its y axis (before it turns). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + mirror: bool, + /// What members without a name of their own are named after (`Rail`, `Rail (2)`...); none: + /// the profile's description (`Rect Tube 40x40x3`) or the profile sketch's name. + #[serde(default, skip_serializing_if = "Option::is_none")] + body_name: Option, + /// Each member's body name, by its line's id, so a member keeps its name when other lines + /// come and go (`frame::member_names`). + #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] + names: std::collections::BTreeMap, + }, /// Raise or sink profiles on a planar face, or wrap them on a selected cylindrical wall. Emboss { sketch: SketchId, @@ -1991,6 +2024,15 @@ pub enum HoleKind { }, } +/// A Single hole's reference edge (#37): a point on a straight edge of the hole's face, and the +/// distance of the hole's centre from that edge's line, in the face's plane. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)] +pub struct HoleReference { + pub edge: Position, + #[walk(length = "Reference distance")] + pub distance: Expr, +} + /// Pattern layouts. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)] #[serde(tag = "type", rename_all = "snake_case")] @@ -2176,6 +2218,7 @@ impl FeatureKind { FeatureKind::SurfaceOffset { .. } => "OffsetSurfaceFeature", FeatureKind::BoundingSolid { .. } => "BoundingSolidFeature", FeatureKind::Pipe { .. } => "PipeFeature", + FeatureKind::FrameMember { .. } => "FrameMemberFeature", FeatureKind::Emboss { .. } => "EmbossFeature", FeatureKind::Boss { .. } => "BossFeature", FeatureKind::Lip { groove: false, .. } => "LipFeature", @@ -2256,6 +2299,7 @@ impl FeatureKind { FeatureKind::SurfaceOffset { .. } => "OffsetSurface", FeatureKind::BoundingSolid { .. } => "BoundingSolid", FeatureKind::Pipe { .. } => "Pipe", + FeatureKind::FrameMember { .. } => "Member", FeatureKind::Emboss { .. } => "Emboss", FeatureKind::Boss { .. } => "Boss", FeatureKind::Lip { groove: false, .. } => "Lip", @@ -2345,6 +2389,12 @@ pub struct Feature { /// mean that numbering. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub kernel_piece_order: bool, + /// Made by inserting a file (STEP, IGES or a mesh file) into the design: the feature built + /// the component tree its component is the top of, and deleting it removes that tree + /// ([`Document::remove_component_tree`]). Not set on other imported bodies, such as the + /// independent base of a copied component. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub inserted: bool, #[serde(flatten)] pub kind: FeatureKind, } @@ -2353,7 +2403,7 @@ fn inside_str() -> String { "inside".into() } -fn all_profiles() -> ProfileSel { +pub(crate) fn all_profiles() -> ProfileSel { ProfileSel::All } @@ -2982,7 +3032,16 @@ impl Document { } }; self.components.push(Component { id, name, parent, ..Default::default() }); - self.add_occurrence(id, parent, crate::IDENTITY)?; + let occurrence = self.add_occurrence(id, parent, crate::IDENTITY)?; + // The design's first part is grounded, so joints and drags have something fixed to work + // from (#2804); later parts and copies start free. Only creation decides this: opening a + // design never grounds anything. + if parent == 0 + && !self.occurrences.iter().any(|o| o.parent == 0 && o.id != occurrence) + && let Some(o) = self.occurrences.iter_mut().find(|o| o.id == occurrence) + { + o.grounded = true; + } Ok(id) } @@ -3100,6 +3159,7 @@ impl Document { participants: Vec::new(), shell_piece_order: false, kernel_piece_order: false, + inserted: false, kind, }; self.place_feature(f); @@ -3193,11 +3253,39 @@ impl Document { self.name_feature_inputs(id); } - /// Delete a feature (and, for a sketch, the features that use it). + /// Delete a feature (and, for a sketch, the features that use it). The feature of an inserted + /// file or a linked design takes the component tree it made with it: everything in that tree + /// and every assembly record that refers to it ([`Document::remove_component_tree`]). Other + /// imported bodies (a copied component's independent base) go alone. pub fn delete_feature(&mut self, id: u64) -> Result> { - let idx = self.feature_index(id).ok_or_else(|| DocError::Unknown(format!("feature {id}")))?; - let mut gone = vec![id]; - gone.extend(self.features.iter().filter(|f| f.kind.input_sketches().contains(&id)).map(|f| f.id)); + let owned = self.feature(id).filter(|f| self.feature_owns_component(f)).map(|f| f.component); + match owned { + Some(root) => Ok(self.remove_component_tree(root)?.deleted), + None => self.delete_features(&[id]), + } + } + + /// Delete features `ids` and the features whose sketches they are. Returns every deleted id, + /// `ids` first. + pub(crate) fn delete_features(&mut self, ids: &[u64]) -> Result> { + let mut gone: Vec = Vec::with_capacity(ids.len()); + for id in ids { + if self.feature_index(*id).is_none() { + return Err(DocError::Unknown(format!("feature {id}"))); + } + if !gone.contains(id) { + gone.push(*id); + } + } + let dependants: Vec = self + .features + .iter() + .filter(|f| !gone.contains(&f.id) && f.kind.input_sketches().iter().any(|sketch| gone.contains(sketch))) + .map(|f| f.id) + .collect(); + gone.extend(dependants); + // How many of them come before the rollback marker: it keeps its place among the rest. + let before_marker = self.marker.map(|m| self.features.iter().take(m).filter(|f| gone.contains(&f.id)).count()); let gone_params: Vec = self.features.iter().filter(|f| gone.contains(&f.id)).flat_map(|f| f.param_names.clone()).collect(); self.configs .rules @@ -3229,10 +3317,8 @@ impl Document { r.cells.retain(|_| k.next().copied().unwrap_or(true)); } } - if let Some(m) = self.marker - && m > idx - { - self.marker = Some(m.saturating_sub(gone.len()).max(idx)); + if let (Some(m), Some(n)) = (self.marker, before_marker) { + self.marker = Some(m.saturating_sub(n)); } // Drop model parameters nobody references any more. self.prune_model_params(); diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 1fcdb00e67..40dbac3ce7 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -716,9 +716,11 @@ pub struct ModelState { /// Shared between states: a feature that does not change a sketch keeps it as it was. pub sketches: Vec>, pub body_counter: usize, - /// Body names the design used and must not use again (a name a `body.rename` freed, #4957). - /// The counter starts over at every evaluation, so without these a design that is evaluated - /// again after a rename hands the freed name to the next body. + /// Body names the design used and must not use again (a name a `body.rename` freed, #4957), + /// and the names stored on the suppressed features so far, so a body made while a feature is + /// suppressed keeps its name once it is back (#4302). The counter starts over at every + /// evaluation, so without these a design that is evaluated again hands such a name to the next + /// body. pub reserved_body_names: std::collections::BTreeSet, /// Sheet metal bodies (by body name) and their flat patterns. pub sheets: Vec, @@ -1476,6 +1478,7 @@ fn placement_digest(doc: &Document, f: &Feature, st: Option<&ModelState>) -> u64 FeatureKind::Sweep { path_sketch, .. } | FeatureKind::SurfaceSweep { path_sketch, .. } | FeatureKind::Pipe { path_sketch, .. } + | FeatureKind::FrameMember { path_sketch, .. } | FeatureKind::Pattern { pattern: crate::PatternKind::Path { path_sketch, .. }, .. } => Some(*path_sketch), _ => None, }; @@ -1487,7 +1490,9 @@ fn placement_digest(doc: &Document, f: &Feature, st: Option<&ModelState>) -> u64 | FeatureKind::SurfaceLoft { rails, .. } => rails, _ => &[], }; - if path.as_ref().is_some_and(wired) || rails.iter().any(|r| wired(&r.sketch)) { + // Members map their frame sketch into their own component's frame, 2D lines included. + let member = matches!(f.kind, FeatureKind::FrameMember { .. }); + if path.as_ref().is_some_and(|p| member || wired(p)) || rails.iter().any(|r| wired(&r.sketch)) { if let Some(path) = path { reads.sketch(doc, path.0); } @@ -1775,6 +1780,8 @@ impl Model { let mut prev_fp = 0u64; // The names the design must not use again travel with the state (#4957). let mut state = std::sync::Arc::new(ModelState { reserved_body_names: doc.reserved_body_names.clone(), ..(*self.empty).clone() }); + // Those and the names stored on the suppressed features so far (#4302). + let mut reserved = doc.reserved_body_names.clone(); let mut out: Vec = Vec::with_capacity(doc.features.len()); let mut reuse = !self.no_cache; let mut recomputed = 0; @@ -1801,6 +1808,9 @@ impl Model { // A state the saved model did not keep is evaluated again from the one before it; the // results after it that the model did keep still stand (#3220). let unkept = reuse && self.results.get(i).filter(current).is_some_and(|old| old.unkept); + if f.suppressed && !rolled_back { + reserved.extend(f.body_names.iter().map(|n| n.trim()).filter(|n| !n.is_empty()).map(str::to_string)); + } if reuse && !unkept && let Some(old) = self.results.get(i).filter(current) @@ -1828,6 +1838,11 @@ impl Model { }); continue; } + // Taken from the design each time: a state taken from an earlier evaluation may have + // been made before a rename freed a name or a feature was suppressed. + if state.reserved_body_names != reserved { + Arc::make_mut(&mut state).reserved_body_names.clone_from(&reserved); + } // The same feature on the same inputs: its earlier result. let key = cache_key(f, fp, fingerprint(0, f, &vals, false, &self.step_digests) ^ placement_digest(doc, f, Some(&state)), ctx, &state); let kept = self.kept.0.as_ref().filter(|_| !self.no_cache); @@ -1871,9 +1886,8 @@ impl Model { recomputed += 1; let t0 = now(); let mut next = (*state).clone(); - // The names the design must not use again travel with the state (#4957): a state - // taken from an earlier evaluation may have been made before a rename freed one. - next.reserved_body_names = doc.reserved_body_names.clone(); + // The names the design must not use again travel with the state (#4957, #4302). + next.reserved_body_names.clone_from(&reserved); for b in &mut next.bodies { b.made = crate::lineage::Made::Before; } @@ -1951,6 +1965,7 @@ impl Model { append_warning(&mut warning, note); } track_altered_sheets(&state, &mut next, f, &mut warning); + note_join_apart(&state, &next, f, &mut warning); } else { boolean_diagnostics = Default::default(); } @@ -2023,23 +2038,36 @@ impl Model { /// Follow a rename of body `old` to `new` without evaluating anything (#3044): `doc` is the /// design after the rename, which changed nothing but names, so every result keeps its /// geometry and meshes and only the body's name (and the names worked out from it) changes. + /// Later features that change the body (a pattern, a chamfer, a join) keep their results + /// too (#2467): a feature's geometry doesn't depend on its bodies' names. /// False, leaving the model as it was, when the rename can't be followed that way and the - /// design must be evaluated: the model isn't the design's, the body is changed by a later - /// feature, or is sheet metal, threaded, or named in a later feature's face references. + /// design must be evaluated: the model isn't the design's, a body of that name comes and goes + /// or isn't the same body throughout, a later body's name is worked out from it (`X (2)`), a + /// message quotes it, or it is sheet metal, threaded, or named in a later feature's face + /// references. pub fn rename_body(&mut self, doc: &Document, old: &str, new: &str) -> bool { if self.results.len() != doc.features.len() || self.results.iter().zip(&doc.features).any(|(r, f)| r.id != f.id || r.stale) { return false; } let Some(at) = self.results.iter().position(|r| r.state.body(old).is_some()) else { return false }; - let Some(made) = self.results.get(at).and_then(|r| r.state.body(old)).map(|b| Arc::as_ptr(&b.mesh)) else { return false }; - // Untouched from the feature that made it to the end, and nothing else holds its name. - let untouched = self.results.iter().skip(at).all(|r| r.state.body(old).is_some_and(|b| Arc::as_ptr(&b.mesh) == made)); + let Some(feature) = self.results.get(at).and_then(|r| r.state.body(old)).map(|b| b.feature) else { return false }; + // One body from the feature that made it until it is gone (joined into another), and no + // other body's name comes from its name. + let held: Vec = self.results.iter().skip(at).map(|r| r.state.body(old).is_some()).collect(); + let one_run = held.iter().skip_while(|h| **h).all(|h| !*h); + let derived = format!("{old} ("); + let same_body = self + .results + .iter() + .skip(at) + .all(|r| r.state.bodies.iter().all(|b| if b.name == old { b.feature == feature } else { !b.name.starts_with(&derived) })); + let quoted = self.results.iter().skip(at).any(|r| [&r.error, &r.warning].into_iter().flatten().any(|m| m.contains(old))); let named_elsewhere = self .results .iter() .any(|r| r.state.body(new).is_some() || r.state.sheets.iter().any(|x| x.body == old) || r.state.threads.iter().any(|t| t.body == old)); let faces_named = doc.features.iter().skip(at + 1).any(|f| f.face_names.iter().any(|n| n.contains(old))); - if !untouched || named_elsewhere || faces_named { + if !one_run || !same_body || quoted || named_elsewhere || faces_named { return false; } // States shared between results stay shared. @@ -2316,6 +2344,59 @@ fn track_altered_sheets(before: &ModelState, after: &mut ModelState, f: &Feature } } +/// A Join whose material meets none of its targets still joins it, to the first target as a +/// separate piece (see `apply_traced`): a valid body in several pieces, but rarely what was meant +/// when Join was left on by mistake. Says so when a feature's Join leaves the bodies it changed +/// in more pieces than they had (#4434). Combine joins the bodies it is given, apart or not. +fn note_join_apart(before: &ModelState, after: &ModelState, f: &Feature, warning: &mut Option) { + let operation = match &f.kind { + FeatureKind::Extrude { operation, .. } + | FeatureKind::Revolve { operation, .. } + | FeatureKind::Box { operation, .. } + | FeatureKind::Cylinder { operation, .. } + | FeatureKind::Sphere { operation, .. } + | FeatureKind::Torus { operation, .. } + | FeatureKind::Loft { operation, .. } + | FeatureKind::Sweep { operation, .. } + | FeatureKind::Pipe { operation, .. } + | FeatureKind::Coil { operation, .. } + | FeatureKind::BoundaryFill { operation, .. } + | FeatureKind::Thicken { operation, .. } + | FeatureKind::SurfaceNetwork { operation, .. } => *operation, + _ => return, + }; + if operation != Operation::Join { + return; + } + let pieces = |b: &Body| b.lumps().map_or(1, |l| l.len()); + // Pieces of the bodies the join changed or absorbed before it, and of those it kept after. + let (mut had, mut have) = (0usize, 0usize); + let mut grown: Vec<(&str, usize)> = Vec::new(); + for was in &before.bodies { + match after.body(&was.name) { + Some(now) if now.body.same_shape(&was.body) => {} + Some(now) => { + let (old, new) = (pieces(&was.body), pieces(&now.body)); + had += old; + have += new; + if new > old { + grown.push((now.name.as_str(), new)); + } + } + None => had += pieces(&was.body), + } + } + if have <= had { + return; + } + for (name, n) in grown { + append_warning( + warning, + format!("the joined piece doesn't touch `{name}`, so `{name}` is now in {n} separate pieces (New Body makes it a body of its own)"), + ); + } +} + /// Add tool bodies to the model according to the operation. fn sheet_boolean_unchanged(body: &Body, tools: &[Body], op: Operation) -> Result<()> { if sheet_boolean_changes(body, tools, op)? { @@ -3570,6 +3651,7 @@ fn hole_spots(vals: &BTreeMap, st: &ModelState, f: &Feature) -> R _ => { let dir = direction.normalized().ok_or_else(|| DocError::Invalid("hole direction".into()))?; let at = hole_on_surface(st, single_hole_position(vals, st, position, at, points)?, dir, &[]); + let at = with_references(vals, st, f, at, dir)?; let dir = hole_dir_into_face(st, at, dir); return Ok((vec![hole_on_surface(st, at, dir, &[])], dir)); } @@ -3617,6 +3699,7 @@ fn hole_spots_raw(vals: &BTreeMap, st: &ModelState, f: &Feature) _ => { let dir = direction.normalized().ok_or_else(|| DocError::Invalid("hole direction".into()))?; let p = single_hole_position(vals, st, position, at, points)?; + let p = if hole_references(f).is_empty() { p } else { with_references(vals, st, f, hole_on_surface(st, p, dir, &[]), dir)? }; Ok((vec![p], hole_dir_into_face(st, hole_on_surface(st, p, dir, &[]), dir))) } } @@ -3638,6 +3721,35 @@ fn single_hole_position( Ok(ss.plane.to_world(p.pos)) } +/// A hole's reference edges (#37); none for any other feature. +fn hole_references(f: &Feature) -> &[crate::HoleReference] { + match &f.kind { + FeatureKind::Hole { references, .. } => references, + _ => &[], + } +} + +/// Where a Single hole with reference edges (#37) is drilled: at its distances from the edges' +/// lines, in the plane of the face at `at` (on the surface), the edges found by their names, else +/// their points. A hole without reference edges stays at `at`. +fn with_references(vals: &BTreeMap, st: &ModelState, f: &Feature, at: Vec3, dir: Vec3) -> Result { + let references = hole_references(f); + if references.is_empty() { + return Ok(at); + } + let mut distances = Vec::with_capacity(references.len()); + for r in references { + distances.push(Document::eval_in(vals, &r.distance, Kind::Length)?); + } + let edges: Vec<(Vec3, Option<&str>)> = references.iter().enumerate().map(|(i, r)| (r.edge.0, f.edge_names.get(i).map(String::as_str))).collect(); + let frame = crate::hole_reference::reference_frame(st, at, dir, &edges, false)?; + let centre = frame.place(at, &distances)?; + if !crate::hole_reference::on_reference_face(st, &frame, centre) { + return Err(DocError::Invalid("the reference distances put the hole's centre off its face".into())); + } + Ok(centre) +} + /// Where a hole placed by position is drilled: its `at` expressions evaluated, so it follows the /// parameters (#1678), else the point it was given. fn hole_position(vals: &BTreeMap, position: &crate::FacePick, at: &Option) -> Result { @@ -5389,6 +5501,23 @@ fn path_at(segs: &[kernel::PathSeg], d: f64) -> (Vec3, Vec3) { (Vec3::ZERO, Vec3::X) } +/// Whether a copy has no material to cut in any of its eligible targets. +fn copy_misses(st: &ModelState, f: &Feature, targets: &[String], tools: &[Traced]) -> bool { + let targets = if targets.is_empty() { &f.participants } else { targets }; + let all = targets.len() == 1 && targets.first().is_some_and(|t| t == "*"); + st.bodies + .iter() + .filter(|b| all || if targets.is_empty() { b.component == f.component || b.feature == f.id } else { targets.contains(&b.name) }) + .all(|b| { + tools.iter().all(|t| { + // A diagnostic must neither reject an otherwise valid cut nor add its own + // retries to the feature's report. An inconclusive probe is not a miss. + let (intersection, _) = kernel::capture_boolean_diagnostics(|| kernel::boolean(&b.body, &t.body, BoolOp::Intersect)); + matches!(intersection, Ok(None)) + }) + }) +} + /// Apply transformed copies of the tools of `features` (patterns, mirrors). fn replay( doc: &Document, @@ -5412,6 +5541,7 @@ fn replay( format!("{list} suppressed and not copied") }); } + let (mut missed, mut total) = (0usize, 0usize); for name in features.iter().filter(|n| !suppressed.contains(n)) { // A sketch and its extrude often share a name; the feature that makes geometry wins. let src = doc @@ -5459,6 +5589,7 @@ fn replay( let present: Vec = targets.iter().filter(|t| st.bodies.iter().any(|b| &b.name == *t)).cloned().collect(); let targets = if present.is_empty() { targets } else { present }; let mut copies = Vec::new(); + let mut instances = Vec::with_capacity(mats.len()); if let FeatureKind::Hole { diameter, depth, hole, to, .. } = &src.kind { // A hole is drilled again at each copy of its spot (found on the surface there), so // a blind hole's depth counts from the face at the copy, not from the bottom of the @@ -5466,6 +5597,7 @@ fn replay( let (spots, dir) = hole_spots_raw(vals, st, src)?; for (k, m) in mats.iter().enumerate() { let d = crate::apply_vector(m, dir).normalized().ok_or_else(|| DocError::Invalid("hole direction".into()))?; + let start = copies.len(); for p in &spots { let at = hole_on_surface(st, crate::apply_point(m, *p), d, hole_scope(src)); let to = to.map(|t| crate::apply_point(m, t.0)); @@ -5477,6 +5609,7 @@ fn replay( .map(|t| Traced { lineage: t.lineage.map(|l| l.copied(src.id, k + 1)), body: t.body }), ); } + instances.push(start..copies.len()); } } else if let FeatureKind::Rib { .. } = &src.kind { // A rib is built again at each copy of its sketch, against the bodies there (#4593): @@ -5492,9 +5625,19 @@ fn replay( // Each copy's faces are named as the source feature's, with the instance. let tools = feature_tools_with_constructor(doc, vals, src, st, &mut None)?; for (k, m) in mats.iter().enumerate() { + let start = copies.len(); for t in &tools { copies.push(t.copied(kernel::transform_matrix(&t.body, *m)?, src.id, k + 1)); } + instances.push(start..copies.len()); + } + } + if op == Operation::Cut { + for instance in instances { + if let Some(tools) = copies.get(instance) { + total += 1; + missed += usize::from(copy_misses(st, f, &targets, tools)); + } } } // New bodies from a pattern join their source when they touch it (as one feature would). @@ -5507,6 +5650,9 @@ fn replay( apply_traced(st, &named, copies, op, &targets)?; } } + if missed > 0 { + append_warning(warning, format!("{missed} of {total} cut copies remove no material")); + } Ok(()) } @@ -5515,6 +5661,8 @@ mod model_cache; #[path = "eval_more.rs"] mod more; pub use model_cache::SavedModel; +#[path = "eval_frame.rs"] +pub(crate) mod frame_member; #[path = "eval_surface.rs"] mod surface; @@ -6583,6 +6731,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: } Ok(()) } + FeatureKind::FrameMember { .. } => frame_member::evaluate(doc, vals, f, st), FeatureKind::BoundingSolid { bodies, margin } => { let m = val(vals, margin, Kind::Length)?; let mut bb = solidcraft_geom::Aabb3::EMPTY; diff --git a/crates/doc/src/eval_cost_tests.rs b/crates/doc/src/eval_cost_tests.rs index 0e72f59efb..07a0aa9628 100644 --- a/crates/doc/src/eval_cost_tests.rs +++ b/crates/doc/src/eval_cost_tests.rs @@ -45,7 +45,9 @@ fn joint_face_measurements_are_shared_across_motion_samples_and_snap_kinds() { use crate::joints::{Joint, JointKind, JointOrigin, Snap, XRef}; let mut doc = Document::new("joint face work"); let part = doc.add_component(Some("Crank"), 0).unwrap(); - let occurrence = doc.occurrences.iter().find(|o| o.component == part).unwrap().id; + let occurrence = doc.occurrences.iter_mut().find(|o| o.component == part).unwrap(); + occurrence.grounded = false; + let occurrence = occurrence.id; let id = doc .add_feature( FeatureKind::Cylinder { @@ -257,3 +259,98 @@ fn an_imported_file_is_fingerprinted_by_its_text() { assert_eq!(model.last_recomputed, 1); assert_eq!(model.state().bodies.len(), 3); } + +/// A 10 mm square plate per hole along X, 4 mm thick, in component `comp`, with `holes` square +/// holes cut through it by boxes drawn in component `tools` (the plate's own, usually). The +/// plate's body name. +fn plate_with_holes(doc: &mut Document, comp: u64, tools: u64, holes: usize) -> String { + let length = format!("{}", 10 * holes.max(1)); + let kind = FeatureKind::Box { + corner: point_expr(Vec3::ZERO), + length: length.into(), + width: "10".into(), + height: "4".into(), + operation: Operation::NewBody, + }; + let plate = doc.add_feature(kind, None).unwrap(); + doc.feature_mut(plate).unwrap().component = comp; + let mut model = Model::new(); + model.evaluate(doc); + let body = model.state().bodies.iter().find(|b| b.feature == plate).unwrap().name.clone(); + for k in 0..holes { + let corner = point_expr(Vec3::new(10.0 * k as f64 + 4.0, 4.0, -1.0)); + let kind = FeatureKind::Box { corner, length: "2".into(), width: "2".into(), height: "6".into(), operation: Operation::Cut }; + let id = doc.add_feature(kind, None).unwrap(); + let f = doc.feature_mut(id).unwrap(); + f.component = tools; + f.participants = vec![body.clone()]; + } + body +} + +fn place(doc: &mut Document, comp: u64, m: Mat) { + doc.occurrences.iter_mut().find(|o| o.component == comp).unwrap().transform = m; +} + +fn volume(model: &Model, body: &str) -> f64 { + kernel::measure(&model.state().body(body).unwrap().body).unwrap().volume +} + +#[test] +fn turning_a_joint_between_parts_cuts_no_hole_again() { + let mut doc = Document::new("two parts"); + let base = doc.add_component(Some("Base"), 0).unwrap(); + let arm = doc.add_component(Some("Arm"), 0).unwrap(); + let base_plate = plate_with_holes(&mut doc, base, base, 3); + let arm_plate = plate_with_holes(&mut doc, arm, arm, 3); + place(&mut doc, arm, rigid(Vec3::new(0.0, 0.0, 10.0), Vec3::ZERO, Vec3::Z, 0.0)); + let mut model = Model::new(); + model.evaluate(&doc); + assert!(model.results.iter().all(|r| r.error.is_none()), "{:?}", model.results.iter().find(|r| r.error.is_some())); + assert!((volume(&model, &base_plate) - (1200.0 - 3.0 * 16.0)).abs() < 1e-6); + // Turning the arm (as a revolute joint does) moves it and nothing else: each part's holes + // stay cut in its own frame, so no hole is cut again. + for step in 1..=3 { + place(&mut doc, arm, rigid(Vec3::new(0.0, 0.0, 10.0), Vec3::ZERO, Vec3::Z, 0.3 * f64::from(step))); + model.evaluate(&doc); + assert_eq!(model.last_recomputed, 0, "step {step}"); + assert!((volume(&model, &arm_plate) - (1200.0 - 3.0 * 16.0)).abs() < 1e-6); + } + // Moving both parts together changes nothing between them either. + for comp in [base, arm] { + place(&mut doc, comp, rigid(Vec3::new(5.0, 0.0, 0.0), Vec3::ZERO, Vec3::Z, 0.0)); + } + model.evaluate(&doc); + assert_eq!(model.last_recomputed, 0); +} + +#[test] +fn a_tool_cutting_another_part_cuts_again_when_that_part_moves() { + let mut doc = Document::new("cross part"); + let tools = doc.add_component(Some("Tools"), 0).unwrap(); + let plate = doc.add_component(Some("Plate"), 0).unwrap(); + let other = doc.add_component(Some("Other"), 0).unwrap(); + // The other part first, so the cut across parts is the last feature: it alone is evaluated + // again when it changes. + plate_with_holes(&mut doc, other, other, 1); + let body = plate_with_holes(&mut doc, plate, tools, 1); + let mut model = Model::new(); + model.evaluate(&doc); + assert!(model.results.iter().all(|r| r.error.is_none()), "{:?}", model.results.iter().find(|r| r.error.is_some())); + assert!((volume(&model, &body) - (400.0 - 16.0)).abs() < 1e-6); + // An unrelated part moving: the cut keeps its result. + place(&mut doc, other, rigid(Vec3::new(0.0, 40.0, 0.0), Vec3::ZERO, Vec3::Z, 0.0)); + model.evaluate(&doc); + assert_eq!(model.last_recomputed, 0); + // The plate moving away from the tool by 5 mm: the hole is cut again where the tool now meets + // it, half of it on the plate. + place(&mut doc, plate, rigid(Vec3::new(5.0, 0.0, 0.0), Vec3::ZERO, Vec3::Z, 0.0)); + model.evaluate(&doc); + assert_eq!(model.last_recomputed, 1); + assert!((volume(&model, &body) - (400.0 - 8.0)).abs() < 1e-6, "{}", volume(&model, &body)); + // The tool's part following it: the hole is where it was at first, the first result again. + place(&mut doc, tools, rigid(Vec3::new(5.0, 0.0, 0.0), Vec3::ZERO, Vec3::Z, 0.0)); + model.evaluate(&doc); + assert_eq!(model.last_recomputed, 0); + assert!((volume(&model, &body) - (400.0 - 16.0)).abs() < 1e-6, "{}", volume(&model, &body)); +} diff --git a/crates/doc/src/eval_frame.rs b/crates/doc/src/eval_frame.rs new file mode 100644 index 0000000000..35ab2b0fcc --- /dev/null +++ b/crates/doc/src/eval_frame.rs @@ -0,0 +1,243 @@ +//! Frame members (#4003): one body per straight line of a frame sketch, its cross-section a +//! profile laid square to the line (`frame::member_axes`) and run from one end to the other. + +use std::borrow::Cow; +use std::collections::{BTreeMap, HashSet}; + +use solidcraft_geom::{Aabb2, Plane, Region2, Ring, Vec2, Vec3}; +use solidcraft_kernel as kernel; +use solidcraft_sketch::CurveKind; + +use super::{ModelBody, ModelState, SolvedSketch, new_name, path_in_frame, select_profiles, sketch_in_feature_frame, val}; +use crate::expr::{Kind, Value}; +use crate::frame::{self, MAX_MEMBERS, MemberProfile}; +use crate::{DocError, Document, Feature, FeatureKind, ProfileSel, Result}; + +/// Most profiles a profile sketch may have for its material to be worked out. +const MAX_PROFILE_PIECES: usize = 256; +/// Shortest member (mm). +const MIN_LENGTH: f64 = 1e-6; +/// How far (mm) a 3D sketch curve may stray from the line between its ends and still be a line. +const STRAIGHT_TOL: f64 = 1e-3; + +/// Evaluate a `FrameMember` feature: one new body per line, each named +/// ([`member_names`], or by position for a copy whose `body_names` were set). +pub(super) fn evaluate(doc: &Document, vals: &BTreeMap, f: &Feature, st: &mut ModelState) -> Result<()> { + let FeatureKind::FrameMember { path_sketch, lines, profile, anchor, angle, mirror, body_name, names } = &f.kind else { + return Err(DocError::Invalid("not a frame member feature".into())); + }; + let ps = st.sketch(path_sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {path_sketch} (it must come earlier in the timeline)")))?; + // The frame sketch in this feature's component frame: its plane, and its 3D lines. + let mapped = + if ps.sketch.wires.is_empty() { Cow::Owned(sketch_in_feature_frame(doc, ps, f.component)?) } else { path_in_frame(doc, ps, f.component)? }; + let segments = member_lines(mapped.as_ref(), lines)?; + let (mut regions, description) = profile_regions(vals, st, profile)?; + if *mirror { + regions = regions.iter().map(frame::mirrored).collect(); + } + let at = frame::anchor_point(®ions, *anchor); + let turn = match angle { + Some(e) => val(vals, e, Kind::Angle)?, + None => 0.0, + }; + let mut made = Vec::with_capacity(segments.len()); + for (id, a, b) in &segments { + let (x, y) = frame::member_axes(*b - *a, turn).ok_or_else(|| DocError::Invalid(format!("line `{id}` has no length")))?; + let plane = Plane::new(*a - x * at.x - y * at.y, x, y).ok_or_else(|| DocError::Invalid(format!("line `{id}`: no plane square to it")))?; + let mut bodies = kernel::extrude(&plane, ®ions, 0.0, a.dist(*b))?; + let body = match (bodies.pop(), bodies.is_empty()) { + (Some(body), true) => body, + _ => return Err(DocError::Invalid(format!("the member on line `{id}` is not one solid"))), + }; + made.push(crate::lineage::numbered_tool(body)); + } + if !f.body_names.is_empty() { + // A copy (pasted, or in a copied component) names its members by position, as any + // feature's bodies. + for (k, t) in made.into_iter().enumerate() { + let name = new_name(st, f, k); + st.bodies.push(ModelBody::traced_new(name, t, f.id)); + } + return Ok(()); + } + let base = body_name.as_deref().map(str::trim).filter(|n| !n.is_empty()).map_or(description, str::to_string); + let ids: Vec = segments.into_iter().map(|(id, _, _)| id).collect(); + for (name, t) in member_names(st, &ids, names, &base).into_iter().zip(made) { + st.bodies.push(ModelBody::traced_new(name, t, f.id)); + } + Ok(()) +} + +/// The body name of the member on each of `lines`: its own name from `pinned` (by line id) when no +/// other body has it, else the first free one of `base`, `base (2)`, `base (3)`… that no line has +/// pinned. A member therefore keeps its name when other lines are added, deleted or reordered. +pub fn member_names(st: &ModelState, lines: &[String], pinned: &BTreeMap, base: &str) -> Vec { + let reserved: HashSet<&str> = pinned.values().map(String::as_str).collect(); + let mut used: HashSet = HashSet::new(); + let mut out: Vec> = lines + .iter() + .map(|line| { + let n = pinned.get(line).map(|n| n.trim()).filter(|n| !n.is_empty() && st.body(n).is_none())?; + used.insert(n.to_string()).then(|| n.to_string()) + }) + .collect(); + let base = if base.trim().is_empty() { "Member" } else { base.trim() }; + // Each candidate is refused at most once per body, used or reserved name: past that many, + // a free one has been found for every member. + let limit = st.bodies.len() + reserved.len() + lines.len() + 2; + let mut next = 1usize; + for slot in out.iter_mut().filter(|n| n.is_none()) { + while next <= limit { + let candidate = if next == 1 { base.to_string() } else { format!("{base} ({next})") }; + next += 1; + if st.body(&candidate).is_none() && !used.contains(&candidate) && !reserved.contains(candidate.as_str()) { + used.insert(candidate.clone()); + *slot = Some(candidate); + break; + } + } + } + out.into_iter().map(|n| n.unwrap_or_else(|| base.to_string())).collect() +} + +/// The ids of a `FrameMember` feature's lines in `st`, in the order its members are made. +pub fn member_line_ids(st: &ModelState, kind: &FeatureKind) -> Result> { + let FeatureKind::FrameMember { path_sketch, lines, .. } = kind else { + return Err(DocError::Invalid("not a frame member feature".into())); + }; + let ps = st.sketch(path_sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {path_sketch}")))?; + Ok(member_lines(ps, lines)?.into_iter().map(|(id, _, _)| id).collect()) +} + +/// The member lines of the frame sketch, each as (curve id, start, end) in its frame: the lines +/// asked for, or every straight line drawn in it (not construction, not projected or otherwise +/// linked, and with a length). +fn member_lines(ss: &SolvedSketch, ids: &[String]) -> Result> { + let sk = &ss.sketch; + let mut out = Vec::new(); + if ids.is_empty() { + for (ci, c) in sk.curves.iter().enumerate() { + if !c.construction + && !c.centerline + && c.link.is_none() + && matches!(c.kind, CurveKind::Line { .. }) + && let Some((a, b)) = planar_line(ss, ci) + && a.dist(b) > MIN_LENGTH + { + out.push((c.id.clone(), a, b)); + } + } + for w in sk.wires.iter().filter(|w| w.link.is_none()) { + if let Some((a, b)) = straight(&w.pts) + && a.dist(b) > MIN_LENGTH + { + out.push((w.id.clone(), a, b)); + } + } + if out.is_empty() { + return Err(DocError::Invalid(format!("{} has no lines for members", ss.name))); + } + if out.len() > MAX_MEMBERS { + return Err(DocError::Invalid(format!("at most {MAX_MEMBERS} members in one feature (pick the lines)"))); + } + return Ok(out); + } + if ids.len() > MAX_MEMBERS { + return Err(DocError::Invalid(format!("at most {MAX_MEMBERS} members in one feature"))); + } + let mut seen = HashSet::new(); + for id in ids { + if !seen.insert(id.as_str()) { + return Err(DocError::Invalid(format!("line `{id}` is listed twice"))); + } + let line = if let Some(wi) = sk.wire_index(id) { + sk.wires.get(wi).and_then(|w| straight(&w.pts)) + } else { + let ci = sk.curve_index(id).ok_or_else(|| DocError::Unknown(format!("line `{id}` in {}", ss.name)))?; + planar_line(ss, ci) + }; + let (a, b) = line.ok_or_else(|| DocError::Invalid(format!("`{id}` is not a straight line (members run along lines)")))?; + if !(a.dist(b) > MIN_LENGTH) { + return Err(DocError::Invalid(format!("line `{id}` has no length"))); + } + out.push((id.clone(), a, b)); + } + Ok(out) +} + +/// A sketch line's ends in the sketch's frame. +fn planar_line(ss: &SolvedSketch, ci: usize) -> Option<(Vec3, Vec3)> { + let CurveKind::Line { a, b } = ss.sketch.curves.get(ci)?.kind else { return None }; + let (pa, pb) = (ss.sketch.points.get(a)?.pos, ss.sketch.points.get(b)?.pos); + Some((ss.plane.to_world(pa), ss.plane.to_world(pb))) +} + +/// The ends of a 3D polyline that runs straight from its first point to its last: every point +/// within [`STRAIGHT_TOL`] of the line between them, none behind the start, past the end or back +/// from a point before it. +fn straight(pts: &[Vec3]) -> Option<(Vec3, Vec3)> { + let (a, b) = (*pts.first()?, *pts.last()?); + let (len, d) = (a.dist(b), (b - a).normalized()?); + let mut reached = 0.0_f64; + for p in pts { + let q = *p - a; + let t = q.dot(d); + if (q - d * t).len() > STRAIGHT_TOL || t < reached - STRAIGHT_TOL || t > len + STRAIGHT_TOL { + return None; + } + reached = reached.max(t); + } + Some((a, b)) +} + +/// The regions of a member's profile, in the profile's own coordinates: one piece of material, +/// holes and all; and what its members are named after when they have no name of their own. +fn profile_regions(vals: &BTreeMap, st: &ModelState, profile: &MemberProfile) -> Result<(Vec, String)> { + let (regions, description) = match profile { + MemberProfile::Library { profile } => { + let p = profile.resolve(|name, e| val(vals, e, Kind::Length).map_err(|err| DocError::Invalid(format!("profile `{name}`: {err}"))))?; + (vec![p.region()?], p.description()) + } + MemberProfile::Sketch { sketch, profiles } => { + let ss = st.sketch(sketch.0).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?; + let picked = match profiles { + ProfileSel::All => material(ss)?, + sel => select_profiles(ss, sel)?, + }; + (solidcraft_sketch::merge_regions(&picked), ss.name.clone()) + } + }; + match regions.len() { + 1 => Ok((regions, description)), + 0 => Err(DocError::Invalid("the member profile has no area".into())), + n => Err(DocError::Invalid(format!("the member profile is {n} separate pieces (pick one with `profiles`)"))), + } +} + +/// The material of a profile sketch: of what was drawn (as `solid.extrude` takes all profiles: +/// projected reference geometry only when the drawn curves make no profile, letters without +/// their counters filled in), each profile inside an even number of other profiles' outlines, +/// so nested loops alternate between material and hole. +fn material(ss: &SolvedSketch) -> Result> { + let drawn = solidcraft_sketch::find_drawn_profiles(&ss.sketch); + let ps = solidcraft_sketch::without_text_counters(&ss.sketch, if drawn.is_empty() { ss.profiles.clone() } else { drawn }); + if ps.is_empty() { + return Err(DocError::Invalid(format!("{} has no closed profiles", ss.name))); + } + if ps.len() > MAX_PROFILE_PIECES { + return Err(DocError::Invalid(format!("{} has more than {MAX_PROFILE_PIECES} profiles (pick the member's with `profiles`)", ss.name))); + } + // Outer polylines and their boxes, once. + let polys: Vec> = ps.iter().map(|p| p.region.outer.polyline(1e-3)).collect(); + let boxes: Vec = polys.iter().map(|poly| poly.bounds()).collect(); + Ok(ps + .iter() + .enumerate() + .filter(|(i, p)| { + let at = p.region.interior_point(); + let depth = polys.iter().zip(&boxes).enumerate().filter(|(j, (poly, bb))| j != i && bb.contains(at) && poly.contains(at)).count(); + depth % 2 == 0 + }) + .map(|(_, p)| p.region.clone()) + .collect()) +} diff --git a/crates/doc/src/feature_refs.rs b/crates/doc/src/feature_refs.rs index 8d57e518de..48ed05b2b0 100644 --- a/crates/doc/src/feature_refs.rs +++ b/crates/doc/src/feature_refs.rs @@ -69,3 +69,29 @@ impl FeatureKind { }); } } + +impl crate::Document { + /// Rename feature `old` to `new` where the design names it outside the features' own + /// references (`remap_refs`): sketch projections of a construction plane and the canvases + /// placed on it (#4353). (The features are the rename command's to change.) + pub fn rename_feature_keys(&mut self, old: &str, new: &str) { + let names = BTreeMap::from([(old.to_string(), new.to_string())]); + let (ids, bodies) = (BTreeMap::new(), BTreeMap::new()); + for f in &mut self.features { + if let FeatureKind::Sketch { sketch, .. } = &mut f.kind { + for link in &mut sketch.links { + crate::clipboard::remap_link(&mut link.source, &ids, &names, &bodies, 0); + } + } + } + for c in &mut self.canvases { + c.plane.walk_refs_mut(&mut |r| { + if let Ref::Feature(name) = r + && name.as_ref() == old + { + *name = FeatureName(new.to_string()); + } + }); + } + } +} diff --git a/crates/doc/src/feature_walk_tests.rs b/crates/doc/src/feature_walk_tests.rs index 2db17da0c7..c31525f6a3 100644 --- a/crates/doc/src/feature_walk_tests.rs +++ b/crates/doc/src/feature_walk_tests.rs @@ -177,6 +177,85 @@ fn references_match_the_golden_record() { check_golden("golden_refs.txt", GOLDEN_REFS, &ref_record()); } +#[test] +fn construction_names_follow_renames_in_every_definition() { + use crate::walk::{Ref, Walk}; + use serde_json::json; + + let axis = json!({"type": "axis", "name": "Old"}); + let point = json!({"type": "construct_point", "name": "Old"}); + let plane = json!({"type": "plane", "plane": {"type": "construction", "name": "Old"}}); + let definitions = [ + json!({"type": "construction_axis", "def": {"type": "through_face", "face": axis}}), + json!({"type": "construction_axis", "def": {"type": "normal_to_face", "face": plane, "point": point}}), + json!({"type": "construction_axis", "def": {"type": "two_planes", "a": plane, "b": plane}}), + json!({"type": "construction_axis", "def": {"type": "two_points", "a": point, "b": point}}), + json!({"type": "construction_axis", "def": {"type": "edge", "edge": axis}}), + json!({"type": "construction_point", "def": {"type": "at", "point": point}}), + json!({"type": "construction_point", "def": {"type": "two_edges", "a": axis, "b": axis}}), + json!({"type": "construction_point", "def": {"type": "three_planes", "a": plane, "b": plane, "c": plane}}), + json!({"type": "construction_point", "def": {"type": "center", "of": axis}}), + json!({"type": "construction_point", "def": {"type": "edge_and_plane", "edge": axis, "plane": plane}}), + json!({"type": "construction_point", "def": {"type": "along_path", "path": axis, "t": "0.5"}}), + ]; + let planes = [ + json!({"type": "midplane", "a": plane, "b": plane}), + json!({"type": "two_edges", "a": axis, "b": axis}), + json!({"type": "three_points", "a": point, "b": point, "c": point}), + json!({"type": "tangent", "face": plane, "at": [0, 0, 0]}), + json!({"type": "along_path", "path": axis, "t": "0.5"}), + json!({"type": "perpendicular", "plane": plane, "line": axis}), + ]; + let wrapped = planes.into_iter().map(|plane| { + json!({"type": "construction_plane", "plane": {"type": "flipped", "base": { + "type": "offset", "distance": "10", "base": {"type": "at_angle", "angle": "20 deg", + "axis_origin": [0, 0, 0], "axis_dir": [1, 0, 0], "base": plane}}}}) + }); + let names = BTreeMap::from([("Old".into(), "New".into()), ("New".into(), "Wrong".into())]); + for definition in definitions.into_iter().chain(wrapped) { + let mut kind: FeatureKind = serde_json::from_value(definition.clone()).unwrap(); + let expected_count = definition.to_string().matches("\"Old\"").count(); + let mut seen = Vec::new(); + kind.walk_refs(&mut |r| { + if let Ref::Feature(name) = r { + seen.push(name.to_string()); + } + }); + assert_eq!(seen, vec!["Old"; expected_count], "{definition}"); + kind.remap_refs(&BTreeMap::new(), &names); + let expected: serde_json::Value = serde_json::from_str(&definition.to_string().replace("\"Old\"", "\"New\"")).unwrap(); + assert_eq!(serde_json::to_value(kind).unwrap(), serde_json::to_value(serde_json::from_value::(expected).unwrap()).unwrap()); + } +} + +#[test] +fn construction_reference_walk_keeps_its_depth_limit_across_geometry() { + use crate::construct::GeoRef; + use crate::walk::{Ref, Walk}; + + let mut plane = crate::PlaneRef::Construction { name: "TooDeep".into() }; + for _ in 0..80 { + plane = crate::PlaneRef::Perpendicular { plane: Box::new(GeoRef::Plane { plane }), line: Box::new(GeoRef::Axis { name: "Side".into() }) }; + } + let mut seen = Vec::new(); + plane.walk_refs(&mut |r| { + if let Ref::Feature(name) = r { + seen.push(name.to_string()); + } + }); + assert!(!seen.is_empty() && seen.len() < 64); + assert!(seen.iter().all(|name| name == "Side")); + let mut changed = 0; + plane.walk_refs_mut(&mut |r| { + if let Ref::Feature(name) = r { + assert_eq!(name.as_ref(), "Side"); + *name = "Renamed".into(); + changed += 1; + } + }); + assert_eq!(changed, seen.len()); +} + #[test] fn inputs_and_expressions_match_the_golden_record() { check_golden("golden_exprs.txt", GOLDEN_EXPRS, &record()); diff --git a/crates/doc/src/feature_walk_tests/fixtures.rs b/crates/doc/src/feature_walk_tests/fixtures.rs index 1e262b68ca..651576f648 100644 --- a/crates/doc/src/feature_walk_tests/fixtures.rs +++ b/crates/doc/src/feature_walk_tests/fixtures.rs @@ -72,6 +72,11 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> { ("hole_drilled", hole(json!({"type": "drilled", "tip_angle": "hole.tip_angle"}))), ("hole_counterbore", hole(json!({"type": "counterbore", "cb_diameter": "hole.cb_diameter", "cb_depth": "hole.cb_depth"}))), ("hole_countersink", hole(json!({"type": "countersink", "cs_diameter": "hole.cs_diameter", "cs_angle": "hole.cs_angle"}))), + ("hole_references", { + let mut h = hole(json!({"type": "simple"})); + h["references"] = json!([{"edge": o, "distance": "hole.reference1"}, {"edge": o, "distance": "hole.reference2"}]); + h + }), ("thread", json!({"type": "thread", "face": o, "length": "thread.length"})), ("loft", json!({"type": "loft", "sections": []})), ("sweep", json!({"type": "sweep", "sketch": 1, "profiles": {"type": "all"}, "path_sketch": 2, "path": ["l1"]})), @@ -103,6 +108,16 @@ pub(super) fn fixtures() -> Vec<(&'static str, Value)> { ("delete_face", json!({"type": "delete_face", "faces": [o]})), ("bounding_solid", json!({"type": "bounding_solid", "bodies": ["B"], "margin": "bounding.margin"})), ("pipe", json!({"type": "pipe", "path_sketch": 1, "path": ["l1"], "diameter": "pipe.diameter", "wall": "pipe.wall"})), + ( + "frame_member", + json!({"type": "frame_member", "path_sketch": 1, "lines": ["l1"], + "profile": {"source": "library", "profile": {"kind": "rect_tube", "width": 40, "height": 40, "wall": 3}}, "angle": "frame_member.angle"}), + ), + ( + "frame_member_sketch", + json!({"type": "frame_member", "path_sketch": 1, "profile": {"source": "sketch", "sketch": 2, "profiles": {"type": "all"}}, + "anchor": "bottom_left", "mirror": true}), + ), ("emboss", json!({"type": "emboss", "sketch": 1, "profiles": {"type": "all"}, "depth": "emboss.depth"})), ("rib", json!({"type": "rib", "sketch": 1, "curves": ["l1"], "thickness": "rib.thickness", "depth": "rib.depth"})), ("replace_face", json!({"type": "replace_face", "faces": [o], "target": plane("replace_face")})), @@ -262,6 +277,9 @@ pub(super) fn with_points(name: &str, mut v: Value) -> Value { "surface_revolve" => json!({"axis": line}), "hole_simple" => json!({"points": null, "to": p(4.0)}), "hole_drilled" | "hole_counterbore" | "hole_countersink" => json!({"to": p(4.0)}), + "hole_references" => { + json!({"references": [{"edge": p(11.0), "distance": "hole.reference1"}, {"edge": p(12.0), "distance": "hole.reference2"}]}) + } "emboss" => json!({"face": {"body": "B", "at": p(5.0)}}), "sheet_convert" => json!({"bends": [p(6.0)]}), "sheet_fold" => json!({"fixed": p(7.0)}), diff --git a/crates/doc/src/feature_walk_tests/golden_exprs.txt b/crates/doc/src/feature_walk_tests/golden_exprs.txt index 5a4f9da2e3..ff4ba3a820 100644 --- a/crates/doc/src/feature_walk_tests/golden_exprs.txt +++ b/crates/doc/src/feature_walk_tests/golden_exprs.txt @@ -130,6 +130,13 @@ hole_countersink Countersink diameter = hole.cs_diameter (Length) Countersink angle = hole.cs_angle (Angle) expressions: hole.diameter, hole.depth, hole.cs_diameter, hole.cs_angle +hole_references + inputs: + Diameter = hole.diameter (Length) + Depth = hole.depth (Length) + Reference distance = hole.reference1 (Length) + Reference distance = hole.reference2 (Length) + expressions: hole.diameter, hole.depth, hole.reference1, hole.reference2 thread inputs: Length = thread.length (Length) @@ -212,6 +219,14 @@ pipe Diameter = pipe.diameter (Length) Wall = pipe.wall (Length) expressions: pipe.diameter, pipe.wall +frame_member + inputs: + Height = 40 (Length) + Wall = 3 (Length) + Width = 40 (Length) + Rotation = frame_member.angle (Angle) + expressions: 40, 3, 40, frame_member.angle +frame_member_sketch emboss inputs: Depth = emboss.depth (Length) diff --git a/crates/doc/src/feature_walk_tests/golden_geometry.txt b/crates/doc/src/feature_walk_tests/golden_geometry.txt index 9d223eb924..f33a46bfa0 100644 --- a/crates/doc/src/feature_walk_tests/golden_geometry.txt +++ b/crates/doc/src/feature_walk_tests/golden_geometry.txt @@ -56,6 +56,9 @@ hole_counterbore hole_countersink moves: direction[0], direction[2], position[0], position[1], position[2], to[0], to[1], to[2] face points: [4, 4.5, 4.25] +hole_references + moves: direction[0], direction[2], position[0], position[1], position[2], references[0].edge[0], references[0].edge[1], references[0].edge[2], references[1].edge[0], references[1].edge[1], references[1].edge[2] + face points: [0, 0, 0] thread moves: face[0], face[1], face[2] face points: [0, 0, 0] @@ -97,6 +100,9 @@ delete_face face points: [0, 0, 0] bounding_solid pipe +frame_member +frame_member_sketch + profiles: 2 all emboss face points: [5, 5.5, 5.25] profiles: 1 all diff --git a/crates/doc/src/feature_walk_tests/golden_refs.txt b/crates/doc/src/feature_walk_tests/golden_refs.txt index e6c3fcbdf4..8f30912bf3 100644 --- a/crates/doc/src/feature_walk_tests/golden_refs.txt +++ b/crates/doc/src/feature_walk_tests/golden_refs.txt @@ -58,6 +58,7 @@ hole_counterbore hole_countersink sketches: [3] remap_refs: points.sketch +hole_references thread loft sketches: [4, 5, 6] @@ -86,7 +87,9 @@ split_line remap_refs: plane.base.base.name, sketch remap_bodies: body construction_axis + remap_refs: def.a.plane.base.base.name, def.b.plane.base.base.name construction_point_planes + remap_refs: def.a.plane.base.base.name, def.b.plane.base.base.name, def.c.plane.base.base.name construction_point_along split_face sketches: [6] @@ -104,6 +107,12 @@ pipe sketches: [1] remap_refs: path_sketch remap_bodies: targets[0] +frame_member + sketches: [1] + remap_refs: path_sketch +frame_member_sketch + sketches: [1, 2] + remap_refs: path_sketch, profile.sketch emboss sketches: [1] remap_refs: sketch diff --git a/crates/doc/src/format/pending/frame-members.rs b/crates/doc/src/format/pending/frame-members.rs new file mode 100644 index 0000000000..fee7d53ba3 --- /dev/null +++ b/crates/doc/src/format/pending/frame-members.rs @@ -0,0 +1,7 @@ +//! A design can hold frame members (#4003): a `frame_member` feature with its frame sketch +//! (`path_sketch`), the `lines` it runs along (none: every straight line drawn in it), its +//! `profile` (a built-in profile by `source: library`, its dimensions saved as expressions, or a +//! profile sketch by `source: sketch`), the profile's `anchor`, `angle` and `mirror`, and its +//! members' names: `body_name` and `names` (each member's body name by its line's id). Older +//! designs need no transformation: they have no such feature. The last release refuses a design +//! saved with this (the newer format number). diff --git a/crates/doc/src/format/pending/hole-reference-edges.rs b/crates/doc/src/format/pending/hole-reference-edges.rs new file mode 100644 index 0000000000..9a51c1e450 --- /dev/null +++ b/crates/doc/src/format/pending/hole-reference-edges.rs @@ -0,0 +1,6 @@ +//! A Single hole can be placed by distances from up to two straight edges of its face +//! (`references`: each `{"edge": [x, y, z], "distance": "…"}`, the edges' persistent names in the +//! feature's `edge_names`, #37): it is drilled where those distances hold, measured in the face's +//! plane, and follows the edges when the part changes. Older designs need no transformation: a +//! hole without `references` stays at its position, as before; 0.21.x asks for an update instead +//! of drilling a referenced hole where it was first placed. diff --git a/crates/doc/src/format/pending/inserted-file-features.rs b/crates/doc/src/format/pending/inserted-file-features.rs new file mode 100644 index 0000000000..17c1719650 --- /dev/null +++ b/crates/doc/src/format/pending/inserted-file-features.rs @@ -0,0 +1,52 @@ +//! The feature of a file inserted into a design (STEP, IGES or a mesh file) is marked `inserted`: +//! it made the component tree at whose top it sits, and deleting it removes that tree with it. +//! Older designs are rewritten as they open: an import or mesh import inside a component is +//! marked, except the independent base of a copied component, which goes alone as before. The +//! last release refuses a design saved with this (the newer format number). +use serde_json::Value; + +pub fn step(v: &mut Value) -> crate::Result<()> { + let Some(Value::Array(features)) = v.get_mut("features") else { + return Ok(()); + }; + for feature in features { + let Some(object) = feature.as_object_mut() else { continue }; + let in_component = object.get("component").and_then(Value::as_u64).is_some_and(|c| c != 0); + let inserted = match object.get("type").and_then(Value::as_str) { + Some("mesh_import") => true, + // A copied component's independent base: a body re-exported on its own, no file tree. + Some("import") => { + let base = object.get("file").and_then(Value::as_str).is_some_and(|f| f.ends_with(" independent base")) + && object.get("components").and_then(Value::as_array).is_none_or(Vec::is_empty); + !base + } + _ => false, + }; + if in_component && inserted { + object.insert("inserted".into(), Value::Bool(true)); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + #[test] + fn older_inserted_files_are_marked_but_copied_bases_are_not() { + let mut v = json!({"features": [ + {"type": "import", "file": "parts.step", "component": 2}, + {"type": "mesh_import", "file": "bracket.stl", "component": 5}, + {"type": "import", "file": "Block independent base", "component": 7}, + {"type": "import", "file": "opened.step"}, + {"type": "extrude", "component": 2} + ]}); + super::step(&mut v).unwrap(); + let marked: Vec = v["features"].as_array().unwrap().iter().map(|f| f["inserted"] == json!(true)).collect(); + assert_eq!(marked, [true, true, false, false, false]); + let mut empty = json!({"name": "Untitled"}); + super::step(&mut empty).unwrap(); + assert_eq!(empty, json!({"name": "Untitled"})); + } +} diff --git a/crates/doc/src/frame.rs b/crates/doc/src/frame.rs new file mode 100644 index 0000000000..6040478177 --- /dev/null +++ b/crates/doc/src/frame.rs @@ -0,0 +1,760 @@ +//! Frames: the profile library structural members are made of (#4003). +//! +//! A profile is a closed cross-section, an outline and its holes, built in code from a few +//! dimensions ([`Profile`]). Every built-in profile is SolidCraft's own parametric definition: +//! no vendor's or standard's size table is shipped. A dimension left out takes a default worked +//! out from the others, so `{"kind": "rect_tube", "width": 40, "height": 40, "wall": 3}` is a +//! complete profile. Any other section (a vendor's extrusion, say) comes from a sketch. +//! +//! Features and commands hold a profile as a [`ProfileSpec`]: each dimension an expression over +//! the design's parameters, worked out into a [`Profile`] when the feature is evaluated. +//! +//! Coordinates are millimetres in the profile's own plane, x across and y up, with the centre +//! of the profile's bounding box at the origin. The outline runs counter-clockwise and holes +//! clockwise, as [`Region2`] expects. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use solidcraft_geom::{Loop2, Region2, Seg2, Vec2, Vec3}; + +use crate::expr::Kind; +use crate::{DocError, Expr, Result}; + +#[cfg(test)] +#[path = "frame_tests.rs"] +mod tests; + +/// Most members one frame member feature makes. +pub const MAX_MEMBERS: usize = 1000; +/// The largest profile dimension (mm). +pub const MAX_PROFILE_SIZE: f64 = 10_000.0; +/// The smallest profile dimension (mm): walls and sides thinner than this are refused, and so +/// are radii smaller than this but not 0. +pub const MIN_PROFILE_DIMENSION: f64 = 0.01; +/// How far from vertical (radians) a member's line may lean and still count as vertical for its +/// orientation ([`member_axes`]). +pub const VERTICAL_TOLERANCE: f64 = 1e-6; + +/// A built-in profile and its dimensions (mm). `Option` dimensions take a default worked out from +/// the profile's size ([`Profile::filled`] says which). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum Profile { + /// A solid rectangle (flat bar, square bar), its corners optionally rounded. + Bar { + width: f64, + height: f64, + #[serde(default, skip_serializing_if = "Option::is_none")] + corner_radius: Option, + }, + /// A solid circle. + RoundBar { diameter: f64 }, + /// A rectangular or square hollow section. The outer corner radius defaults to twice the + /// wall; the inner radius is the outer one less the wall. + RectTube { + width: f64, + height: f64, + wall: f64, + #[serde(default, skip_serializing_if = "Option::is_none")] + outer_radius: Option, + }, + /// A round hollow section (pipe or tube). + RoundTube { diameter: f64, wall: f64 }, + /// An L angle: one leg `width` along x, the other `height` along y, both `thickness` thick, + /// the heel at the bottom left. The root radius defaults to the thickness, the toe radius to + /// half of it, both made smaller where the legs leave less room. + Angle { + width: f64, + height: f64, + thickness: f64, + #[serde(default, skip_serializing_if = "Option::is_none")] + root_radius: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + toe_radius: Option, + }, + /// A channel with parallel flanges, open towards +x: the web `web` thick along the left side, + /// flanges `width` long and `flange` thick. The root radius defaults to the web thickness (less + /// where the flanges or the web leave less room), the toe radius to none. + Channel { + height: f64, + width: f64, + web: f64, + flange: f64, + #[serde(default, skip_serializing_if = "Option::is_none")] + root_radius: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + toe_radius: Option, + }, + /// An I or H beam with parallel flanges. The root radius defaults to the web thickness (less + /// where the flanges or the web leave less room). + IBeam { + height: f64, + width: f64, + web: f64, + flange: f64, + #[serde(default, skip_serializing_if = "Option::is_none")] + root_radius: Option, + }, +} + +/// One built-in profile kind, for listing. +#[derive(Clone, Debug, Serialize)] +pub struct ProfileKind { + pub kind: &'static str, + pub label: &'static str, + /// The dimensions it needs. + pub required: &'static [&'static str], + /// The dimensions that default when left out. + pub optional: &'static [&'static str], + /// A complete example, as JSON. + pub example: Profile, +} + +/// Every built-in profile kind. +pub fn profile_kinds() -> Vec { + vec![ + ProfileKind { + kind: "bar", + label: "Bar", + required: &["width", "height"], + optional: &["corner_radius"], + example: Profile::Bar { width: 40.0, height: 10.0, corner_radius: None }, + }, + ProfileKind { kind: "round_bar", label: "Round Bar", required: &["diameter"], optional: &[], example: Profile::RoundBar { diameter: 20.0 } }, + ProfileKind { + kind: "rect_tube", + label: "Rectangular Tube", + required: &["width", "height", "wall"], + optional: &["outer_radius"], + example: Profile::RectTube { width: 40.0, height: 40.0, wall: 3.0, outer_radius: None }, + }, + ProfileKind { + kind: "round_tube", + label: "Round Tube", + required: &["diameter", "wall"], + optional: &[], + example: Profile::RoundTube { diameter: 42.4, wall: 3.2 }, + }, + ProfileKind { + kind: "angle", + label: "L-Angle", + required: &["width", "height", "thickness"], + optional: &["root_radius", "toe_radius"], + example: Profile::Angle { width: 50.0, height: 50.0, thickness: 5.0, root_radius: None, toe_radius: None }, + }, + ProfileKind { + kind: "channel", + label: "Channel", + required: &["height", "width", "web", "flange"], + optional: &["root_radius", "toe_radius"], + example: Profile::Channel { height: 100.0, width: 50.0, web: 6.0, flange: 8.0, root_radius: None, toe_radius: None }, + }, + ProfileKind { + kind: "i_beam", + label: "I-Beam", + required: &["height", "width", "web", "flange"], + optional: &["root_radius"], + example: Profile::IBeam { height: 100.0, width: 50.0, web: 4.5, flange: 7.0, root_radius: None }, + }, + ] +} + +/// The label of a profile dimension, as the feature's inputs show it. +pub fn dimension_label(name: &str) -> &'static str { + match name { + "width" => "Width", + "height" => "Height", + "wall" => "Wall", + "diameter" => "Diameter", + "thickness" => "Thickness", + "web" => "Web thickness", + "flange" => "Flange thickness", + "corner_radius" => "Corner radius", + "outer_radius" => "Outer radius", + "root_radius" => "Root radius", + "toe_radius" => "Toe radius", + _ => "Dimension", + } +} + +/// A built-in profile as a feature saves it and a command takes it: its kind and each dimension +/// as an expression over the design's parameters (`"tube_wall"`, `"2 in"`, a number). Saved as +/// one JSON object, `{"kind": "rect_tube", "width": "40", ...}`; numbers read as their text. A +/// length without a unit is millimetres here: commands give bare numbers the design's unit first. +#[derive(Clone, Debug, PartialEq)] +pub struct ProfileSpec { + pub kind: String, + pub dims: BTreeMap, +} + +impl ProfileSpec { + /// The profile, each dimension worked out by `eval` (given the dimension's name and its + /// expression, returning millimetres). + pub fn resolve(&self, mut eval: impl FnMut(&str, &Expr) -> Result) -> Result { + let mut o = serde_json::Map::new(); + o.insert("kind".into(), serde_json::Value::String(self.kind.clone())); + for (name, e) in &self.dims { + let v = eval(name, e)?; + let n = serde_json::Number::from_f64(v).ok_or_else(|| invalid(format!("profile `{name}` is not a finite length")))?; + o.insert(name.clone(), serde_json::Value::Number(n)); + } + serde_json::from_value(serde_json::Value::Object(o)).map_err(|e| invalid(format!("profile: {e}"))) + } +} + +impl Serialize for ProfileSpec { + fn serialize(&self, s: S) -> std::result::Result { + use serde::ser::SerializeMap; + let mut m = s.serialize_map(Some(self.dims.len() + 1))?; + m.serialize_entry("kind", &self.kind)?; + for (k, v) in &self.dims { + m.serialize_entry(k, v)?; + } + m.end() + } +} + +impl<'de> Deserialize<'de> for ProfileSpec { + fn deserialize>(d: D) -> std::result::Result { + use serde::de::Error; + let mut m = BTreeMap::::deserialize(d)?; + let kind = match m.remove("kind") { + Some(serde_json::Value::String(k)) => k, + _ => return Err(D::Error::custom("a profile needs `kind` (bar, round_bar, rect_tube, round_tube, angle, channel or i_beam)")), + }; + let kinds = profile_kinds(); + let info = kinds.iter().find(|k| k.kind == kind).ok_or_else(|| { + D::Error::custom(format!("unknown profile kind `{kind}` (bar, round_bar, rect_tube, round_tube, angle, channel or i_beam)")) + })?; + let mut dims = BTreeMap::new(); + for (name, v) in m { + if !info.required.contains(&name.as_str()) && !info.optional.contains(&name.as_str()) { + return Err(D::Error::custom(format!("a {kind} profile has no dimension `{name}`"))); + } + let text = match v { + serde_json::Value::Number(n) => n.to_string(), + serde_json::Value::String(t) if !t.trim().is_empty() && t.len() <= 4096 => t, + _ => return Err(D::Error::custom(format!("profile `{name}` must be a number or an expression"))), + }; + dims.insert(name, Expr::new(text)); + } + if let Some(missing) = info.required.iter().find(|r| !dims.contains_key(**r)) { + return Err(D::Error::custom(format!("a {kind} profile needs `{missing}`"))); + } + Ok(ProfileSpec { kind, dims }) + } +} + +impl crate::walk::Walk for ProfileSpec { + fn walk_exprs<'a>(&'a self, f: &mut dyn FnMut(crate::walk::ExprItem<'a>)) { + for (name, e) in &self.dims { + f((dimension_label(name), e, Kind::Length, crate::walk::ExprRole::Input)); + } + } + fn walk_exprs_mut<'a>(&'a mut self, f: &mut dyn FnMut(crate::walk::ExprItemMut<'a>)) { + for (name, e) in self.dims.iter_mut() { + f((dimension_label(name), e, Kind::Length, crate::walk::ExprRole::Input)); + } + } +} + +pub use crate::eval::frame_member::{member_line_ids, member_names}; + +/// Where a frame member's cross-section comes from. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, solidcraft_derive::Walk)] +#[serde(tag = "source", rename_all = "snake_case")] +pub enum MemberProfile { + /// A built-in profile, from its dimensions. + Library { profile: ProfileSpec }, + /// The profiles of a sketch, in the sketch's own x and y, its origin the profile's origin. + /// Every profile (the default) means the material as nested loops alternate: an outline is + /// material, a loop inside it a hole, a loop inside that material again, so a tube drawn as + /// two circles is its wall. + Sketch { + sketch: crate::SketchId, + #[serde(default = "crate::document::all_profiles")] + #[walk(profiles_of = sketch)] + profiles: crate::ProfileSel, + }, +} + +impl Anchor { + pub fn is_origin(&self) -> bool { + *self == Anchor::Origin + } +} + +/// The point of a profile that sits on a member's line (or on `at` when a profile is inserted): +/// the profile's own origin (a built-in profile's bounding-box centre, a sketch profile's sketch +/// origin), its centroid, or a corner or edge middle of its bounding box. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Anchor { + #[default] + Origin, + Center, + Centroid, + TopLeft, + Top, + TopRight, + Left, + Right, + BottomLeft, + Bottom, + BottomRight, +} + +/// A profile's section properties (mm², mm, mm⁴). Second moments are about axes through the +/// centroid, parallel to the profile's x and y. +#[derive(Clone, Copy, Debug, PartialEq, Serialize)] +pub struct Section { + pub area: f64, + pub centroid: [f64; 2], + /// ∫ y² dA about the centroid: bending about the x axis. + pub ixx: f64, + /// ∫ x² dA about the centroid: bending about the y axis. + pub iyy: f64, + pub ixy: f64, + /// The bounding box's size. + pub width: f64, + pub height: f64, +} + +fn invalid(msg: impl Into) -> DocError { + DocError::Invalid(msg.into()) +} + +/// `v` as a dimension: finite, at least [`MIN_PROFILE_DIMENSION`] and at most +/// [`MAX_PROFILE_SIZE`]. +fn dim(name: &str, v: f64) -> Result { + if v.is_finite() && (MIN_PROFILE_DIMENSION..=MAX_PROFILE_SIZE).contains(&v) { + Ok(v) + } else { + Err(invalid(format!("profile `{name}` must be between {MIN_PROFILE_DIMENSION} and {MAX_PROFILE_SIZE} mm (got {v})"))) + } +} + +/// `v` as a radius: 0 (a sharp corner), or from [`MIN_PROFILE_DIMENSION`] up to +/// [`MAX_PROFILE_SIZE`]. +fn radius(name: &str, v: f64) -> Result { + if v == 0.0 || (v.is_finite() && (MIN_PROFILE_DIMENSION..=MAX_PROFILE_SIZE).contains(&v)) { + Ok(v) + } else { + Err(invalid(format!("profile `{name}` must be 0 or between {MIN_PROFILE_DIMENSION} and {MAX_PROFILE_SIZE} mm (got {v})"))) + } +} + +/// `a` less than `b`. +fn less(a_name: &str, a: f64, b_name: &str, b: f64) -> Result<()> { + if a < b { Ok(()) } else { Err(invalid(format!("profile `{a_name}` ({a}) must be less than {b_name} ({b})"))) } +} + +/// `a` less than half of `b`. +fn less_half(a_name: &str, a: f64, b_name: &str, b: f64) -> Result<()> { + if 2.0 * a < b { Ok(()) } else { Err(invalid(format!("profile `{a_name}` ({a}) must be less than half {b_name} ({})", b / 2.0))) } +} + +/// Radius `r` at most `room` (with a little slack for rounding). +fn at_most(what: &str, r: f64, room: f64, why: &str) -> Result<()> { + if r <= room + 1e-9 * (1.0 + room.abs()) { + Ok(()) + } else { + Err(invalid(format!("profile {what} ({r}) must be at most {} mm: {why}", room.max(0.0)))) + } +} + +/// A number for a name: up to four decimals, without trailing zeros. +fn short(x: f64) -> String { + let t = format!("{x:.4}"); + let t = if t.contains('.') { t.trim_end_matches('0').trim_end_matches('.') } else { &t }; + if t == "-0" { "0".into() } else { t.to_string() } +} + +impl Profile { + /// The kind's id (`rect_tube`, `angle`…). + pub fn kind(&self) -> &'static str { + match self { + Profile::Bar { .. } => "bar", + Profile::RoundBar { .. } => "round_bar", + Profile::RectTube { .. } => "rect_tube", + Profile::RoundTube { .. } => "round_tube", + Profile::Angle { .. } => "angle", + Profile::Channel { .. } => "channel", + Profile::IBeam { .. } => "i_beam", + } + } + + /// A short description from the kind and its main sizes, such as `Rect Tube 40x40x3`: what a + /// member body is named after. + pub fn description(&self) -> String { + let d = |v: &[f64]| v.iter().map(|x| short(*x)).collect::>().join("x"); + match *self { + Profile::Bar { width, height, .. } => format!("Bar {}", d(&[width, height])), + Profile::RoundBar { diameter } => format!("Round Bar {}", short(diameter)), + Profile::RectTube { width, height, wall, .. } => format!("Rect Tube {}", d(&[width, height, wall])), + Profile::RoundTube { diameter, wall } => format!("Round Tube {}", d(&[diameter, wall])), + Profile::Angle { width, height, thickness, .. } => format!("Angle {}", d(&[width, height, thickness])), + Profile::Channel { height, width, web, .. } => format!("Channel {}", d(&[height, width, web])), + Profile::IBeam { height, width, web, .. } => format!("I-Beam {}", d(&[height, width, web])), + } + } + + /// The profile with every defaulted dimension written out. A default radius is made smaller + /// where the profile leaves less room for it than its usual size (a short leg, a narrow + /// flange), so leaving radii out never makes a profile that does not fit. + pub fn filled(&self) -> Profile { + let room = |x: f64| if x.is_finite() { x.max(0.0) } else { 0.0 }; + match *self { + Profile::Bar { width, height, corner_radius } => Profile::Bar { width, height, corner_radius: Some(corner_radius.unwrap_or(0.0)) }, + Profile::RectTube { width, height, wall, outer_radius } => { + let r = outer_radius.unwrap_or_else(|| (2.0 * wall).min(width.min(height) / 2.0)); + Profile::RectTube { width, height, wall, outer_radius: Some(r) } + } + Profile::Angle { width, height, thickness: t, root_radius, toe_radius } => { + // Root and toe share the inside length of the shorter leg. + let leg = room(width.min(height) - t); + let (r1, r2) = match (root_radius, toe_radius) { + (Some(r1), Some(r2)) => (r1, r2), + (Some(r1), None) => (r1, (t / 2.0).min(room(leg - r1))), + (None, Some(r2)) => (t.min(room(leg - r2)), r2), + (None, None) => { + let k = if 1.5 * t > leg { leg / (1.5 * t) } else { 1.0 }; + (t * k, t / 2.0 * k) + } + }; + Profile::Angle { width, height, thickness: t, root_radius: Some(r1), toe_radius: Some(r2) } + } + Profile::Channel { height, width, web, flange, root_radius, toe_radius } => { + let r2 = toe_radius.unwrap_or(0.0); + // The root shares a flange's inside length with the toe, and the web's inside + // height with the other root. + let r1 = root_radius.unwrap_or_else(|| web.min(room(width - web - r2)).min(room(height - 2.0 * flange) / 2.0)); + Profile::Channel { height, width, web, flange, root_radius: Some(r1), toe_radius: Some(r2) } + } + Profile::IBeam { height, width, web, flange, root_radius } => { + let r = root_radius.unwrap_or_else(|| web.min(room(width - web) / 2.0).min(room(height - 2.0 * flange) / 2.0)); + Profile::IBeam { height, width, web, flange, root_radius: Some(r) } + } + Profile::RoundBar { .. } | Profile::RoundTube { .. } => self.clone(), + } + } + + /// The bounding box's width and height (mm), before validation. + pub fn size(&self) -> (f64, f64) { + match *self { + Profile::Bar { width, height, .. } + | Profile::RectTube { width, height, .. } + | Profile::Angle { width, height, .. } + | Profile::Channel { width, height, .. } + | Profile::IBeam { width, height, .. } => (width, height), + Profile::RoundBar { diameter } | Profile::RoundTube { diameter, .. } => (diameter, diameter), + } + } + + /// The profile's cross-section, the bounding box centred on the origin. An error names the + /// dimension that is out of range or does not fit. + pub fn region(&self) -> Result { + let f = self.filled(); + match f { + Profile::Bar { width, height, corner_radius } => { + let (w, h) = (dim("width", width)?, dim("height", height)?); + let r = radius("corner_radius", corner_radius.unwrap_or(0.0))?; + if r > w.min(h) / 2.0 { + return Err(invalid(format!("profile `corner_radius` ({r}) must be at most half the smaller side ({})", w.min(h) / 2.0))); + } + Ok(Region2 { outer: rounded_rect(w, h, r)?, holes: Vec::new() }) + } + Profile::RoundBar { diameter } => Ok(Region2 { outer: Loop2::circle(Vec2::ZERO, dim("diameter", diameter)? / 2.0), holes: Vec::new() }), + Profile::RectTube { width, height, wall, outer_radius } => { + let (w, h, t) = (dim("width", width)?, dim("height", height)?, dim("wall", wall)?); + less_half("wall", t, "the smaller side", w.min(h))?; + let ro = radius("outer_radius", outer_radius.unwrap_or(0.0))?; + if ro > w.min(h) / 2.0 { + return Err(invalid(format!("profile `outer_radius` ({ro}) must be at most half the smaller side ({})", w.min(h) / 2.0))); + } + let ri = (ro - t).max(0.0); + Ok(Region2 { outer: rounded_rect(w, h, ro)?, holes: vec![rounded_rect(w - 2.0 * t, h - 2.0 * t, ri)?.reversed()] }) + } + Profile::RoundTube { diameter, wall } => { + let (d, t) = (dim("diameter", diameter)?, dim("wall", wall)?); + less_half("wall", t, "the diameter", d)?; + Ok(Region2 { outer: Loop2::circle(Vec2::ZERO, d / 2.0), holes: vec![Loop2::circle(Vec2::ZERO, d / 2.0 - t).reversed()] }) + } + Profile::Angle { width, height, thickness, root_radius, toe_radius } => { + let (a, b, t) = (dim("width", width)?, dim("height", height)?, dim("thickness", thickness)?); + less("thickness", t, "the shorter leg", a.min(b))?; + let r1 = radius("root_radius", root_radius.unwrap_or(0.0))?; + let r2 = radius("toe_radius", toe_radius.unwrap_or(0.0))?; + at_most("`toe_radius`", r2, t, "the thickness")?; + at_most("`root_radius` + `toe_radius`", r1 + r2, a.min(b) - t, "the inside length of the shorter leg")?; + let pts = [(0.0, 0.0), (a, 0.0), (a, t), (t, t), (t, b), (0.0, b)]; + let radii = [0.0, 0.0, r2, r1, r2, 0.0]; + Ok(Region2 { outer: rounded_polygon(&shifted(&pts, a, b), &radii)?, holes: Vec::new() }) + } + Profile::Channel { height, width, web, flange, root_radius, toe_radius } => { + let (h, b, tw, tf) = (dim("height", height)?, dim("width", width)?, dim("web", web)?, dim("flange", flange)?); + less("web", tw, "width", b)?; + less_half("flange", tf, "the height", h)?; + let r1 = radius("root_radius", root_radius.unwrap_or(0.0))?; + let r2 = radius("toe_radius", toe_radius.unwrap_or(0.0))?; + at_most("`toe_radius`", r2, tf, "the flange thickness")?; + at_most("`root_radius` + `toe_radius`", r1 + r2, b - tw, "the inside length of a flange")?; + at_most("`root_radius`", r1, (h - 2.0 * tf) / 2.0, "half the web's inside height")?; + let pts = [(0.0, 0.0), (b, 0.0), (b, tf), (tw, tf), (tw, h - tf), (b, h - tf), (b, h), (0.0, h)]; + let radii = [0.0, 0.0, r2, r1, r1, r2, 0.0, 0.0]; + Ok(Region2 { outer: rounded_polygon(&shifted(&pts, b, h), &radii)?, holes: Vec::new() }) + } + Profile::IBeam { height, width, web, flange, root_radius } => { + let (h, b, tw, tf) = (dim("height", height)?, dim("width", width)?, dim("web", web)?, dim("flange", flange)?); + less("web", tw, "width", b)?; + less_half("flange", tf, "the height", h)?; + let r = radius("root_radius", root_radius.unwrap_or(0.0))?; + at_most("`root_radius`", r, (b - tw) / 2.0, "the inside length of a flange")?; + at_most("`root_radius`", r, (h - 2.0 * tf) / 2.0, "half the web's inside height")?; + let (x, y, w, f) = (b / 2.0, h / 2.0, tw / 2.0, h / 2.0 - tf); + let pts = [(-x, -y), (x, -y), (x, -f), (w, -f), (w, f), (x, f), (x, y), (-x, y), (-x, f), (-w, f), (-w, -f), (-x, -f)]; + let radii = [0.0, 0.0, 0.0, r, r, 0.0, 0.0, 0.0, 0.0, r, r, 0.0]; + let pts: Vec = pts.iter().map(|&(x, y)| Vec2::new(x, y)).collect(); + Ok(Region2 { outer: rounded_polygon(&pts, &radii)?, holes: Vec::new() }) + } + } + } +} + +/// Points given with the heel at the origin, moved so a `w` × `h` bounding box is centred on it. +fn shifted(pts: &[(f64, f64)], w: f64, h: f64) -> Vec { + pts.iter().map(|&(x, y)| Vec2::new(x - w / 2.0, y - h / 2.0)).collect() +} + +fn rounded_rect(w: f64, h: f64, r: f64) -> Result { + let (x, y) = (w / 2.0, h / 2.0); + rounded_polygon(&[Vec2::new(-x, -y), Vec2::new(x, -y), Vec2::new(x, y), Vec2::new(-x, y)], &[r; 4]) +} + +/// The counter-clockwise polygon `pts` with each corner rounded by its radius in `radii` (0: a +/// sharp corner). Convex and concave corners alike: the arc is tangent to both edges. An error +/// when the arcs at the two ends of an edge need more than its length. +pub fn rounded_polygon(pts: &[Vec2], radii: &[f64]) -> Result { + let n = pts.len(); + if n < 3 || radii.len() != n { + return Err(invalid("a profile polygon needs 3 or more corners, each with a radius")); + } + // Each corner as (where its arc starts, where it ends, the arc). + let mut corners: Vec<(Vec2, Vec2, Option)> = Vec::with_capacity(n); + for i in 0..n { + let (Some(&prev), Some(&v), Some(&next), Some(&r)) = (pts.get((i + n - 1) % n), pts.get(i), pts.get((i + 1) % n), radii.get(i)) else { + return Err(invalid("a profile polygon corner is missing")); + }; + let (Some(d1), Some(d2)) = ((v - prev).normalized(), (next - v).normalized()) else { + return Err(invalid("a profile polygon has two corners in one place")); + }; + let turn = d1.cross(d2).atan2(d1.dot(d2)); + if r <= 0.0 || turn.abs() < 1e-12 { + corners.push((v, v, None)); + continue; + } + if turn.abs() > std::f64::consts::PI - 1e-9 { + return Err(invalid("a profile polygon turns back on itself")); + } + let l = r * (turn.abs() / 2.0).tan(); + let (t1, t2) = (v - d1 * l, v + d2 * l); + let side = if turn > 0.0 { d1.perp() } else { d1.perp() * -1.0 }; + let center = t1 + side * r; + corners.push((t1, t2, Some(Seg2::Arc { center, radius: r, start: (t1 - center).angle(), sweep: turn }))); + } + let mut segs = Vec::with_capacity(2 * n); + for i in 0..n { + let (Some(&(_, out, arc)), Some(&(inn, _, _)), Some(&a), Some(&b)) = + (corners.get(i), corners.get((i + 1) % n), pts.get(i), pts.get((i + 1) % n)) + else { + return Err(invalid("a profile polygon corner is missing")); + }; + if let Some(arc) = arc { + segs.push(arc); + } + // The edge left between the two arcs must run the edge's way (or be empty). + let along = (inn - out).dot(b - a); + if along < -1e-9 * (1.0 + a.dist(b)) { + return Err(invalid(format!("profile corner radii do not fit along an edge {:.4} mm long", a.dist(b)))); + } + if out.dist(inn) > 1e-9 { + segs.push(Seg2::Line { a: out, b: inn }); + } + } + Ok(Loop2 { segs }) +} + +/// Gauss–Legendre nodes and weights on [0, 1] (5 points). +const GL5: [(f64, f64); 5] = [ + (0.046_910_077_030_668, 0.118_463_442_528_094_5), + (0.230_765_344_947_158_5, 0.239_314_335_249_683_2), + (0.5, 0.284_444_444_444_444_4), + (0.769_234_655_052_841_5, 0.239_314_335_249_683_2), + (0.953_089_922_969_332, 0.118_463_442_528_094_5), +]; + +/// The boundary integrals of one loop, as it runs (counter-clockwise positive): area, ∫x dA, +/// ∫y dA, ∫x² dA, ∫y² dA, ∫xy dA, by Green's theorem. +fn loop_moments(l: &Loop2) -> [f64; 6] { + let mut m = [0.0; 6]; + for seg in &l.segs { + let pieces = match seg { + Seg2::Line { .. } => 1, + _ => 16, + }; + for k in 0..pieces { + let h = 1.0 / pieces as f64; + for (x, wt) in GL5 { + let t = (k as f64 + x) * h; + let (p, dp) = (seg.point_at(t), seg.derivative(t)); + let w = wt * h; + let terms = [ + p.x * dp.y, + 0.5 * p.x * p.x * dp.y, + -0.5 * p.y * p.y * dp.x, + p.x * p.x * p.x * dp.y / 3.0, + -p.y * p.y * p.y * dp.x / 3.0, + 0.5 * p.x * p.x * p.y * dp.y, + ]; + for (acc, v) in m.iter_mut().zip(terms) { + *acc += w * v; + } + } + } + } + m +} + +/// Points of `l` that include its extremes along x and y: every segment's ends, and where an arc +/// passes a quarter turn. Free-form segments add the polyline points within 1 µm. +fn extreme_points(l: &Loop2) -> Vec { + let mut out = Vec::new(); + for seg in &l.segs { + match *seg { + Seg2::Line { a, b } => out.extend([a, b]), + Seg2::Arc { center, radius, start, sweep } => { + out.extend([seg.start(), seg.end()]); + let (lo, hi) = if sweep >= 0.0 { (start, start + sweep) } else { (start + sweep, start) }; + let quarter = std::f64::consts::FRAC_PI_2; + let mut k = (lo / quarter).ceil(); + // An arc turns at most once around: five quarter turns at most. + for _ in 0..5 { + let t = k * quarter; + if t > hi { + break; + } + out.push(center + Vec2::from_angle(t) * radius); + k += 1.0; + } + } + _ => out.extend(seg.polyline(1e-3)), + } + } + out +} + +/// The section properties of `region` (outer loop and holes, whichever way each runs). +pub fn section(region: &Region2) -> Section { + let mut m = loop_moments(®ion.outer.ccw()); + for hole in ®ion.holes { + for (acc, v) in m.iter_mut().zip(loop_moments(&hole.ccw())) { + *acc -= v; + } + } + let [a, sx, sy, jyy, jxx, jxy] = m; + let (cx, cy) = if a.abs() > 1e-300 { (sx / a, sy / a) } else { (0.0, 0.0) }; + let (mut lo, mut hi) = (Vec2::new(f64::INFINITY, f64::INFINITY), Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY)); + for p in extreme_points(®ion.outer) { + lo = Vec2::new(lo.x.min(p.x), lo.y.min(p.y)); + hi = Vec2::new(hi.x.max(p.x), hi.y.max(p.y)); + } + let (width, height) = if lo.x <= hi.x { (hi.x - lo.x, hi.y - lo.y) } else { (0.0, 0.0) }; + Section { area: a, centroid: [cx, cy], ixx: jxx - a * cy * cy, iyy: jyy - a * cx * cx, ixy: jxy - a * cx * cy, width, height } +} + +/// Where `anchor` is on the profile made of `regions`, in the profile's own coordinates. +pub fn anchor_point(regions: &[Region2], anchor: Anchor) -> Vec2 { + let (mut lo, mut hi) = (Vec2::new(f64::INFINITY, f64::INFINITY), Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY)); + for p in regions.iter().flat_map(|r| extreme_points(&r.outer)) { + lo = Vec2::new(lo.x.min(p.x), lo.y.min(p.y)); + hi = Vec2::new(hi.x.max(p.x), hi.y.max(p.y)); + } + if !(lo.x <= hi.x && lo.y <= hi.y) { + return Vec2::ZERO; + } + let mid = Vec2::new((lo.x + hi.x) / 2.0, (lo.y + hi.y) / 2.0); + match anchor { + Anchor::Origin => Vec2::ZERO, + Anchor::Center => mid, + Anchor::Centroid => { + let (mut a, mut c) = (0.0, Vec2::ZERO); + for r in regions { + let s = section(r); + a += s.area; + c += Vec2::new(s.centroid[0], s.centroid[1]) * s.area; + } + if a > 1e-300 { c / a } else { mid } + } + Anchor::TopLeft => Vec2::new(lo.x, hi.y), + Anchor::Top => Vec2::new(mid.x, hi.y), + Anchor::TopRight => hi, + Anchor::Left => Vec2::new(lo.x, mid.y), + Anchor::Right => Vec2::new(hi.x, mid.y), + Anchor::BottomLeft => lo, + Anchor::Bottom => Vec2::new(mid.x, lo.y), + Anchor::BottomRight => Vec2::new(hi.x, lo.y), + } +} + +/// `region` mirrored across its y axis (x → −x), its loops still running the way [`Region2`] +/// expects. +pub fn mirrored(region: &Region2) -> Region2 { + let m = |p: Vec2| Vec2::new(-p.x, p.y); + let seg = |g: &Seg2| match *g { + Seg2::Line { a, b } => Seg2::Line { a: m(a), b: m(b) }, + Seg2::Arc { center, radius, start, sweep } => Seg2::Arc { center: m(center), radius, start: std::f64::consts::PI - start, sweep: -sweep }, + Seg2::Cubic { p0, p1, p2, p3 } => Seg2::Cubic { p0: m(p0), p1: m(p1), p2: m(p2), p3: m(p3) }, + Seg2::Conic { a, apex, b, w } => Seg2::Conic { a: m(a), apex: m(apex), b: m(b), w }, + Seg2::Bezier { n, p } => Seg2::Bezier { n, p: p.map(m) }, + }; + let lp = |l: &Loop2| Loop2 { segs: l.segs.iter().map(seg).collect() }.reversed(); + Region2 { outer: lp(®ion.outer), holes: region.holes.iter().map(lp).collect() } +} + +/// The directions a member's profile x and y take in the world, for a member running along `d` +/// (any length): the profile's y points up (world +Z) as far as the line allows and x = y × d, so +/// x × y runs along the line; a vertical member's x is world +X (a line leaning less than +/// [`VERTICAL_TOLERANCE`] counts as vertical, so rounding never turns a post). `angle` (radians) then turns the +/// profile counter-clockwise about the line, seen looking back from its end. `None` for a +/// direction of no length. +pub fn member_axes(d: Vec3, angle: f64) -> Option<(Vec3, Vec3)> { + let d = d.normalized()?; + let up = Vec3::Z - d * Vec3::Z.dot(d); + // `up` is as long as the sine of the line's lean from vertical. + let (x, y) = match up.normalized().filter(|_| up.len() > VERTICAL_TOLERANCE) { + Some(y) => (y.cross(d), y), + None => (Vec3::X, d.cross(Vec3::X)), + }; + let (c, s) = (angle.cos(), angle.sin()); + Some((x * c + y * s, y * c - x * s)) +} + +/// `region` turned by `angle` (radians, counter-clockwise) about its `anchor` point and moved so +/// that point lands on `at`. +pub fn placed(region: &Region2, anchor: Vec2, at: Vec2, angle: f64) -> Region2 { + let (c, s) = (angle.cos(), angle.sin()); + let map = |p: Vec2| { + let q = p - anchor; + at + Vec2::new(c * q.x - s * q.y, s * q.x + c * q.y) + }; + let seg = |g: &Seg2| match *g { + Seg2::Line { a, b } => Seg2::Line { a: map(a), b: map(b) }, + Seg2::Arc { center, radius, start, sweep } => Seg2::Arc { center: map(center), radius, start: start + angle, sweep }, + Seg2::Cubic { p0, p1, p2, p3 } => Seg2::Cubic { p0: map(p0), p1: map(p1), p2: map(p2), p3: map(p3) }, + Seg2::Conic { a, apex, b, w } => Seg2::Conic { a: map(a), apex: map(apex), b: map(b), w }, + Seg2::Bezier { n, p } => Seg2::Bezier { n, p: p.map(map) }, + }; + let lp = |l: &Loop2| Loop2 { segs: l.segs.iter().map(seg).collect() }; + Region2 { outer: lp(®ion.outer), holes: region.holes.iter().map(lp).collect() } +} diff --git a/crates/doc/src/frame_tests.rs b/crates/doc/src/frame_tests.rs new file mode 100644 index 0000000000..e1a295ea96 --- /dev/null +++ b/crates/doc/src/frame_tests.rs @@ -0,0 +1,413 @@ +//! Profile library tests. Expected values are worked out by hand: rectangles and circles, and +//! composite rectangles by the parallel-axis theorem; each fillet adds or removes a spandrel of +//! (1 − π/4) r². +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::*; +use std::f64::consts::PI; + +const SPANDREL: f64 = 1.0 - PI / 4.0; + +fn close(got: f64, want: f64, what: &str) { + let tol = 1e-9 * (1.0 + want.abs()); + assert!((got - want).abs() <= tol, "{what}: got {got}, want {want}"); +} + +fn profile(json: serde_json::Value) -> Profile { + serde_json::from_value(json).unwrap() +} + +fn sec(p: &Profile) -> Section { + let r = p.region().unwrap(); + assert!(r.outer.is_closed(1e-9), "{} outline is closed", p.kind()); + assert!(r.outer.is_ccw(), "{} outline runs counter-clockwise", p.kind()); + for h in &r.holes { + assert!(h.is_closed(1e-9) && !h.is_ccw(), "{} holes are closed and clockwise", p.kind()); + } + // The exact boundary integral agrees with the region's own area. + let s = section(&r); + close(s.area, r.area(), "area against Region2::area"); + s +} + +/// Area, centroid (in the profile's centred frame) and centroidal Ixx, Iyy of rectangles +/// given as (x0, y0, x1, y1), combined. +fn rects(parts: &[(f64, f64, f64, f64)]) -> (f64, f64, f64, f64, f64) { + let a: f64 = parts.iter().map(|&(x0, y0, x1, y1)| (x1 - x0) * (y1 - y0)).sum(); + let cx = parts.iter().map(|&(x0, y0, x1, y1)| (x1 - x0) * (y1 - y0) * (x0 + x1) / 2.0).sum::() / a; + let cy = parts.iter().map(|&(x0, y0, x1, y1)| (x1 - x0) * (y1 - y0) * (y0 + y1) / 2.0).sum::() / a; + let ixx = parts + .iter() + .map(|&(x0, y0, x1, y1)| { + let (b, h) = (x1 - x0, y1 - y0); + b * h * h * h / 12.0 + b * h * ((y0 + y1) / 2.0 - cy).powi(2) + }) + .sum(); + let iyy = parts + .iter() + .map(|&(x0, y0, x1, y1)| { + let (b, h) = (x1 - x0, y1 - y0); + h * b * b * b / 12.0 + b * h * ((x0 + x1) / 2.0 - cx).powi(2) + }) + .sum(); + (a, cx, cy, ixx, iyy) +} + +fn check_rects(s: &Section, parts: &[(f64, f64, f64, f64)], what: &str) { + let (a, cx, cy, ixx, iyy) = rects(parts); + close(s.area, a, &format!("{what} area")); + close(s.centroid[0], cx, &format!("{what} centroid x")); + close(s.centroid[1], cy, &format!("{what} centroid y")); + close(s.ixx, ixx, &format!("{what} Ixx")); + close(s.iyy, iyy, &format!("{what} Iyy")); +} + +#[test] +fn bar_is_a_rectangle() { + let s = sec(&profile(serde_json::json!({"kind": "bar", "width": 40, "height": 10}))); + close(s.area, 400.0, "area"); + close(s.ixx, 40.0 * 1000.0 / 12.0, "Ixx = b h³ / 12"); + close(s.iyy, 10.0 * 64_000.0 / 12.0, "Iyy = h b³ / 12"); + close(s.ixy, 0.0, "Ixy"); + close(s.width, 40.0, "width"); + close(s.height, 10.0, "height"); +} + +#[test] +fn rounded_bar_loses_four_spandrels() { + let s = sec(&profile(serde_json::json!({"kind": "bar", "width": 30, "height": 20, "corner_radius": 4}))); + close(s.area, 600.0 - 4.0 * SPANDREL * 16.0, "area"); + // Fully rounded: a 20 × 20 bar with radius 10 is a circle. + let s = sec(&profile(serde_json::json!({"kind": "bar", "width": 20, "height": 20, "corner_radius": 10}))); + close(s.area, PI * 100.0, "fully rounded area"); + close(s.ixx, PI * 10f64.powi(4) / 4.0, "fully rounded Ixx"); +} + +#[test] +fn round_bar_and_tube() { + let s = sec(&Profile::RoundBar { diameter: 20.0 }); + close(s.area, PI * 100.0, "bar area"); + close(s.ixx, PI * 20f64.powi(4) / 64.0, "bar Ixx = π d⁴ / 64"); + close(s.iyy, s.ixx, "bar Iyy"); + let s = sec(&Profile::RoundTube { diameter: 42.4, wall: 3.2 }); + let d = 42.4 - 6.4; + close(s.area, PI / 4.0 * (42.4f64.powi(2) - d * d), "tube area"); + close(s.ixx, PI / 64.0 * (42.4f64.powi(4) - d.powi(4)), "tube Ixx = π (D⁴ − d⁴) / 64"); +} + +#[test] +fn rect_tube_sharp_and_rounded() { + let s = sec(&profile(serde_json::json!({"kind": "rect_tube", "width": 60, "height": 40, "wall": 3, "outer_radius": 0}))); + close(s.area, 60.0 * 40.0 - 54.0 * 34.0, "sharp area"); + close(s.ixx, (60.0 * 40f64.powi(3) - 54.0 * 34f64.powi(3)) / 12.0, "sharp Ixx"); + close(s.iyy, (40.0 * 60f64.powi(3) - 34.0 * 54f64.powi(3)) / 12.0, "sharp Iyy"); + // Defaults: outer radius 2 t = 6, inner radius 3. + let p = profile(serde_json::json!({"kind": "rect_tube", "width": 40, "height": 40, "wall": 3})); + assert_eq!(p.filled(), Profile::RectTube { width: 40.0, height: 40.0, wall: 3.0, outer_radius: Some(6.0) }); + let s = sec(&p); + let outer = 1600.0 - 4.0 * SPANDREL * 36.0; + let inner = 34.0 * 34.0 - 4.0 * SPANDREL * 9.0; + close(s.area, outer - inner, "rounded area"); + close(s.centroid[0], 0.0, "centroid x"); + close(s.ixy, 0.0, "Ixy"); +} + +#[test] +fn angle_sharp_matches_two_rectangles() { + let (a, b, t) = (60.0, 40.0, 5.0); + let s = sec(&profile(serde_json::json!({"kind": "angle", "width": a, "height": b, "thickness": t, "root_radius": 0, "toe_radius": 0}))); + let (x0, y0) = (-a / 2.0, -b / 2.0); + check_rects(&s, &[(x0, y0, x0 + a, y0 + t), (x0, y0 + t, x0 + t, y0 + b)], "angle"); + assert!(s.ixy.abs() > 1.0, "an unequal angle has a product of inertia"); +} + +#[test] +fn angle_root_adds_and_toes_remove_spandrels() { + let (a, b, t) = (50.0, 50.0, 5.0); + let p = profile(serde_json::json!({"kind": "angle", "width": a, "height": b, "thickness": t})); + let s = sec(&p); + // Defaults: root radius t, toe radius t / 2. + close(s.area, t * (a + b - t) + SPANDREL * t * t - 2.0 * SPANDREL * (t / 2.0).powi(2), "area"); + // An equal angle is symmetric about its diagonal. + close(s.centroid[0], s.centroid[1], "centroid on the diagonal"); + close(s.ixx, s.iyy, "Ixx = Iyy"); +} + +#[test] +fn channel_and_i_beam_sharp_match_rectangles() { + let (h, b, tw, tf) = (100.0, 50.0, 6.0, 8.0); + let s = sec(&profile(serde_json::json!({"kind": "channel", "height": h, "width": b, "web": tw, "flange": tf, "root_radius": 0}))); + let (x0, y0) = (-b / 2.0, -h / 2.0); + let parts = [(x0, y0, x0 + b, y0 + tf), (x0, y0 + tf, x0 + tw, y0 + h - tf), (x0, y0 + h - tf, x0 + b, y0 + h)]; + check_rects(&s, &parts, "channel"); + close(s.ixy, 0.0, "channel Ixy"); + + let (h, b, tw, tf) = (100.0, 50.0, 4.5, 7.0); + let s = sec(&profile(serde_json::json!({"kind": "i_beam", "height": h, "width": b, "web": tw, "flange": tf, "root_radius": 0}))); + close(s.area, 2.0 * b * tf + (h - 2.0 * tf) * tw, "I area"); + close(s.ixx, (b * h.powi(3) - (b - tw) * (h - 2.0 * tf).powi(3)) / 12.0, "I Ixx"); + close(s.iyy, (2.0 * tf * b.powi(3) + (h - 2.0 * tf) * tw.powi(3)) / 12.0, "I Iyy"); +} + +#[test] +fn i_beam_root_radii_add_four_spandrels() { + let (h, b, tw, tf, r) = (100.0, 50.0, 4.5, 7.0, 7.0); + let s = sec(&profile(serde_json::json!({"kind": "i_beam", "height": h, "width": b, "web": tw, "flange": tf, "root_radius": r}))); + close(s.area, 2.0 * b * tf + (h - 2.0 * tf) * tw + 4.0 * SPANDREL * r * r, "area"); + close(s.centroid[0], 0.0, "centroid x"); + close(s.centroid[1], 0.0, "centroid y"); +} + +#[test] +fn every_kind_example_builds() { + for k in profile_kinds() { + assert_eq!(k.example.kind(), k.kind); + let s = sec(&k.example); + assert!(s.area > 0.0 && s.ixx > 0.0 && s.iyy > 0.0, "{}: {s:?}", k.kind); + let (w, h) = k.example.size(); + close(s.width, w, &format!("{} width", k.kind)); + close(s.height, h, &format!("{} height", k.kind)); + } +} + +#[test] +fn dimensions_that_do_not_fit_are_refused() { + let bad = [ + serde_json::json!({"kind": "bar", "width": f64::MAX, "height": 1}), + serde_json::json!({"kind": "bar", "width": -1, "height": 1}), + serde_json::json!({"kind": "bar", "width": 0, "height": 1}), + serde_json::json!({"kind": "bar", "width": 10, "height": 10, "corner_radius": 6}), + serde_json::json!({"kind": "round_bar", "diameter": 1e300}), + serde_json::json!({"kind": "rect_tube", "width": 10, "height": 10, "wall": 5}), + serde_json::json!({"kind": "rect_tube", "width": 10, "height": 10, "wall": 1, "outer_radius": -1}), + serde_json::json!({"kind": "round_tube", "diameter": 10, "wall": 5}), + serde_json::json!({"kind": "angle", "width": 10, "height": 10, "thickness": 10}), + serde_json::json!({"kind": "angle", "width": 10, "height": 10, "thickness": 2, "root_radius": 9}), + serde_json::json!({"kind": "angle", "width": 10, "height": 10, "thickness": 2, "toe_radius": 3}), + serde_json::json!({"kind": "channel", "height": 10, "width": 10, "web": 2, "flange": 5}), + serde_json::json!({"kind": "channel", "height": 10, "width": 10, "web": 10, "flange": 2}), + serde_json::json!({"kind": "i_beam", "height": 100, "width": 50, "web": 5, "flange": 7, "root_radius": 30}), + ]; + for j in bad { + let p: Profile = serde_json::from_value(j.clone()).unwrap(); + assert!(p.region().is_err(), "{j} is refused"); + } + for j in [ + serde_json::json!({"kind": "bar", "width": 1}), + serde_json::json!({"kind": "bar", "width": 1, "height": 1, "colour": 2}), + serde_json::json!({"kind": "hexagon"}), + ] { + assert!(serde_json::from_value::(j.clone()).is_err(), "{j} is not a profile"); + } +} + +#[test] +fn non_finite_dimensions_are_refused() { + for v in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + assert!(Profile::RoundBar { diameter: v }.region().is_err()); + assert!(Profile::RectTube { width: 40.0, height: 40.0, wall: 3.0, outer_radius: Some(v) }.region().is_err()); + } +} + +#[test] +fn placing_moves_the_anchor_onto_the_point() { + let p = profile(serde_json::json!({"kind": "angle", "width": 60, "height": 40, "thickness": 5})); + let r = p.region().unwrap(); + let s0 = section(&r); + let anchor = anchor_point(std::slice::from_ref(&r), Anchor::BottomLeft); + close(anchor.x, -30.0, "bottom left x"); + close(anchor.y, -20.0, "bottom left y"); + let at = Vec2::new(100.0, 50.0); + let moved = placed(&r, anchor, at, PI / 2.0); + let s1 = section(&moved); + close(s1.area, s0.area, "area is kept"); + // Turned a quarter: the heel stays on `at`, the 60 mm leg now runs up and the 40 mm one left. + close(s1.width, 40.0, "turned width"); + close(s1.height, 60.0, "turned height"); + let c = at + Vec2::new(-(s0.centroid[1] + 20.0), s0.centroid[0] + 30.0); + close(s1.centroid[0], c.x, "centroid x"); + close(s1.centroid[1], c.y, "centroid y"); + close(s1.ixx, s0.iyy, "Ixx after a quarter turn"); + let centre = anchor_point(std::slice::from_ref(&r), Anchor::Centroid); + let s2 = section(&placed(&r, centre, Vec2::ZERO, 0.0)); + close(s2.centroid[0], 0.0, "centroid anchor x"); + close(s2.centroid[1], 0.0, "centroid anchor y"); +} + +#[test] +fn member_axes_are_square_to_the_line_and_keep_up() { + for d in [Vec3::X, Vec3::new(0.0, -3.0, 0.0), Vec3::new(1.0, 2.0, 3.0), Vec3::Z, Vec3::new(0.0, 0.0, -2.0), Vec3::new(1e-12, 0.0, 1.0)] { + for angle in [0.0, 0.3, -PI / 2.0] { + let (x, y) = member_axes(d, angle).unwrap(); + let n = d.normalized().unwrap(); + close(x.len(), 1.0, "|x|"); + close(y.len(), 1.0, "|y|"); + close(x.dot(y), 0.0, "x ⟂ y"); + close(x.cross(y).dist(n), 0.0, "x × y runs along the line"); + } + } + // Unturned: a horizontal member's y is world up; a vertical one's x is world X. + let (_, y) = member_axes(Vec3::new(1.0, 1.0, 0.0), 0.0).unwrap(); + close(y.dist(Vec3::Z), 0.0, "horizontal y"); + let (x, _) = member_axes(Vec3::Z, 0.0).unwrap(); + close(x.dist(Vec3::X), 0.0, "vertical x"); + let (x, _) = member_axes(Vec3::new(0.0, 0.0, -1.0), 0.0).unwrap(); + close(x.dist(Vec3::X), 0.0, "downward x"); + // A quarter turn takes x to where y was. + let (_, y0) = member_axes(Vec3::X, 0.0).unwrap(); + let (x1, _) = member_axes(Vec3::X, PI / 2.0).unwrap(); + close(x1.dist(y0), 0.0, "quarter turn"); + assert!(member_axes(Vec3::ZERO, 0.0).is_none()); +} + +#[test] +fn mirroring_keeps_the_section_and_flips_x() { + let p = profile(serde_json::json!({"kind": "angle", "width": 60, "height": 40, "thickness": 5})); + let r = p.region().unwrap(); + let m = mirrored(&r); + assert!(m.outer.is_ccw() && m.outer.is_closed(1e-9), "the outline still runs counter-clockwise"); + let (a, b) = (section(&r), section(&m)); + close(b.area, a.area, "area"); + close(b.centroid[0], -a.centroid[0], "centroid x"); + close(b.centroid[1], a.centroid[1], "centroid y"); + close(b.ixy, -a.ixy, "Ixy changes sign"); + let tube = mirrored(&profile(serde_json::json!({"kind": "rect_tube", "width": 40, "height": 20, "wall": 2})).region().unwrap()); + assert!(tube.holes.iter().all(|h| !h.is_ccw()), "holes still run clockwise"); +} + +#[test] +fn anchors_of_an_off_centre_region() { + // A sketch profile need not be centred: a 10 × 20 box from (5, 5). + let r = Region2 { + outer: Loop2::polygon(&[Vec2::new(5.0, 5.0), Vec2::new(15.0, 5.0), Vec2::new(15.0, 25.0), Vec2::new(5.0, 25.0)]), + holes: Vec::new(), + }; + let at = |a| anchor_point(std::slice::from_ref(&r), a); + assert_eq!(at(Anchor::Origin), Vec2::ZERO); + assert_eq!(at(Anchor::Center), Vec2::new(10.0, 15.0)); + assert_eq!(at(Anchor::BottomLeft), Vec2::new(5.0, 5.0)); + assert_eq!(at(Anchor::TopRight), Vec2::new(15.0, 25.0)); + assert_eq!(at(Anchor::Left), Vec2::new(5.0, 15.0)); + let c = at(Anchor::Centroid); + close(c.x, 10.0, "centroid x"); + close(c.y, 15.0, "centroid y"); +} + +#[test] +fn default_radii_shrink_to_fit_thick_sections() { + // Each of these failed with the defaults at their usual size (#4003 review). + for j in [ + serde_json::json!({"kind": "angle", "width": 20, "height": 20, "thickness": 10}), + serde_json::json!({"kind": "channel", "height": 50, "width": 10, "web": 6, "flange": 2}), + serde_json::json!({"kind": "channel", "height": 20, "width": 40, "web": 5, "flange": 8}), + serde_json::json!({"kind": "i_beam", "height": 100, "width": 10, "web": 6, "flange": 5}), + ] { + let p = profile(j.clone()); + let s = sec(&p); + assert!(s.area > 0.0, "{j}"); + } + // A 20 × 20 × 10 angle has 10 mm of leg inside: root and toe keep their 2 : 1 ratio. + let Profile::Angle { root_radius: Some(r1), toe_radius: Some(r2), .. } = + profile(serde_json::json!({"kind": "angle", "width": 20, "height": 20, "thickness": 10})).filled() + else { + panic!("an angle") + }; + close(r1, 20.0 / 3.0, "root"); + close(r2, 10.0 / 3.0, "toe"); + // One given, the other default takes what is left. + let Profile::Angle { toe_radius: Some(r2), .. } = + profile(serde_json::json!({"kind": "angle", "width": 20, "height": 20, "thickness": 10, "root_radius": 8})).filled() + else { + panic!("an angle") + }; + close(r2, 2.0, "toe after an 8 mm root"); + // A channel 20 tall with 8 mm flanges has 4 mm of web inside: two roots of 2. + let Profile::Channel { root_radius: Some(r1), .. } = + profile(serde_json::json!({"kind": "channel", "height": 20, "width": 40, "web": 5, "flange": 8})).filled() + else { + panic!("a channel") + }; + close(r1, 2.0, "channel root"); + // Defaults that fit are left as they are. + assert_eq!( + profile(serde_json::json!({"kind": "angle", "width": 50, "height": 50, "thickness": 5})).filled(), + Profile::Angle { width: 50.0, height: 50.0, thickness: 5.0, root_radius: Some(5.0), toe_radius: Some(2.5) } + ); +} + +#[test] +fn radii_given_too_large_or_too_small_are_refused_by_name() { + for (j, name) in [ + (serde_json::json!({"kind": "angle", "width": 20, "height": 20, "thickness": 10, "toe_radius": 11}), "toe_radius"), + (serde_json::json!({"kind": "angle", "width": 20, "height": 20, "thickness": 10, "root_radius": 9, "toe_radius": 2}), "root_radius"), + (serde_json::json!({"kind": "channel", "height": 20, "width": 40, "web": 5, "flange": 8, "root_radius": 3}), "root_radius"), + (serde_json::json!({"kind": "i_beam", "height": 100, "width": 10, "web": 6, "flange": 5, "root_radius": 3}), "root_radius"), + (serde_json::json!({"kind": "rect_tube", "width": 40, "height": 40, "wall": 3, "outer_radius": 1e-12}), "outer_radius"), + (serde_json::json!({"kind": "bar", "width": 40, "height": 10, "corner_radius": 0.001}), "corner_radius"), + ] { + let err = profile(j.clone()).region().unwrap_err().to_string(); + assert!(err.contains(name), "{j}: {err}"); + } + // The value named is the one given, not twice it. + let err = profile(serde_json::json!({"kind": "rect_tube", "width": 10, "height": 10, "wall": 5})).region().unwrap_err().to_string(); + assert!(err.contains("`wall` (5)") && err.contains("half the smaller side (5)"), "{err}"); + let err = profile(serde_json::json!({"kind": "channel", "height": 10, "width": 10, "web": 2, "flange": 5})).region().unwrap_err().to_string(); + assert!(err.contains("`flange` (5)"), "{err}"); +} + +#[test] +fn a_spec_holds_expressions_and_resolves_them() { + let spec: ProfileSpec = serde_json::from_value(serde_json::json!({"kind": "rect_tube", "width": 40, "height": "H", "wall": "2 * t"})).unwrap(); + assert_eq!(spec.dims.get("width").map(|e| e.as_str()), Some("40")); + // Saved as one object of text. + assert_eq!(serde_json::to_value(&spec).unwrap(), serde_json::json!({"kind": "rect_tube", "width": "40", "height": "H", "wall": "2 * t"})); + let p = spec + .resolve(|_, e| match e.as_str() { + "40" => Ok(40.0), + "H" => Ok(60.0), + "2 * t" => Ok(3.0), + other => Err(crate::DocError::Invalid(other.into())), + }) + .unwrap(); + assert_eq!(p, Profile::RectTube { width: 40.0, height: 60.0, wall: 3.0, outer_radius: None }); + // A value that is not a finite length is refused, naming the dimension. + let err = spec.resolve(|name, _| Ok(if name == "wall" { f64::NAN } else { 40.0 })).unwrap_err().to_string(); + assert!(err.contains("wall"), "{err}"); + let mut labels = Vec::new(); + crate::walk::Walk::walk_exprs(&spec, &mut |(label, e, kind, _)| labels.push((label, e.as_str().to_string(), kind))); + assert_eq!(labels, [("Height", "H".into(), Kind::Length), ("Wall", "2 * t".into(), Kind::Length), ("Width", "40".into(), Kind::Length)]); + for (j, why) in [ + (serde_json::json!({"width": 40}), "kind"), + (serde_json::json!({"kind": "hexagon", "width": 40}), "unknown profile kind"), + (serde_json::json!({"kind": "round_bar"}), "needs `diameter`"), + (serde_json::json!({"kind": "round_bar", "diameter": 10, "colour": 2}), "no dimension `colour`"), + (serde_json::json!({"kind": "round_bar", "diameter": true}), "number or an expression"), + (serde_json::json!({"kind": "round_bar", "diameter": " "}), "number or an expression"), + (serde_json::json!({"kind": "round_bar", "diameter": "x".repeat(5000)}), "number or an expression"), + ] { + let err = serde_json::from_value::(j.clone()).unwrap_err().to_string(); + assert!(err.contains(why), "{j}: {err}"); + } +} + +#[test] +fn descriptions_round_to_a_tenth_of_a_micron() { + let p = Profile::RectTube { width: 25.4, height: 25.4, wall: 0.125 * 25.4, outer_radius: None }; + assert_eq!(p.description(), "Rect Tube 25.4x25.4x3.175"); + assert_eq!(Profile::RoundBar { diameter: 1.0 / 3.0 }.description(), "Round Bar 0.3333"); +} + +#[test] +fn a_line_a_hair_off_vertical_is_vertical() { + // Rounding noise must not turn a post a quarter turn (#4003 review). + let (x0, _) = member_axes(Vec3::Z, 0.0).unwrap(); + for lean in [1e-8, -1e-8, 1e-12] { + let (x, _) = member_axes(Vec3::new(lean, 0.0, 1.0), 0.0).unwrap(); + close(x.dist(x0), 0.0, "near-vertical x"); + let (x, _) = member_axes(Vec3::new(0.0, lean, 1.0), 0.0).unwrap(); + close(x.dist(x0), 0.0, "near-vertical x"); + } + // A lean you can see is a lean: its y tips toward up. + let (_, y) = member_axes(Vec3::new(1e-3, 0.0, 1.0), 0.0).unwrap(); + assert!(y.z > 0.0 && y.x < 0.0, "{y:?}"); +} diff --git a/crates/doc/src/hole_reference.rs b/crates/doc/src/hole_reference.rs new file mode 100644 index 0000000000..187a1971f7 --- /dev/null +++ b/crates/doc/src/hole_reference.rs @@ -0,0 +1,251 @@ +//! A Single hole placed by distances from reference edges (#37): up to two straight edges of the +//! face it is drilled into, each with the distance of the hole's centre from that edge's line, +//! measured in the face's plane. The edges are found again by their persistent names, else by +//! their stored points, so the hole follows them when the part changes. + +use solidcraft_geom::{Mesh, Vec3}; + +use crate::{DocError, ModelBody, ModelState, Result}; + +/// The most reference edges a hole takes. +pub const MAX_HOLE_REFERENCES: usize = 2; + +/// A reference edge's line in the face's plane. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ReferenceLine { + /// A point on the line: the edge's middle, moved into the face's plane. + pub point: Vec3, + /// Along the edge (unit). + pub along: Vec3, + /// In the plane, square to the edge, toward the side the face is on (unit): the way the + /// distance grows. + pub toward: Vec3, +} + +impl ReferenceLine { + /// How far `p` is from the line, toward the face's side (negative on the other side). + pub fn distance(&self, p: Vec3) -> f64 { + (p - self.point).dot(self.toward) + } + + /// The point of the line nearest `p`. + pub fn foot(&self, p: Vec3) -> Vec3 { + self.point + self.along * (p - self.point).dot(self.along) + } +} + +/// The face a referenced hole is drilled into (its plane) and its reference edges' lines. +#[derive(Clone, Debug, PartialEq)] +pub struct ReferenceFrame { + /// The body and the face (its index). + pub body: String, + pub face: usize, + /// A point on the face's plane. + pub origin: Vec3, + /// The face's outward unit normal. + pub normal: Vec3, + pub lines: Vec, + /// The reference edges found (their indices in the body's mesh), one per line. + pub edges: Vec, +} + +impl ReferenceFrame { + /// `p` moved square onto the face's plane. + pub fn onto_plane(&self, p: Vec3) -> Vec3 { + p - self.normal * (p - self.origin).dot(self.normal) + } + + /// How far `p` is from each line (see [`ReferenceLine::distance`]). + pub fn distances(&self, p: Vec3) -> Vec { + let p = self.onto_plane(p); + self.lines.iter().map(|l| l.distance(p)).collect() + } + + /// The hole's centre at `distances` (one per line) from the lines, in the face's plane. With + /// one line, `near` moves square to it (it keeps its place along the edge); with two, the + /// centre is where both distances hold. + pub fn place(&self, near: Vec3, distances: &[f64]) -> Result { + if let Some((i, _)) = distances.iter().enumerate().find(|(_, d)| !(d.is_finite() && **d >= 0.0 && **d < 1e9)) { + return Err(DocError::Invalid(format!("reference distance {} must be a length of 0 or more", i + 1))); + } + let p = self.onto_plane(near); + match (self.lines.as_slice(), distances) { + ([], []) => Ok(p), + ([a], [d]) => Ok(p + a.toward * (d - a.distance(p))), + ([a, b], [d1, d2]) => { + // p + r1 * a.toward + s * e2, with e2 square to a in the plane: a's distance is + // then d1, and s gives b's. + let (r1, r2) = (d1 - a.distance(p), d2 - b.distance(p)); + let e2 = self.normal.cross(a.toward); + let across = b.toward.dot(e2); + if across.abs() < 1e-6 { + return Err(DocError::Invalid("the reference edges are parallel: pick edges that cross".into())); + } + Ok(p + a.toward * r1 + e2 * ((r2 - r1 * a.toward.dot(b.toward)) / across)) + } + _ => Err(DocError::Invalid("a hole takes one distance for each reference edge".into())), + } + } +} + +/// The reference frame of a hole placed at `at` (on the surface), drilled along `dir` (into the +/// material), from its reference edges: a point on each edge and, when it has one, the edge's +/// persistent name. A fresh pick (`exact`) must lie on its edge; a stored one is found by its +/// name, else the edge nearest its point. The face is the flat face under `at` that faces the +/// drill, else the one the edges border. +pub fn reference_frame(st: &ModelState, at: Vec3, dir: Vec3, edges: &[(Vec3, Option<&str>)], exact: bool) -> Result { + if edges.len() > MAX_HOLE_REFERENCES { + return Err(DocError::Invalid(format!("a hole takes at most {MAX_HOLE_REFERENCES} reference edges"))); + } + let dir = dir.normalized().ok_or_else(|| DocError::Invalid("hole direction".into()))?; + if !at.is_finite() || edges.iter().any(|(p, _)| !p.is_finite()) { + return Err(DocError::Invalid("reference edges need finite points".into())); + } + let b = body_at(st, at).ok_or_else(|| DocError::Invalid("no body at the hole for its reference edges".into()))?; + let m = b.mesh(); + let size = b.body.size().max(1e-9); + let mut found = Vec::with_capacity(edges.len()); + for (k, (p, name)) in edges.iter().enumerate() { + found.push(find_edge(b, &m, k, *p, *name, exact)?); + } + let (face, origin, normal) = flat_face(b, &m, at, dir, &found).ok_or_else(|| { + DocError::Invalid("reference edges need the hole on a flat face: no flat face facing the drill at the hole or along its edges".into()) + })?; + let mut lines = Vec::with_capacity(found.len()); + for (k, (a, z, _)) in found.iter().enumerate() { + let flat = |q: Vec3| q - normal * (q - origin).dot(normal); + let (a, z) = (flat(*a), flat(*z)); + let along = (z - a).normalized().ok_or_else(|| DocError::Invalid(format!("reference edge {} is square to the face", k + 1)))?; + let across = normal.cross(along).normalized().ok_or_else(|| DocError::Invalid(format!("reference edge {} is square to the face", k + 1)))?; + let mid = (a + z) * 0.5; + // The side the face is on next to the edge; an edge the face doesn't border (or borders + // on both sides) takes the side the hole is on. + let side = face_side(&m, face, mid, across, normal, size).unwrap_or_else(|| if (at - mid).dot(across) < 0.0 { -1.0 } else { 1.0 }); + lines.push(ReferenceLine { point: mid, along, toward: across * side }); + } + if let [a, b] = lines.as_slice() + && a.along.cross(b.along).len() < 1e-6 + { + return Err(DocError::Invalid("the reference edges are parallel: pick edges that cross".into())); + } + Ok(ReferenceFrame { body: b.name.clone(), face, origin, normal, lines, edges: found.iter().map(|(_, _, i)| *i).collect() }) +} + +/// Whether `p` lies on the flat face a reference frame was made for. +pub fn on_reference_face(st: &ModelState, frame: &ReferenceFrame, p: Vec3) -> bool { + let Some(b) = st.bodies.iter().find(|b| b.name == frame.body) else { return false }; + let m = b.mesh(); + let tol = (b.body.size() * 1e-4).max(1e-6); + m.triangles + .iter() + .zip(&m.tri_face) + .filter(|(_, f)| **f as usize == frame.face) + .any(|(t, _)| m.tri(t).is_some_and(|[a, bb, c]| crate::appearance::closest_on_triangle(p, a, bb, c).dist(p) <= tol)) +} + +/// The persistent names of the edges at `edges`, on the body the hole at `at` is drilled into +/// (empty where an edge has none). +pub fn reference_edge_names(st: &ModelState, at: Vec3, edges: &[Vec3]) -> Vec { + match body_at(st, at) { + Some(b) => crate::naming::edge_names_at(b, edges), + None => Vec::new(), + } +} + +/// The (non-mesh) body whose surface is nearest `p`. +fn body_at(st: &ModelState, p: Vec3) -> Option<&ModelBody> { + st.bodies + .iter() + .filter(|b| !b.body.is_mesh()) + .filter_map(|b| crate::appearance::nearest_face(b, p).map(|(d, _)| (d, b))) + .min_by(|a, b| a.0.total_cmp(&b.0)) + .map(|(_, b)| b) +} + +/// How far `p` is from an edge's polyline. +fn to_polyline(p: Vec3, e: &[Vec3]) -> f64 { + e.windows(2).map(|w| p.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min) +} + +/// Reference edge `k` (its ends, and its index in the mesh), found by `name`, else by the edge +/// nearest `p`: one that runs through `p` for a fresh pick, within a quarter of the body for a +/// stored one. It must be straight. +fn find_edge(b: &ModelBody, m: &Mesh, k: usize, p: Vec3, name: Option<&str>, exact: bool) -> Result<(Vec3, Vec3, usize)> { + let size = b.body.size().max(1e-9); + let by_name = name.filter(|n| !n.is_empty()).and_then(|n| crate::naming::locate_edge(b, n, p).ok()).map(|hit| hit.edge); + let i = match by_name { + Some(i) => i, + None => { + let reach = if exact { (size * 1e-2).max(1e-3) } else { size * 0.25 }; + m.edges + .iter() + .enumerate() + .map(|(i, e)| (to_polyline(p, e), i)) + .filter(|(d, _)| d.is_finite()) + .min_by(|a, b| a.0.total_cmp(&b.0)) + .filter(|(d, _)| *d <= reach) + .map(|(_, i)| i) + .ok_or_else(|| { + DocError::Invalid(if exact { + format!("reference edge {}: no edge at [{}, {}, {}]", k + 1, p.x, p.y, p.z) + } else { + format!("reference edge {} no longer exists", k + 1) + }) + })? + } + }; + let e = m.edges.get(i).ok_or_else(|| DocError::Invalid(format!("reference edge {} no longer exists", k + 1)))?; + let (Some(a), Some(z)) = (e.first().copied(), e.last().copied()) else { + return Err(DocError::Invalid(format!("reference edge {} no longer exists", k + 1))); + }; + let len = a.dist(z); + let straight = len > size * 1e-9 && e.iter().all(|q| q.dist_to_segment(a, z) <= 1e-5 * (1.0 + len)); + if !straight { + return Err(DocError::Invalid(format!("reference edge {} is not straight: pick a straight edge", k + 1))); + } + Ok((a, z, i)) +} + +/// The flat face a referenced hole goes into (its index, a point on it and its outward normal): +/// the one under `at` when it faces the drill, else the one the edges border that faces it most. +fn flat_face(b: &ModelBody, m: &Mesh, at: Vec3, dir: Vec3, edges: &[(Vec3, Vec3, usize)]) -> Option<(usize, Vec3, Vec3)> { + let size = b.body.size().max(1e-9); + let plane = |f: usize| match crate::kernel::face_surface(&b.body, f) { + // Outward, so against the drill. + Some(crate::kernel::FaceSurface::Plane { normal, point }) => { + let normal: Vec3 = normal.into(); + let n = if normal.dot(dir) > 0.0 { -normal } else { normal }; + (n.dot(dir) < -1e-3).then_some((f, point, n)) + } + _ => None, + }; + if let Some((d, f)) = crate::appearance::nearest_face(b, at) + && d <= (size * 1e-4).max(1e-6) + && let Some(hit) = plane(f) + { + return Some(hit); + } + // The faces every edge borders. + let mut faces: Vec = + edges.first().and_then(|(_, _, i)| m.edge_faces.get(*i)).map(|fs| fs.iter().map(|f| *f as usize).collect()).unwrap_or_default(); + for (_, _, i) in edges.iter().skip(1) { + let theirs = m.edge_faces.get(*i).map(Vec::as_slice).unwrap_or(&[]); + faces.retain(|f| theirs.iter().any(|g| *g as usize == *f)); + } + faces.into_iter().filter_map(plane).min_by(|x, y| x.2.dot(dir).total_cmp(&y.2.dot(dir))) +} + +/// Which side of an edge (through `mid`, square to it in the plane along `across`) face `face` +/// lies on: +1 or −1, or `None` when both sides or neither are that face. +fn face_side(m: &Mesh, face: usize, mid: Vec3, across: Vec3, normal: Vec3, size: f64) -> Option { + let step = (size * 1e-3).max(1e-6); + let lands = |s: f64| { + let from = mid + across * (s * step) + normal * step; + m.raycast(from, -normal).is_some_and(|(t, tri)| t <= 2.0 * step && m.tri_face.get(tri).is_some_and(|f| *f as usize == face)) + }; + match (lands(1.0), lands(-1.0)) { + (true, false) => Some(1.0), + (false, true) => Some(-1.0), + _ => None, + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 3cdab51d15..8c35170918 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -14,6 +14,8 @@ pub use body_transfer::{valid_transfer_matrix, validate_body_transfer}; pub mod canvas; mod clipboard; pub use clipboard::{clone_expression, remap_topology}; +mod component_removal; +pub use component_removal::RemovedTree; pub mod config; pub mod construct; pub mod display_quality; @@ -27,7 +29,9 @@ mod feature_refs; pub use face_anchor::{FaceAnchor, FaceAttachmentStatus}; pub mod format; mod format_offsets; +pub mod frame; mod history_names; +pub mod hole_reference; pub use format_offsets::whole_profile_selection_from_value; pub mod joints; mod lineage; diff --git a/crates/doc/src/walk.rs b/crates/doc/src/walk.rs index aabbf8a8bb..b484b4206a 100644 --- a/crates/doc/src/walk.rs +++ b/crates/doc/src/walk.rs @@ -43,6 +43,8 @@ impl Walk for f64 {} impl Walk for usize {} impl Walk for u32 {} impl Walk for ProfileSel {} +impl Walk for std::collections::BTreeMap {} +impl Walk for crate::frame::Anchor {} impl Walk for solidcraft_sketch::Sketch {} impl Walk for solidcraft_sketch::PatternAlignment {} impl Walk for solidcraft_kernel::StepNode {} @@ -317,7 +319,7 @@ impl &'static str> Label for F { /// The offsets and angles of a plane, outermost first, and an explicit origin's coordinates /// (nesting is capped like every walk over a plane), the construction plane it is built on, and -/// the plane itself. +/// the plane itself. Feature references also reach the geometry used to construct a plane. impl Walk for PlaneRef { fn walk_exprs<'a>(&'a self, f: &mut dyn FnMut(ExprItem<'a>)) { let mut p = self; @@ -372,24 +374,10 @@ impl Walk for PlaneRef { } } fn walk_refs<'a>(&'a self, f: &mut dyn FnMut(RefItem<'a>)) { - let mut p = self; - for _ in 0..64 { - match p { - PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::Flipped { base, .. } => p = base, - PlaneRef::Construction { name } => return f(Ref::Feature(name)), - _ => return, - } - } + self.walk_feature_refs(f, 0); } fn walk_refs_mut<'a>(&'a mut self, f: &mut dyn FnMut(RefItemMut<'a>)) { - let mut p = self; - for _ in 0..64 { - match p { - PlaneRef::Offset { base, .. } | PlaneRef::AtAngle { base, .. } | PlaneRef::Flipped { base, .. } => p = base, - PlaneRef::Construction { name } => return f(Ref::Feature(name)), - _ => return, - } - } + self.walk_feature_refs_mut(f, 0); } fn walk_geometry<'a>(&'a self, f: &mut dyn FnMut(GeoItem<'a>)) { f(Geo::Plane(self)) @@ -399,9 +387,92 @@ impl Walk for PlaneRef { } } +impl PlaneRef { + fn walk_feature_refs<'a>(&'a self, f: &mut dyn FnMut(RefItem<'a>), depth: usize) { + if depth >= 64 { + return; + } + match self { + Self::Construction { name } => f(Ref::Feature(name)), + Self::Offset { base, .. } | Self::AtAngle { base, .. } | Self::Flipped { base, .. } => base.walk_feature_refs(f, depth + 1), + Self::Midplane { a, b } | Self::TwoEdges { a, b } | Self::Perpendicular { plane: a, line: b } => { + a.walk_feature_refs(f, depth + 1); + b.walk_feature_refs(f, depth + 1); + } + Self::ThreePoints { a, b, c } => { + a.walk_feature_refs(f, depth + 1); + b.walk_feature_refs(f, depth + 1); + c.walk_feature_refs(f, depth + 1); + } + Self::Tangent { face, .. } | Self::AlongPath { path: face, .. } => face.walk_feature_refs(f, depth + 1), + Self::Origin { .. } | Self::Custom { .. } | Self::Placed { .. } | Self::Face { .. } => {} + } + } + + fn walk_feature_refs_mut<'a>(&'a mut self, f: &mut dyn FnMut(RefItemMut<'a>), depth: usize) { + if depth >= 64 { + return; + } + match self { + Self::Construction { name } => f(Ref::Feature(name)), + Self::Offset { base, .. } | Self::AtAngle { base, .. } | Self::Flipped { base, .. } => base.walk_feature_refs_mut(f, depth + 1), + Self::Midplane { a, b } | Self::TwoEdges { a, b } | Self::Perpendicular { plane: a, line: b } => { + a.walk_feature_refs_mut(f, depth + 1); + b.walk_feature_refs_mut(f, depth + 1); + } + Self::ThreePoints { a, b, c } => { + a.walk_feature_refs_mut(f, depth + 1); + b.walk_feature_refs_mut(f, depth + 1); + c.walk_feature_refs_mut(f, depth + 1); + } + Self::Tangent { face, .. } | Self::AlongPath { path: face, .. } => face.walk_feature_refs_mut(f, depth + 1), + Self::Origin { .. } | Self::Custom { .. } | Self::Placed { .. } | Self::Face { .. } => {} + } + } +} + +impl GeoRef { + // Carry the depth across GeoRef -> PlaneRef -> GeoRef, rather than restarting its cap. + fn walk_feature_refs<'a>(&'a self, f: &mut dyn FnMut(RefItem<'a>), depth: usize) { + if depth >= 64 { + return; + } + match self { + Self::Axis { name } | Self::ConstructPoint { name } => f(Ref::Feature(name)), + Self::Plane { plane } => plane.walk_feature_refs(f, depth + 1), + Self::Point { .. } + | Self::Origin + | Self::Vertex { .. } + | Self::Edge { .. } + | Self::Face { .. } + | Self::World { .. } + | Self::SketchPoint { .. } + | Self::SketchCurve { .. } => {} + } + } + + fn walk_feature_refs_mut<'a>(&'a mut self, f: &mut dyn FnMut(RefItemMut<'a>), depth: usize) { + if depth >= 64 { + return; + } + match self { + Self::Axis { name } | Self::ConstructPoint { name } => f(Ref::Feature(name)), + Self::Plane { plane } => plane.walk_feature_refs_mut(f, depth + 1), + Self::Point { .. } + | Self::Origin + | Self::Vertex { .. } + | Self::Edge { .. } + | Self::Face { .. } + | Self::World { .. } + | Self::SketchPoint { .. } + | Self::SketchCurve { .. } => {} + } + } +} + /// Construction axes and points: the expressions of the planes they are built from (and a point -/// along a path's distance), and their references to the model. They name no sketches, features -/// or bodies: copies remap their geometry references on their own (`Feature::remap_clone`). +/// along a path's distance), and their feature and geometry references. Copies still remap +/// sketch ids and persistent topology names through their geometry (`Feature::remap_clone`). fn plane_exprs<'a>(refs: Vec<&'a GeoRef>, f: &mut dyn FnMut(ExprItem<'a>)) { for r in refs { if let GeoRef::Plane { plane } = r { @@ -420,6 +491,16 @@ fn plane_exprs_mut<'a>(refs: Vec<&'a mut GeoRef>, f: &mut dyn FnMut(ExprItemMut< macro_rules! construct_geometry { () => { + fn walk_refs<'a>(&'a self, f: &mut dyn FnMut(RefItem<'a>)) { + for r in self.refs() { + r.walk_feature_refs(f, 0); + } + } + fn walk_refs_mut<'a>(&'a mut self, f: &mut dyn FnMut(RefItemMut<'a>)) { + for r in self.refs_mut() { + r.walk_feature_refs_mut(f, 0); + } + } fn walk_geometry<'a>(&'a self, f: &mut dyn FnMut(GeoItem<'a>)) { for r in self.refs() { f(Geo::Construct(r)); diff --git a/crates/engine/src/boolean_regression_tests.rs b/crates/engine/src/boolean_regression_tests.rs index af5135924a..af7a91df39 100644 --- a/crates/engine/src/boolean_regression_tests.rs +++ b/crates/engine/src/boolean_regression_tests.rs @@ -1298,3 +1298,112 @@ fn intersect_with_every_body_still_drops_the_ones_it_misses() -> crate::Result<( assert!((volume(&s, "Near") - 500.0).abs() < 1e-6); Ok(()) } + +/// #451: a spherical shell (r 18.5 to 40, revolved on XY) cut from a block, or joined with it. +/// Swept with its two radial lines, the shell had zero-area faces on the axis joining the outer +/// sphere to the inner one, and a cut through the inner sphere's pole failed. As a solid with a +/// void, the boolean took its shells one at a time: a cut through both spheres kept only the piece +/// in the hollow, and a join failed. +#[test] +fn spherical_shell_cuts_and_joins_a_block_crossing_its_hollow() { + let r: f64 = 18.5; + // ∫∫ √(R² − y² − z²) dy dz over the blocks' 8 × 8 section, the inner integral in closed form, + // the outer by Simpson's rule. + let under = |big: f64| { + let inner = |y: f64| { + let a2 = big * big - y * y; + let f = |z: f64| 0.5 * (z * (a2 - z * z).sqrt() + a2 * (z / a2.sqrt()).asin()); + f(4.0) - f(-4.0) + }; + let n = 200; + let h = 8.0 / n as f64; + (0..n / 2).map(|i| -4.0 + 2.0 * i as f64 * h).map(|y| inner(y) + 4.0 * inner(y + h) + inner(y + 2.0 * h)).sum::() * h / 3.0 + }; + // From x = 8 to the inner sphere, and from the outer sphere to x = 50. + let (hollow, beyond) = (under(r) - 8.0 * 64.0, 50.0 * 64.0 - under(40.0)); + let shell = 4.0 / 3.0 * std::f64::consts::PI * (40.0f64.powi(3) - r.powi(3)); + for (x0, length, operation, want) in [ + (-4.0, 8.0, "cut", 512.0), + (8.0, 12.0, "cut", hollow), + (8.0, 42.0, "cut", hollow + beyond), + (8.0, 12.0, "join", shell + hollow), + (8.0, 42.0, "join", shell + hollow + beyond), + ] { + let mut s = Session::default(); + let mut run = |c: &str, p: Value| s.execute(c, &p).unwrap(); + run("solid.box", json!({"corner": [x0, -4, -4], "length": length, "width": 8, "height": 8, "body_name": "Block"})); + run("sketch.create", json!({"plane": "XY", "name": "Ring"})); + run("sketch.line", json!({"points": [[-40, 0], [40, 0]]})); + run("sketch.arc.center_point", json!({"center": [0, 0], "start": [40, 0], "end": [-40, 0]})); + run("sketch.arc.center_point", json!({"center": [0, 0], "start": [r, 0], "end": [-r, 0]})); + run("sketch.finish", json!({})); + run("solid.revolve", json!({"sketch": "Ring", "profiles": [{"point": [0, 30]}], "axis": "l1", "operation": operation, "targets": ["Block"]})); + // A cut through both spheres leaves two pieces, one each side of the shell. + let state = s.model.state(); + let got: f64 = state.bodies.iter().map(|b| solidcraft_kernel::measure(&b.body).unwrap().volume).sum(); + println!("#451 {operation} of the block from x = {x0} to {}: {got:.6} mm³ in {} bodies, expected {want:.6}", x0 + length, state.bodies.len()); + assert!((got - want).abs() < 1e-4 * want, "{operation} from x = {x0} to {}: {got} vs {want}", x0 + length); + assert!(state.bodies.iter().all(|b| b.body.validity().is_empty())); + } +} + +/// #4434: a Join whose profile stands apart from the body still joins it, as a second piece of +/// `Body1`, and says so; scripts read it from the reply as History shows it. A join that meets +/// the body, one that closes a cavity (a void is not a piece) and a Combine of bodies chosen +/// apart say nothing. +#[test] +fn join_that_meets_no_body_keeps_the_piece_and_says_so() { + let apart = "`Body1` is now in 2 separate pieces"; + let two_squares = || { + let mut s = Session::default(); + s.execute("sketch.create", &json!({"plane": "XY", "name": "Sketch1"})).unwrap(); + s.execute("sketch.rectangle.two_point", &json!({"p0": [0, 0], "p1": [20, 20]})).unwrap(); + s.execute("sketch.rectangle.two_point", &json!({"p0": [40, 0], "p1": [60, 20]})).unwrap(); + s.execute( + "solid.extrude", + &json!({"sketch": "Sketch1", "profiles": [{"point": [10, 10]}], "distance": 10, "operation": "new", "name": "Extrude1"}), + ) + .unwrap(); + s + }; + let mut s = two_squares(); + let join = s + .execute( + "solid.extrude", + &json!({"sketch": "Sketch1", "profiles": [{"point": [50, 10]}], "distance": 10, "operation": "join", "name": "Extrude2"}), + ) + .unwrap(); + assert!(join["warning"].as_str().is_some_and(|w| w.contains(apart)), "{join}"); + assert_eq!(s.model.results.last().and_then(|r| r.warning.as_deref()), join["warning"].as_str()); + assert_eq!(s.model.state().bodies.iter().map(|b| b.name.as_str()).collect::>(), ["Body1"]); + assert!((volume(&s, "Body1") - 8000.0).abs() < 1e-6, "{}", volume(&s, "Body1")); + + // Both profiles at once, one on the body and one apart: joined as one tool, still told. + let mut s = two_squares(); + let both = s + .execute("solid.extrude", &json!({"sketch": "Sketch1", "profiles": [{"point": [10, 10]}, {"point": [50, 10]}], "distance": 5, "start_offset": 10, "operation": "join"})) + .unwrap(); + assert!(both["warning"].as_str().is_some_and(|w| w.contains(apart)), "{both}"); + + // A box joined apart from the body, then one bridging both pieces: only the first is told. + let mut s = two_squares(); + let box_apart = s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10, "center": [10, 40, 5], "operation": "join"})).unwrap(); + assert!(box_apart["warning"].as_str().is_some_and(|w| w.contains(apart)), "{box_apart}"); + let bridge = s.execute("solid.box", &json!({"length": 10, "width": 30, "height": 10, "center": [10, 25, 5], "operation": "join"})).unwrap(); + assert!(bridge["warning"].is_null(), "{bridge}"); + + // A lid joined on a cup closes a cavity: one piece with a void, nothing to say. + let mut s = Session::default(); + s.execute("solid.box", &json!({"length": 20, "width": 20, "height": 10, "center": [0, 0, 5], "operation": "new"})).unwrap(); + s.execute("solid.box", &json!({"length": 16, "width": 16, "height": 10, "center": [0, 0, 6], "operation": "cut"})).unwrap(); + let lid = s.execute("solid.box", &json!({"length": 20, "width": 20, "height": 2, "center": [0, 0, 11], "operation": "join"})).unwrap(); + assert_eq!(s.model.state().body("Body1").map(|b| b.body.shell_count()), Some(2)); + assert!(lid["warning"].is_null(), "{lid}"); + + // Combine joins the bodies it is given, apart or not. + let mut s = Session::default(); + s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10, "center": [0, 0, 5], "operation": "new", "body_name": "A"})).unwrap(); + s.execute("solid.box", &json!({"length": 10, "width": 10, "height": 10, "center": [30, 0, 5], "operation": "new", "body_name": "B"})).unwrap(); + let combine = s.execute("solid.combine", &json!({"target": "A", "tools": ["B"], "operation": "join"})).unwrap(); + assert!(combine["warning"].is_null(), "{combine}"); +} diff --git a/crates/engine/src/cmd/browser.rs b/crates/engine/src/cmd/browser.rs index 75577cbced..1e2e1abaef 100644 --- a/crates/engine/src/cmd/browser.rs +++ b/crates/engine/src/cmd/browser.rs @@ -4,6 +4,8 @@ //! Browser items are keyed per component folder (`bodies`, `sketches`, `construction`): a body //! by name, a sketch or construction plane by feature id. +use std::collections::BTreeMap; + use serde_json::{Value, json}; use solidcraft_doc::FeatureKind; @@ -539,6 +541,23 @@ fn replace_body_name(v: &mut Value, old: &str, new: &str, names_body: bool, dept } } +/// A frame member feature's bodies by line id, as its own result made them: what pins each +/// member's name to its line. `None` for any other feature, or when they don't pair up. +fn member_pins(s: &Session, fid: u64) -> Option> { + let f = s.doc.feature(fid)?; + let result = s.model.result(fid)?; + let lines = solidcraft_doc::frame::member_line_ids(&result.state, &f.kind).ok()?; + let made: Vec = result.state.bodies.iter().filter(|b| b.feature == fid).map(|b| b.name.clone()).collect(); + (lines.len() == made.len()).then(|| lines.into_iter().zip(made).collect()) +} + +/// Whether the `i`-th new body of `f` is numbered by the body counter (`BodyN`): it has no stored +/// name of its own and is not numbered after the feature's first name (`Ribs (4)`). +pub(crate) fn counted(f: &solidcraft_doc::Feature, i: usize) -> bool { + let first = f.body_names.first().is_some_and(|n| !n.trim().is_empty()); + f.body_names.get(i).is_none_or(|n| n.trim().is_empty()) && (f.shell_piece_order || i < f.body_names.len() || !first) +} + fn rename_body(s: &mut Session, p: &Value) -> Result { let cmd = "body.rename"; let old = str_(p, "body").ok_or_else(|| bad(cmd, "`body` must be a body name"))?.to_string(); @@ -566,10 +585,6 @@ fn rename_body(s: &mut Session, p: &Value) -> Result { // own, and every counted body after it would move down one (`Body3` becoming `Body1`) and // break what names it. Those later features keep the names they made too (#1109): each one's // own result holds them, also for bodies a later feature joins away. - let counted = |f: &solidcraft_doc::Feature, i: usize| { - let first = f.body_names.first().is_some_and(|n| !n.trim().is_empty()); - f.body_names.get(i).is_none_or(|n| n.trim().is_empty()) && (f.shell_piece_order || i < f.body_names.len() || !first) - }; // Bodies later features name after this one (`Top (1)`, a split's other piece) keep their // names too: they would otherwise follow it, and break what names them (#3510). let derived = format!("{old} ("); @@ -589,22 +604,40 @@ fn rename_body(s: &mut Session, p: &Value) -> Result { .collect(), None => Vec::new(), }; + // Frame members are named by their lines (#4003): each keeps the name it has now on its + // line, whichever lines come and go later, and the renamed one takes its new name. + let later_pins: Vec>> = later.iter().map(|(id, _)| member_pins(s, *id)).collect(); + let own_pins = member_pins(s, fid).and_then(|mut pins| { + let line = pins.iter().find(|(_, n)| **n == old).map(|(line, _)| line.clone())?; + pins.insert(line, name.to_string()); + Some(pins) + }); let d = s.doc_mut(); - for (id, made) in &later { + for ((id, made), pins) in later.iter().zip(later_pins) { if let Some(f) = d.feature_mut(*id) { - f.body_names.clone_from(made); + if let (Some(pins), FeatureKind::FrameMember { names, .. }) = (pins, &mut f.kind) { + *names = pins; + f.body_names.clear(); + } else { + f.body_names.clone_from(made); + } } } let f = d.feature_mut(fid).ok_or_else(|| bad(cmd, "the body's feature is gone"))?; - while f.body_names.len() < siblings.len().max(k + 1) { - let i = f.body_names.len(); - f.body_names.push(siblings.get(i).cloned().unwrap_or_default()); - } - for (slot, current) in f.body_names.iter_mut().zip(&siblings) { - slot.clone_from(current); - } - if let Some(slot) = f.body_names.get_mut(k) { - *slot = name.to_string(); + if let (Some(pins), FeatureKind::FrameMember { names, .. }) = (own_pins, &mut f.kind) { + *names = pins; + f.body_names.clear(); + } else { + while f.body_names.len() < siblings.len().max(k + 1) { + let i = f.body_names.len(); + f.body_names.push(siblings.get(i).cloned().unwrap_or_default()); + } + for (slot, current) in f.body_names.iter_mut().zip(&siblings) { + slot.clone_from(current); + } + if let Some(slot) = f.body_names.get_mut(k) { + *slot = name.to_string(); + } } // Later features refer to bodies by name. let mut failed = None; diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index 7165824eeb..f9d1e972e4 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -11,6 +11,8 @@ use super::CommandSpec; use crate::params::{bad, bool_, str_, string_list, vec3}; use crate::{Result, Session}; +#[path = "component_drag.rs"] +mod drag; #[path = "component_matrix.rs"] pub(super) mod matrix; #[path = "component_properties.rs"] @@ -27,7 +29,7 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("component.create", "New Component", new_component) .at("SOLID", "ASSEMBLE") .icon("component") - .params("name?, parent?: component id or name (default: the active one); parent_path?: exact parent placement; activate?: bool (default true)"), + .params("name?, parent?: component id or name (default: the active one); parent_path?: exact parent placement; activate?: bool (default true); grounded?: bool (default: true for the design's first part, false otherwise)"), CommandSpec::new("component.from_bodies", "Create Components from Bodies", from_bodies) .icon("component") .params("bodies: [names] → one new component per body, named after it"), @@ -54,7 +56,12 @@ pub static COMMANDS: &[CommandSpec] = &[ .params("component (or occurrence): what to place again; path?: validates selected occurrence; parent?: component (default: the same parent); translate?: [x,y,z]. Copies the selected effective transform, including pending moves"), CommandSpec::new("component.paste_new", "Paste New", paste_new).icon("paste").params("component: independently copy its nested definition graph and history, materials and styles; occurrence?, path?: selected source placement; translate?: [x,y,z]. Reassigned external bodies become base geometry; retained external links and omitted boundary joints are reported"), CommandSpec::new("occurrence.move", "Move Occurrence", move_occurrence).icon("move") - .params("occurrence; path?: exact placement; space?: local|world (default local); translate?: [x,y,z]; axis?: [x,y,z], angle?: radians or \"30 deg\", origin?: [x,y,z]; matrix?: [12 or 16 numbers] (instead of translate/axis/angle/origin: a rigid move as a row-major 3x4 rotation-and-translation or 4x4 matrix, flat or as rows; one that is a rotation only up to rounding is orthonormalised, a scale, shear or mirror is refused); tolerance?: largest accepted |RᵀR − I| (default 1e-9, at most 1e-3); capture?: bool (default true; false keeps it pending until Capture Position). Nested occurrence definitions remain shared"), + .params("occurrence; path?: exact placement; space?: local|world (default local); translate?: [x,y,z]; axis?: [x,y,z], angle?: radians or \"30 deg\", origin?: [x,y,z]; matrix?: [12 or 16 numbers] (instead of translate/axis/angle/origin: a rigid move as a row-major 3x4 rotation-and-translation or 4x4 matrix, flat or as rows; one that is a rotation only up to rounding is orthonormalised, a scale, shear or mirror is refused); tolerance?: largest accepted |RᵀR − I| (default 1e-9, at most 1e-3); capture?: bool (default true; false keeps it pending until Capture Position); move_grounded?: bool (default false: a grounded occurrence refuses; true moves it and it stays grounded). Nested occurrence definitions remain shared"), + CommandSpec::new("occurrence.drag", "Drag Occurrence", drag::drag).icon("move").params( + "occurrence or path: exact placement; at: [x,y,z] world point on it; point: [x,y,z] where it goes. Moves the placement by \ + point - at (with any chain of joints it is in that is joined to nothing grounded) and captures its position; other pending \ + moves stay pending. Shared definitions move in all placements. Grounded parts and parts joints hold to something fixed are refused (joint.reach moves those)", + ), CommandSpec::new("occurrence.ground", "Ground", ground).icon("ground").params("occurrence; grounded?: bool (default toggles)"), CommandSpec::new("component.capture_position", "Capture Position", capture).at("SOLID", "POSITION").icon("capture").params("keeps pending occurrence moves"), CommandSpec::new("component.revert_position", "Revert Position", revert).at("SOLID", "POSITION").icon("revert").noundo().params("occurrences?: [ids] (default all); drops selected pending occurrence moves"), @@ -110,10 +117,10 @@ pub(super) fn valid_position(m: &Mat) -> bool { pub(super) fn new_component(s: &mut Session, p: &Value) -> Result { let cmd = "component.create"; if let Some(params) = p.as_object() - && let Some(key) = params.keys().find(|key| !matches!(key.as_str(), "name" | "parent" | "parent_path" | "activate")) + && let Some(key) = params.keys().find(|key| !matches!(key.as_str(), "name" | "parent" | "parent_path" | "activate" | "grounded")) { let hint = if params.contains_key("bodies") { "; use component.move_bodies or component.from_bodies to move bodies" } else { "" }; - return Err(bad(cmd, format!("unknown parameter `{key}`; accepted parameters: name, parent, parent_path, activate{hint}"))); + return Err(bad(cmd, format!("unknown parameter `{key}`; accepted parameters: name, parent, parent_path, activate, grounded{hint}"))); } let parent = if p.get("parent").is_some() { component(s, p, "parent", cmd)? } else { s.active_component }; let parent_path = if let Some(value) = p.get("parent_path") { @@ -135,7 +142,16 @@ pub(super) fn new_component(s: &mut Session, p: &Value) -> Result { if parent_path.len() >= 64 { return Err(bad(cmd, "new component exceeds occurrence depth limit")); } + let grounded = match p.get("grounded") { + None => None, + Some(v) => Some(v.as_bool().ok_or_else(|| bad(cmd, "grounded must be true or false"))?), + }; let id = s.doc_mut().add_component(str_(p, "name"), parent)?; + if let Some(grounded) = grounded + && let Some(o) = s.doc_mut().occurrences.iter_mut().find(|o| o.component == id) + { + o.grounded = grounded; + } if p.get("activate").and_then(Value::as_bool).unwrap_or(true) { let mut path = parent_path; if let Some(o) = s.doc.occurrence_of(id) { @@ -145,8 +161,8 @@ pub(super) fn new_component(s: &mut Session, p: &Value) -> Result { s.active_occurrence_path = Some(path); } let name = s.doc.components.iter().find(|c| c.id == id).map(|c| c.name.clone()).unwrap_or_default(); - let occ = s.doc.occurrence_of(id).map(|o| o.id); - Ok(json!({"component": id, "name": name, "parent": parent, "occurrence": occ})) + let occ = s.doc.occurrence_of(id); + Ok(json!({"component": id, "name": name, "parent": parent, "occurrence": occ.map(|o| o.id), "grounded": occ.is_some_and(|o| o.grounded)})) } fn from_bodies(s: &mut Session, p: &Value) -> Result { @@ -244,31 +260,15 @@ fn delete(s: &mut Session, p: &Value) -> Result { if id == 0 { return Err(bad(cmd, "the design itself can't be deleted")); } - let gone: Vec = s.doc.components.iter().filter(|c| s.doc.component_within(c.id, id)).map(|c| c.id).collect(); - if s.doc.features.iter().any(|feature| { - !gone.contains(&feature.component) - && matches!(&feature.kind, FeatureKind::BodyTransfer { from, to, .. } if gone.contains(from) || gone.contains(to)) - }) { - return Err(bad( - cmd, - "component supplies a recorded body transfer outside this subtree; remove that transfer history before deleting its source", - )); - } - let feats: Vec = s.doc.features.iter().filter(|f| gone.contains(&f.component)).map(|f| f.id).collect(); - let d = s.doc_mut(); - for f in &feats { - let _ = d.delete_feature(*f); - } - d.components.retain(|c| !gone.contains(&c.id)); - d.remove_configuration_components(&gone); - d.occurrences.retain(|o| !gone.contains(&o.component) && !gone.contains(&o.parent)); - let moved_out: Vec = d.body_components.iter().filter(|(_, c)| gone.contains(c)).map(|(b, _)| b.clone()).collect(); - d.body_components.retain(|_, c| !gone.contains(c)); - d.body_offsets.retain(|b, _| !moved_out.contains(b)); + let removed = s.doc_mut().remove_component_tree(id).map_err(|e| match e { + solidcraft_doc::DocError::Invalid(msg) => bad(cmd, msg), + e => e.into(), + })?; + let gone = removed.components; if gone.contains(&s.active_component) { s.active_component = 0; } - Ok(json!({"deleted": gone, "features": feats})) + Ok(json!({"deleted": gone, "features": removed.features})) } fn unlock(s: &mut Session, p: &Value) -> Result { @@ -410,8 +410,11 @@ fn move_occurrence(s: &mut Session, p: &Value) -> Result { let cmd = "occurrence.move"; let id = occurrence(s, p, cmd)?; let o = s.doc.occurrences.iter().find(|o| o.id == id).cloned().ok_or_else(|| bad(cmd, "occurrence"))?; - if o.grounded { - return Err(bad(cmd, format!("{} is grounded", o.name))); + if o.grounded && bool_(p, "move_grounded") != Some(true) { + return Err(bad( + cmd, + format!("{} is grounded: unground it first (occurrence.ground; a design's first part is grounded when it is made)", o.name), + )); } if let Some(mut group) = s.held_by_rigid_group(id) { // The outermost component the group holds it in moves it. diff --git a/crates/engine/src/cmd/component_clone.rs b/crates/engine/src/cmd/component_clone.rs index 52ce952b3e..cd0eb0c8f8 100644 --- a/crates/engine/src/cmd/component_clone.rs +++ b/crates/engine/src/cmd/component_clone.rs @@ -275,7 +275,8 @@ pub(super) fn paste( clone_path.push(root_occurrence); if let Some(o) = scratch.doc_mut().occurrences.iter_mut().find(|o| o.id == root_occurrence) { o.transform = transform; - o.grounded = source_occurrence.as_ref().is_some_and(|o| o.grounded); + // A copy starts free, even of a grounded part (#2804); the parts inside it keep theirs. + o.grounded = false; } if let Some(o) = &source_occurrence { occurrences.insert(o.id, root_occurrence); diff --git a/crates/engine/src/cmd/component_clone_tests.rs b/crates/engine/src/cmd/component_clone_tests.rs index 081d403857..1f55c30a88 100644 --- a/crates/engine/src/cmd/component_clone_tests.rs +++ b/crates/engine/src/cmd/component_clone_tests.rs @@ -5,7 +5,7 @@ fn run(s: &mut Session, cmd: &str, p: Value) -> Value { s.execute(cmd, &p).unwrap() } fn component(s: &mut Session, name: &str) -> u64 { - run(s, "component.create", json!({"name":name}))["component"].as_u64().unwrap() + run(s, "component.create", json!({"name":name,"grounded":false}))["component"].as_u64().unwrap() } fn volume(s: &mut Session, id: u64) -> f64 { run(s, "component.properties", json!({"component":id}))["total"]["volume_mm3"].as_f64().unwrap() @@ -75,6 +75,25 @@ fn reassigned_body_is_an_independent_base_feature() { near(volume(&mut s, new), 8.0); } +#[test] +fn deleting_an_independent_base_keeps_the_copied_component() { + let mut s = Session::default(); + box2(&mut s); + let part = component(&mut s, "Part"); + run(&mut s, "component.move_bodies", json!({"component":part,"bodies":["Cube"]})); + let new = copy(&mut s, part)["component"].as_u64().unwrap(); + let base = s.doc.features.iter().find(|f| f.component == new && matches!(f.kind, solidcraft_doc::FeatureKind::Import { .. })).unwrap(); + assert!(!base.inserted && !s.doc.feature_owns_component(base)); + let (base, others) = (base.id, s.doc.features.iter().filter(|f| f.component == new).count() - 1); + let (components, occurrences, joints) = (s.doc.components.clone(), s.doc.occurrences.clone(), s.doc.assembly.joints.clone()); + let reply = run(&mut s, "timeline.delete", json!({"feature": base.to_string()})); + assert_eq!(reply["deleted"], json!([base])); + assert_eq!(s.doc.components, components); + assert_eq!(s.doc.occurrences, occurrences); + assert_eq!(s.doc.assembly.joints, joints); + assert_eq!(s.doc.features.iter().filter(|f| f.component == new).count(), others); +} + #[test] fn clone_preserves_physical_material_and_independent_styles() { let mut s = Session::default(); diff --git a/crates/engine/src/cmd/component_drag.rs b/crates/engine/src/cmd/component_drag.rs new file mode 100644 index 0000000000..1815a79d86 --- /dev/null +++ b/crates/engine/src/cmd/component_drag.rs @@ -0,0 +1,162 @@ +//! Dragging a part to put it somewhere (#2804): what the viewport's left-button drag on a free +//! part runs each frame, and what scripts and agents use to move a placement by a grabbed point. +//! A grounded part stays where it is, and so does one that joints hold to something fixed +//! (`joint.reach` moves those as far as their joints allow). A chain of joints joined to nothing +//! fixed moves as one, so its joints stay as they are. + +use serde_json::{Value, json}; +use solidcraft_doc::{mat_inverse, mat_mul}; + +use super::{move_vector, occurrence, occurrence_path, translation, valid_position}; +use crate::params::bad; +use crate::{EngineError, Result, Session}; + +#[cfg(test)] +#[path = "component_drag_tests.rs"] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests; + +/// Most placements one drag moves (the dragged one and the chain of joints it is in). +const MAX_MOVED: usize = 1000; + +fn refused(message: String) -> EngineError { + EngineError::Other(message) +} + +impl Session { + /// The placements a drag on the placement `path` moves, each by its exact path: that + /// placement first, then those a chain of joints joined to nothing fixed carries along with + /// it. An error says why it can't be dragged (grounded, held by joints to something fixed, + /// or held in a rigid group). Shared definitions move in all of their placements. + pub fn drag_group(&self, path: &[u64]) -> Result>> { + let placement = self.occurrence_placement(path)?; + let doc = &self.doc; + let name = |id: u64| doc.occurrences.iter().find(|o| o.id == id).map_or_else(|| id.to_string(), |o| o.name.clone()); + let dragged = placement.occurrence; + if placement.grounded { + return Err(refused(format!("{} is grounded", placement.name))); + } + if let Some(group) = self.held_by_rigid_group(dragged) { + let group = doc.components.iter().find(|c| c.id == group).map(|c| c.name.clone()).unwrap_or_default(); + return Err(refused(format!("{} is held in {group} by a rigid group: drag {group}, or Unlock Contents first", placement.name))); + } + // Is `o` the placement `top` or inside its component (in every placement of it)? + let within = |o: u64, top: u64| -> bool { + if o == top { + return true; + } + let (Some(o), Some(top)) = (doc.occurrences.iter().find(|x| x.id == o), doc.occurrences.iter().find(|x| x.id == top)) else { + return false; + }; + o.parent != 0 && doc.component_within(o.parent, top.component) + }; + let joints: Vec<_> = doc.assembly.joints.iter().filter(|j| !j.suppressed).collect(); + let mut members = vec![dragged]; + // Grow the chain one joint at a time; each pass adds a member or stops. + for _ in 0..MAX_MOVED { + let inside = |o: u64| members.iter().any(|m| within(o, *m)); + let next = joints.iter().find_map(|j| match (inside(j.a.occurrence), inside(j.b.occurrence)) { + (true, false) => Some((j, j.b.occurrence)), + (false, true) => Some((j, j.a.occurrence)), + _ => None, + }); + let Some((joint, other)) = next else { + break; + }; + if other == 0 { + return Err(refused(format!("{} holds {} to the design's origin", joint.name, placement.name))); + } + // Only this member's placement is edited. A grounded ancestor fixes its own + // transform, while its free contents may still move inside that frame. + if doc.occurrences.iter().any(|o| o.id == other && o.grounded) { + return Err(refused(format!( + "{} holds {} to grounded {}: it moves only as far as its joints allow (joint.reach)", + joint.name, + placement.name, + name(other) + ))); + } + if path.contains(&other) || within(dragged, other) { + return Err(refused(format!("{} holds {} to the assembly it is in: drag {}", joint.name, placement.name, name(other)))); + } + members.push(other); + if members.len() > MAX_MOVED { + return Err(refused(format!("the chain of joints {} is in moves more than {MAX_MOVED} parts", placement.name))); + } + } + // A member inside another one moves with it (moving both would move it twice). + let tops: Vec = members.iter().copied().filter(|m| !members.iter().any(|o| o != m && within(*m, *o))).collect(); + let mut out = vec![path.to_vec()]; + for m in tops.into_iter().filter(|m| *m != dragged) { + let parent = doc.occurrences.iter().find(|o| o.id == m).map_or(0, |o| o.parent); + let mut chain = doc.first_path(parent); + chain.push(m); + out.push(chain); + } + Ok(out) + } +} + +/// `occurrence.drag`: move a placement by `point - at` in world coordinates (and the free chain +/// of joints it is in), capturing its position. +pub(super) fn drag(s: &mut Session, p: &Value) -> Result { + let cmd = "occurrence.drag"; + let path = if p.get("occurrence").is_some() { + let id = occurrence(s, p, cmd)?; + occurrence_path(s, p, id, cmd)? + } else { + let path = p + .get("path") + .and_then(Value::as_array) + .filter(|a| !a.is_empty() && a.len() <= 64) + .and_then(|a| a.iter().map(Value::as_u64).collect::>>()) + .ok_or_else(|| bad(cmd, "give `occurrence` or `path` (1..64 occurrence ids)"))?; + s.occurrence_placement(&path).map_err(|e| bad(cmd, e.to_string()))?; + path + }; + if p.get("at").is_none() || p.get("point").is_none() { + return Err(bad(cmd, "`at` and `point` must both be given")); + } + let at = move_vector(p, "at", Default::default(), true, cmd)?; + let point = move_vector(p, "point", Default::default(), true, cmd)?; + let t = point - at; + let world_move = translation(t); + let group = s.drag_group(&path).map_err(|e| bad(cmd, e.to_string()))?; + let dragged = s.occurrence_placement(&path).map_err(|e| bad(cmd, e.to_string()))?; + let inverse = mat_inverse(&dragged.parent_world).ok_or_else(|| bad(cmd, "parent frame is singular"))?; + let local_delta = mat_mul(&mat_mul(&inverse, &world_move), &dragged.parent_world); + // Joints refer to shared occurrence definitions, and the solver places them through their + // first path. Carry the selected placement's local change into that same frame before + // moving its peers. Using the pointer's world delta directly would break the joint when + // the selected instance has a different rotation from the first one. + let parent = s.doc.occurrences.iter().find(|o| o.id == dragged.occurrence).map_or(0, |o| o.parent); + let mut first_path = s.doc.first_path(parent); + first_path.push(dragged.occurrence); + let first = s.occurrence_placement(&first_path).map_err(|e| bad(cmd, e.to_string()))?; + let first_inverse = mat_inverse(&first.parent_world).ok_or_else(|| bad(cmd, "parent frame is singular"))?; + let world_delta = mat_mul(&mat_mul(&first.parent_world, &local_delta), &first_inverse); + // Every new position first, from the placements as they are, then all of them at once. + let mut moves = Vec::with_capacity(group.len()); + for placement_path in &group { + let placement = s.occurrence_placement(placement_path).map_err(|e| bad(cmd, e.to_string()))?; + let inverse = mat_inverse(&placement.parent_world).ok_or_else(|| bad(cmd, "parent frame is singular"))?; + let delta = + if placement.occurrence == dragged.occurrence { local_delta } else { mat_mul(&mat_mul(&inverse, &world_delta), &placement.parent_world) }; + let next = mat_mul(&delta, &placement.local); + if !valid_position(&next) { + return Err(bad(cmd, "drag exceeds supported numeric range")); + } + moves.push((placement.occurrence, next)); + } + let moved: Vec = moves.iter().map(|(id, _)| *id).collect(); + if world_move != solidcraft_doc::IDENTITY { + for (id, next) in moves { + s.pending_moves.remove(&id); + if let Some(o) = s.doc_mut().occurrences.iter_mut().find(|o| o.id == id) { + o.transform = next; + } + } + } + let continuous = p.get("continuous").and_then(Value::as_bool).unwrap_or(false); + Ok(json!({"occurrence": path.last(), "path": path, "moved": moved, "translate": [t.x, t.y, t.z], "continuous": continuous})) +} diff --git a/crates/engine/src/cmd/component_drag_tests.rs b/crates/engine/src/cmd/component_drag_tests.rs new file mode 100644 index 0000000000..2f2cc3b50b --- /dev/null +++ b/crates/engine/src/cmd/component_drag_tests.rs @@ -0,0 +1,289 @@ +//! The design's first part is grounded; dragging a free part moves it (#2804). +use serde_json::{Value, json}; + +use crate::Session; + +fn run(s: &mut Session, command: &str, p: Value) -> Value { + s.execute(command, &p).unwrap_or_else(|e| panic!("{command} {p}: {e}")) +} + +/// A new top-level part holding a 10 mm cube at `corner` (activates the design first, so the +/// part goes into it): (component, occurrence). +fn part(s: &mut Session, name: &str, corner: [f64; 3]) -> (u64, u64) { + run(s, "component.activate", json!({"component": "root"})); + let c = run(s, "component.create", json!({"name": name})); + run(s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": corner})); + run(s, "component.activate", json!({"component": "root"})); + (c["component"].as_u64().unwrap(), c["occurrence"].as_u64().unwrap()) +} + +fn grounded(s: &Session, occurrence: u64) -> bool { + s.doc.occurrences.iter().find(|o| o.id == occurrence).unwrap().grounded +} + +/// Where the placement `path` puts its origin, in world coordinates (pending moves included). +fn origin(s: &Session, path: &[u64]) -> [f64; 3] { + let w = s.occurrence_placement(path).unwrap().world; + [w[3][0], w[3][1], w[3][2]] +} + +fn near(a: [f64; 3], b: [f64; 3]) { + assert!(a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-9), "{a:?} != {b:?}"); +} + +#[test] +fn the_first_top_level_part_is_grounded_and_later_parts_and_copies_are_free() { + let mut s = Session::default(); + let (a, first) = part(&mut s, "A", [0.0, 0.0, 0.0]); + let created = s.doc.occurrences.iter().find(|o| o.id == first).cloned().unwrap(); + assert!(created.grounded, "the first part is grounded"); + let (_, second) = part(&mut s, "B", [20.0, 0.0, 0.0]); + assert!(!grounded(&s, second), "a later part starts free"); + let instance = run(&mut s, "occurrence.copy", json!({"occurrence": first, "translate": [0, 20, 0]}))["occurrence"].as_u64().unwrap(); + assert!(!grounded(&s, instance), "Paste (Instance) of the grounded part is free"); + let copy = run(&mut s, "component.paste_new", json!({"component": a, "translate": [0, 40, 0]})); + let copy = s.doc.occurrence_of(copy["component"].as_u64().unwrap()).unwrap().id; + assert!(!grounded(&s, copy), "Paste New of the grounded part is free"); + // A part made inside another one is placed in it, not in the design: free. + run(&mut s, "component.activate", json!({"component": a})); + let nested = run(&mut s, "component.create", json!({"name": "Inner"}))["occurrence"].as_u64().unwrap(); + assert!(!grounded(&s, nested)); + // Several parts may be grounded, or none. + run(&mut s, "occurrence.ground", json!({"occurrence": second, "grounded": true})); + assert!(grounded(&s, first) && grounded(&s, second)); + for o in [first, second] { + run(&mut s, "occurrence.ground", json!({"occurrence": o, "grounded": false})); + } + assert!(!s.doc.occurrences.iter().any(|o| o.grounded)); +} + +#[test] +fn components_from_bodies_ground_only_the_first_part() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "One"})); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "body_name": "Two", "operation": "new"})); + let made = run(&mut s, "component.from_bodies", json!({"bodies": ["One", "Two"]})); + let occurrence = |i: usize| s.doc.occurrence_of(made["components"][i]["component"].as_u64().unwrap()).unwrap().id; + let (one, two) = (occurrence(0), occurrence(1)); + assert!(grounded(&s, one) && !grounded(&s, two)); +} + +#[test] +fn ungrounded_designs_stay_ungrounded_through_new_parts_and_reopening() { + let mut s = Session::default(); + let (_, first) = part(&mut s, "A", [0.0, 0.0, 0.0]); + run(&mut s, "occurrence.ground", json!({"occurrence": first, "grounded": false})); + let (_, second) = part(&mut s, "B", [20.0, 0.0, 0.0]); + assert!(!grounded(&s, first) && !grounded(&s, second), "a part added later grounds nothing"); + let back = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap(); + assert!(!back.occurrences.iter().any(|o| o.grounded), "opening a design grounds nothing"); + // With every part gone, the next one is the design's first part again. + for c in ["A", "B"] { + run(&mut s, "component.delete", json!({"component": c})); + } + let (_, again) = part(&mut s, "C", [0.0, 0.0, 0.0]); + assert!(grounded(&s, again)); +} + +#[test] +fn dragging_moves_a_free_part_by_the_grabbed_point_in_one_undo_step() { + let mut s = Session::default(); + let (_, base) = part(&mut s, "Base", [0.0, 0.0, 0.0]); + let (_, free) = part(&mut s, "Free", [20.0, 0.0, 0.0]); + let undo = s.undo.len(); + let r = run(&mut s, "occurrence.drag", json!({"occurrence": free, "at": [25, 5, 10], "point": [35, 10, 10]})); + assert_eq!(r["moved"], json!([free])); + near(origin(&s, &[free]), [10.0, 5.0, 0.0]); + assert_eq!(s.undo.len(), undo + 1); + assert_eq!(s.undo.last().map(|u| u.label.as_str()), Some("Drag Occurrence")); + // Saved and opened again, it is where it was dragged. + let back = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap(); + let t = back.occurrences.iter().find(|o| o.id == free).unwrap().transform; + near([t[3][0], t[3][1], t[3][2]], [10.0, 5.0, 0.0]); + run(&mut s, "edit.undo", json!({})); + near(origin(&s, &[free]), [0.0, 0.0, 0.0]); + // A grounded part stays where it is. + let e = s.execute("occurrence.drag", &json!({"occurrence": base, "at": [0, 0, 0], "point": [5, 0, 0]})).unwrap_err().to_string(); + assert!(e.contains("grounded"), "{e}"); + near(origin(&s, &[base]), [0.0, 0.0, 0.0]); +} + +#[test] +fn a_drag_captures_its_part_and_leaves_other_pending_moves_pending() { + let mut s = Session::default(); + part(&mut s, "Base", [0.0, 0.0, 0.0]); + let (_, a) = part(&mut s, "A", [20.0, 0.0, 0.0]); + let (_, b) = part(&mut s, "B", [40.0, 0.0, 0.0]); + run(&mut s, "occurrence.move", json!({"occurrence": a, "translate": [0, 7, 0], "capture": false})); + run(&mut s, "occurrence.move", json!({"occurrence": b, "translate": [0, 9, 0], "capture": false})); + run(&mut s, "occurrence.drag", json!({"occurrence": a, "at": [0, 0, 0], "point": [3, 0, 0]})); + assert!(!s.pending_moves.contains_key(&a), "the dragged part's position is captured"); + let t = s.doc.occurrences.iter().find(|o| o.id == a).unwrap().transform; + near([t[3][0], t[3][1], t[3][2]], [3.0, 7.0, 0.0]); + assert!(s.pending_moves.contains_key(&b), "another part's move stays pending"); + near(origin(&s, &[b]), [0.0, 9.0, 0.0]); + run(&mut s, "edit.undo", json!({})); + near(origin(&s, &[a]), [0.0, 7.0, 0.0]); + assert!(s.pending_moves.contains_key(&a), "undo gives the pending move back"); +} + +#[test] +fn dragging_uses_the_exact_path_of_a_repeated_placement() { + let mut s = Session::default(); + part(&mut s, "Base", [0.0, 0.0, 0.0]); + let (asm, first) = part(&mut s, "Asm", [0.0, 0.0, 0.0]); + run(&mut s, "occurrence.move", json!({"occurrence": first, "axis": [0, 0, 1], "angle": "90 deg"})); + run(&mut s, "component.activate", json!({"component": asm})); + let leaf = run(&mut s, "component.create", json!({"name": "Leaf"}))["occurrence"].as_u64().unwrap(); + run(&mut s, "solid.box", json!({"length": 2, "width": 2, "height": 2})); + run(&mut s, "component.activate", json!({"component": "root"})); + let second = run(&mut s, "occurrence.copy", json!({"occurrence": first, "translate": [100, 0, 0]}))["occurrence"].as_u64().unwrap(); + // The second instance of the assembly moves alone. + run(&mut s, "occurrence.drag", json!({"path": [second], "at": [100, 0, 0], "point": [100, 30, 0]})); + near(origin(&s, &[second]), [100.0, 30.0, 0.0]); + near(origin(&s, &[first]), [0.0, 0.0, 0.0]); + // Its leaf, a definition shared by both instances, moves in world X through that path's + // rotated frame, and so in both. + let before = origin(&s, &[first, leaf]); + run(&mut s, "occurrence.drag", json!({"path": [second, leaf], "at": [100, 30, 0], "point": [110, 30, 0]})); + near(origin(&s, &[second, leaf]), [110.0, 30.0, 0.0]); + near(origin(&s, &[first, leaf]), [before[0] + 10.0, before[1], before[2]]); + // The leaf alone, without the path of the instance it is in, is ambiguous. + let e = s.execute("occurrence.drag", &json!({"occurrence": leaf, "at": [0, 0, 0], "point": [1, 0, 0]})).unwrap_err().to_string(); + assert!(e.contains("path"), "{e}"); +} + +#[test] +fn joints_hold_a_part_to_something_grounded_and_carry_a_free_chain_along() { + let mut s = Session::default(); + let (_, base) = part(&mut s, "Base", [0.0, 0.0, 0.0]); + let (_, arm) = part(&mut s, "Arm", [0.0, 0.0, 10.0]); + let at = |o: u64, z: f64| json!({"occurrence": o, "point": [0, 0, z], "z": [0, 0, 1]}); + run(&mut s, "joint.create", json!({"type": "revolute", "a": at(base, 10.0), "b": at(arm, 10.0), "flip": true, "name": "Hinge"})); + let e = s.execute("occurrence.drag", &json!({"occurrence": arm, "at": [0, 0, 0], "point": [5, 0, 0]})).unwrap_err().to_string(); + assert!(e.contains("grounded") && e.contains("Hinge"), "{e}"); + // Two parts jointed to each other and to nothing grounded move together. + let (_, x) = part(&mut s, "X", [50.0, 0.0, 0.0]); + let (_, y) = part(&mut s, "Y", [50.0, 0.0, 10.0]); + run(&mut s, "joint.create", json!({"type": "rigid", "a": at(x, 10.0), "b": at(y, 10.0), "flip": true, "name": "Weld"})); + let (x0, y0) = (origin(&s, &[x]), origin(&s, &[y])); + let r = run(&mut s, "occurrence.drag", json!({"occurrence": y, "at": [0, 0, 0], "point": [0, 25, 0]})); + assert_eq!(r["moved"].as_array().map(Vec::len), Some(2), "{r}"); + near(origin(&s, &[x]), [x0[0], x0[1] + 25.0, x0[2]]); + near(origin(&s, &[y]), [y0[0], y0[1] + 25.0, y0[2]]); + // Suppressed, the joint holds nothing. + run(&mut s, "joint.edit", json!({"joint": "Hinge", "suppressed": true})); + run(&mut s, "occurrence.drag", json!({"occurrence": arm, "at": [0, 0, 0], "point": [5, 0, 0]})); +} + +#[test] +fn a_drag_and_a_world_move_leave_the_same_design() { + // Whatever follows a moved part (projections, joints, Properties) follows a dragged one. + let build = || { + let mut s = Session::default(); + part(&mut s, "Base", [0.0, 0.0, 0.0]); + let (_, free) = part(&mut s, "Free", [20.0, 0.0, 0.0]); + run(&mut s, "sketch.create", json!({"plane": "XY"})); + run(&mut s, "sketch.project", json!({"refs": [{"edge": [25, 0, 0]}]})); + run(&mut s, "sketch.finish", json!({})); + (s, free) + }; + let (mut dragged, free) = build(); + let (mut moved, _) = build(); + run(&mut dragged, "occurrence.drag", json!({"occurrence": free, "at": [25, 0, 0], "point": [25, 15, 0]})); + run(&mut moved, "occurrence.move", json!({"occurrence": free, "space": "world", "translate": [0, 15, 0]})); + assert_eq!(dragged.doc.to_json(), moved.doc.to_json()); + let shown = |s: &Session| format!("{:?}", s.world_state().sketches.iter().map(|k| &k.sketch.points).collect::>()); + assert_eq!(shown(&dragged), shown(&moved)); +} + +#[test] +fn bad_drag_parameters_are_refused() { + let mut s = Session::default(); + part(&mut s, "Base", [0.0, 0.0, 0.0]); + let (_, free) = part(&mut s, "Free", [20.0, 0.0, 0.0]); + for p in [ + json!({}), + json!({"occurrence": free}), + json!({"occurrence": free, "at": [0, 0, 0]}), + json!({"occurrence": free, "at": [0, 0, 0], "point": [1e300, 0, 0]}), + json!({"occurrence": free, "at": [0, 0, "x"], "point": [0, 0, 0]}), + json!({"path": [], "at": [0, 0, 0], "point": [1, 0, 0]}), + json!({"path": [free, free], "at": [0, 0, 0], "point": [1, 0, 0]}), + json!({"path": [999_999], "at": [0, 0, 0], "point": [1, 0, 0]}), + json!({"occurrence": "nothing", "at": [0, 0, 0], "point": [1, 0, 0]}), + ] { + assert!(s.execute("occurrence.drag", &p).is_err(), "{p}"); + } + near(origin(&s, &[free]), [0.0, 0.0, 0.0]); +} + +#[test] +fn moving_a_grounded_part_needs_saying_so_and_it_stays_grounded() { + let mut s = Session::default(); + let (_, base) = part(&mut s, "Base", [0.0, 0.0, 0.0]); + let e = s.execute("occurrence.move", &json!({"occurrence": base, "translate": [5, 0, 0]})).unwrap_err().to_string(); + assert!(e.contains("is grounded") && e.contains("occurrence.ground"), "{e}"); + run(&mut s, "occurrence.move", json!({"occurrence": base, "translate": [5, 0, 0], "move_grounded": true})); + near(origin(&s, &[base]), [5.0, 0.0, 0.0]); + assert!(grounded(&s, base)); +} + +/// The solver uses definitions' first paths; a drag through a rotated instance must carry +/// that local change to its jointed peers, including peers that are themselves shared. +#[test] +fn shared_joint_chains_follow_the_selected_frame_and_survive_solving() { + let mut s = Session::default(); + part(&mut s, "Base", [0.0, 0.0, 0.0]); + let asm = run(&mut s, "component.create", json!({"name": "Assembly"})); + let first = asm["occurrence"].as_u64().unwrap(); + let a = run(&mut s, "component.create", json!({"name": "A"}))["occurrence"].as_u64().unwrap(); + run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4})); + run(&mut s, "component.activate", json!({"component": asm["component"]})); + let b = run(&mut s, "component.create", json!({"name": "B"}))["occurrence"].as_u64().unwrap(); + run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "corner": [10, 0, 0]})); + run(&mut s, "component.activate", json!({"component": "root"})); + let at = |o| json!({"occurrence": o, "point": [0, 0, 0], "z": [0, 0, 1]}); + run(&mut s, "joint.create", json!({"type": "revolute", "a": at(a), "b": at(b), "flip": true})); + let second = run(&mut s, "occurrence.copy", json!({"occurrence": first, "translate": [100, 0, 0]}))["occurrence"].as_u64().unwrap(); + run(&mut s, "occurrence.move", json!({"occurrence": second, "angle": std::f64::consts::FRAC_PI_2, "origin": [100, 0, 0]})); + run(&mut s, "occurrence.ground", json!({"occurrence": first, "grounded": true})); + let undo = s.undo.len(); + let before = s.doc.to_json(); + let values = s.doc.assembly.joints[0].values.clone(); + // Drag the non-root side, through the second assembly's rotated frame. + run(&mut s, "occurrence.drag", json!({"path": [second, b], "at": [100, 0, 0], "point": [110, 0, 0]})); + near(origin(&s, &[second, a]), [110.0, 0.0, 0.0]); + near(origin(&s, &[second, b]), [110.0, 0.0, 0.0]); + near(origin(&s, &[first, a]), [0.0, -10.0, 0.0]); + near(origin(&s, &[first, b]), [0.0, -10.0, 0.0]); + let dragged = s.doc.to_json(); + let solution = run(&mut s, "joint.solve", json!({})); + assert_eq!(solution["conflicts"], json!([])); + assert_eq!(s.doc.to_json(), dragged, "solving preserves the dragged pose"); + assert_eq!(s.doc.assembly.joints[0].values, values); + assert_eq!(s.undo.len(), undo + 1); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.to_json(), before); +} + +#[test] +fn a_unique_part_can_drag_a_chain_through_a_shared_peer() { + let mut s = Session::default(); + part(&mut s, "Base", [0.0, 0.0, 0.0]); + let (_, free) = part(&mut s, "Free", [20.0, 0.0, 0.0]); + let asm = run(&mut s, "component.create", json!({"name": "Assembly"})); + let first = asm["occurrence"].as_u64().unwrap(); + let peer = run(&mut s, "component.create", json!({"name": "Peer"}))["occurrence"].as_u64().unwrap(); + run(&mut s, "component.activate", json!({"component": "root"})); + let second = run(&mut s, "occurrence.copy", json!({"occurrence": first, "translate": [100, 0, 0]}))["occurrence"].as_u64().unwrap(); + let at = |o| json!({"occurrence": o, "point": [0, 0, 0], "z": [0, 0, 1]}); + run(&mut s, "joint.create", json!({"type": "rigid", "a": at(free), "b": at(peer), "flip": true})); + run(&mut s, "occurrence.drag", json!({"occurrence": free, "at": [0, 0, 0], "point": [0, 15, 0]})); + near(origin(&s, &[free]), [0.0, 15.0, 0.0]); + near(origin(&s, &[first, peer]), [0.0, 15.0, 0.0]); + near(origin(&s, &[second, peer]), [100.0, 15.0, 0.0]); + let before = s.doc.to_json(); + run(&mut s, "joint.solve", json!({})); + assert_eq!(s.doc.to_json(), before); +} diff --git a/crates/engine/src/cmd/component_extract.rs b/crates/engine/src/cmd/component_extract.rs index 7188b6c0e2..8f9944aa44 100644 --- a/crates/engine/src/cmd/component_extract.rs +++ b/crates/engine/src/cmd/component_extract.rs @@ -758,7 +758,7 @@ mod tests { let mut s = Session::default(); run(&mut s, "parameters.add", json!({"name": "plate_h", "expression": "12 mm"})); run(&mut s, "parameters.add", json!({"name": "unused", "expression": "3 mm"})); - let plate = run(&mut s, "component.create", json!({"name": "Plate"})); + let plate = run(&mut s, "component.create", json!({"name": "Plate", "grounded": false})); let (plate, plate_occ) = (plate["component"].as_u64().unwrap(), plate["occurrence"].as_u64().unwrap()); run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": "plate_h", "body_name": "PlateBody"})); let pin = run(&mut s, "component.create", json!({"name": "Pin", "parent": plate})); diff --git a/crates/engine/src/cmd/component_link.rs b/crates/engine/src/cmd/component_link.rs index 2cbc6d1868..2cfe57dabb 100644 --- a/crates/engine/src/cmd/component_link.rs +++ b/crates/engine/src/cmd/component_link.rs @@ -27,7 +27,7 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("component.insert_design", "Insert Design", insert_design).at("SOLID", "ASSEMBLE").icon("component").params( "path: a .solidcraft design → a new component linked to it, holding its solid bodies as placed in that design; \ name? (default: the design's name); parent?: component id or name (default: the active one); translate?: [x,y,z]; \ - reuse?: bool (default false): insert another occurrence of an existing link to the same file", + reuse?: bool (default false): insert a free occurrence, reusing an existing link to the same file when available", ), CommandSpec::new("component.update_links", "Update Links", update_links).icon("update").params( "component?: id or name (default: every linked component) — reads the linked designs again and replaces their bodies where they changed", @@ -181,6 +181,10 @@ fn insert_design(s: &mut Session, p: &Value) -> Result { let (design_name, sf) = linked_bodies(&file, s.path.as_deref())?; let name = str_(p, "name").filter(|n| !n.trim().is_empty()).map(str::to_string).unwrap_or(design_name); let mut create = json!({"name": name, "activate": false}); + // Clipboard insertion uses reuse even for the first paste, which must stay movable. + if p.get("reuse").and_then(Value::as_bool).unwrap_or(false) { + create["grounded"] = json!(false); + } if let Some(parent) = p.get("parent") { create["parent"] = parent.clone(); } diff --git a/crates/engine/src/cmd/component_properties_tests.rs b/crates/engine/src/cmd/component_properties_tests.rs index 8a969551c9..e533ec37a4 100644 --- a/crates/engine/src/cmd/component_properties_tests.rs +++ b/crates/engine/src/cmd/component_properties_tests.rs @@ -8,7 +8,7 @@ fn close(actual: &Value, expected: f64) { assert!((actual.as_f64().unwrap() - expected).abs() < 1e-6, "{actual} != {expected}"); } fn box_in(s: &mut Session, name: &str) -> (u64, u64, String) { - let c = run(s, "component.create", json!({"name": name})); + let c = run(s, "component.create", json!({"name": name, "grounded": false})); run(s, "solid.box", json!({"length": 10, "width": 10, "height": 10})); let body = s.model.state().bodies.last().unwrap().name.clone(); (c["component"].as_u64().unwrap(), c["occurrence"].as_u64().unwrap(), body) @@ -81,7 +81,7 @@ fn body_reassignment_offsets_preserve_world_properties() { let mut s = Session::default(); run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10})); let body = s.model.state().bodies.first().unwrap().name.clone(); - let c = run(&mut s, "component.create", json!({"name": "Moved"})); + let c = run(&mut s, "component.create", json!({"name": "Moved", "grounded": false})); run(&mut s, "occurrence.move", json!({"occurrence": c["occurrence"], "translate": [100, 0, 0]})); run(&mut s, "component.move_bodies", json!({"bodies": [body], "component": c["component"]})); let r = run(&mut s, "component.properties", json!({"component": c["component"]})); diff --git a/crates/engine/src/cmd/component_relink_tests.rs b/crates/engine/src/cmd/component_relink_tests.rs index 31b78c5a84..d457ac51d5 100644 --- a/crates/engine/src/cmd/component_relink_tests.rs +++ b/crates/engine/src/cmd/component_relink_tests.rs @@ -24,6 +24,28 @@ fn volume(s: &mut Session) -> f64 { run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap() } +#[test] +fn reused_design_insertion_is_free_even_before_a_link_exists() { + let dir = folder("reuse-grounding"); + let file = dir.join("part.solidcraft"); + part(&file, 30); + let mut s = Session::default(); + for count in 1..=2 { + let inserted = run(&mut s, "component.insert_design", json!({"path": file, "reuse": true})); + let occurrence = inserted["occurrence"].as_u64().unwrap(); + assert!(!s.doc.occurrences.iter().find(|o| o.id == occurrence).unwrap().grounded); + assert_eq!(s.doc.links.len(), 1); + assert_eq!(s.undo.len(), count, "each paste is one undo step"); + run(&mut s, "occurrence.move", json!({"occurrence": occurrence, "translate": [10, 0, 0]})); + run(&mut s, "edit.undo", json!({})); + } + let mut ordinary = Session::default(); + let inserted = run(&mut ordinary, "component.insert_design", json!({"path": file})); + let occurrence = inserted["occurrence"].as_u64().unwrap(); + assert!(ordinary.doc.occurrences.iter().find(|o| o.id == occurrence).unwrap().grounded); + let _ = std::fs::remove_dir_all(dir); +} + #[test] fn relink_repairs_a_moved_source_for_every_instance_in_one_undo_step() { let dir = folder("moved"); @@ -117,6 +139,7 @@ fn relink_keeps_joint_definitions_and_reports_solver_diagnostics() { let inserted = run(&mut s, "component.insert_design", json!({"path": original})); let component = inserted["component"].as_u64().unwrap(); let occurrence = inserted["occurrence"].as_u64().unwrap(); + run(&mut s, "occurrence.ground", json!({"occurrence": occurrence, "grounded": false})); // Explicit points can keep solving after the source changes, even if the intended // connection no longer exists. The source-change warning must still be present. run( diff --git a/crates/engine/src/cmd/component_transfer_tests.rs b/crates/engine/src/cmd/component_transfer_tests.rs index 75d9a103f0..1532540ce1 100644 --- a/crates/engine/src/cmd/component_transfer_tests.rs +++ b/crates/engine/src/cmd/component_transfer_tests.rs @@ -34,7 +34,7 @@ fn recorded_assignment_preserves_sample_joined_material_and_timeline() { fn transformed_pending_destination_repeated_transfer_preserves_world_pose() { let mut s = Session::default(); run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Block"})); - let created = run(&mut s, "component.create", json!({"name":"Destination"})); + let created = run(&mut s, "component.create", json!({"name":"Destination","grounded":false})); let to = created["component"].as_u64().unwrap(); let occurrence = created["occurrence"].as_u64().unwrap(); run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,20,0],"angle":90,"axis":[0,0,1],"capture":false})); @@ -141,7 +141,7 @@ fn transferred_thread_follows_body_frame_without_changing_world_axis() { run(&mut s, "solid.cylinder", json!({"radius":4,"height":20,"body_name":"Shaft"})); run(&mut s, "solid.thread", json!({"face":[4,0,10],"designation":"M8"})); let before = s.world_state().threads[0].clone(); - let c = run(&mut s, "component.create", json!({"name":"Threaded"})); + let c = run(&mut s, "component.create", json!({"name":"Threaded","grounded":false})); let to = c["component"].as_u64().unwrap(); let occurrence = c["occurrence"].as_u64().unwrap(); run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,20,10],"axis":[1,0,0],"angle":90,"capture":true})); @@ -199,7 +199,7 @@ fn deleting_external_transfer_source_refuses_without_dangling_archive() { #[test] fn earlier_selected_cylinder_emboss_keeps_source_frame_after_transfer_and_edit() { let mut s = Session::default(); - let source = run(&mut s, "component.create", json!({"name":"Source"})); + let source = run(&mut s, "component.create", json!({"name":"Source","grounded":false})); let occurrence = source["occurrence"].as_u64().unwrap(); run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"})); run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,0,0],"capture":true})); @@ -227,7 +227,7 @@ fn earlier_selected_cylinder_emboss_keeps_source_frame_after_transfer_and_edit() #[test] fn transfer_undo_redo_restores_original_pending_source_world_pose() { let mut s = Session::default(); - let created = run(&mut s, "component.create", json!({"name":"Source"})); + let created = run(&mut s, "component.create", json!({"name":"Source","grounded":false})); let source = created["component"].as_u64().unwrap(); let occurrence = created["occurrence"].as_u64().unwrap(); run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"Block"})); @@ -248,7 +248,7 @@ fn transfer_undo_redo_restores_original_pending_source_world_pose() { #[test] fn transfer_checkpoint_restores_pending_world_pose_and_history() { let mut s = Session::default(); - let created = run(&mut s, "component.create", json!({"name":"Source"})); + let created = run(&mut s, "component.create", json!({"name":"Source","grounded":false})); let source = created["component"].as_u64().unwrap(); let occurrence = created["occurrence"].as_u64().unwrap(); run(&mut s, "solid.box", json!({"length":2,"width":2,"height":2,"body_name":"Block"})); diff --git a/crates/engine/src/cmd/config_certificate_tests.rs b/crates/engine/src/cmd/config_certificate_tests.rs index 2cb5c5f6c8..4eef3a87aa 100644 --- a/crates/engine/src/cmd/config_certificate_tests.rs +++ b/crates/engine/src/cmd/config_certificate_tests.rs @@ -214,7 +214,7 @@ fn certified_side_transfer_preserves_geometry_and_unsupported_taper_refuses_atom run(&mut s, "sketch.finish", json!({})); let source = run(&mut s, "solid.extrude", json!({"distance":10,"body_name":"Round"}))["feature"].as_u64().unwrap(); paint(&mut s, "Round", json!([10, 0, 5])); - let created = run(&mut s, "component.create", json!({"name":"Placed"})); + let created = run(&mut s, "component.create", json!({"name":"Placed","grounded":false})); let destination = created["component"].as_u64().unwrap(); let occurrence = created["occurrence"].as_u64().unwrap(); run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,20,0],"angle":90,"axis":[0,0,1],"capture":false})); diff --git a/crates/engine/src/cmd/construct.rs b/crates/engine/src/cmd/construct.rs index 12ed05e372..5762ce9338 100644 --- a/crates/engine/src/cmd/construct.rs +++ b/crates/engine/src/cmd/construct.rs @@ -116,13 +116,13 @@ fn geo(s: &Session, v: Option<&Value>, cmd: &str, what: &str) -> Result GeoRef::World { axis: a.to_ascii_uppercase() } } else { match s.doc.find_feature(a).map(|f| &f.kind) { - Some(FeatureKind::ConstructionAxis { .. }) => GeoRef::Axis { name: a.to_string() }, + Some(FeatureKind::ConstructionAxis { .. }) => GeoRef::Axis { name: a.into() }, _ => return Err(bad(cmd, format!("no axis `{a}`"))), } } } else if let Some(n) = str_(v, "construct_point") { match s.doc.find_feature(n).map(|f| &f.kind) { - Some(FeatureKind::ConstructionPoint { .. }) => GeoRef::ConstructPoint { name: n.to_string() }, + Some(FeatureKind::ConstructionPoint { .. }) => GeoRef::ConstructPoint { name: n.into() }, _ => return Err(bad(cmd, format!("no construction point `{n}`"))), } } else if v.as_str().is_some() { diff --git a/crates/engine/src/cmd/contact_sweep_tests.rs b/crates/engine/src/cmd/contact_sweep_tests.rs index 79e8bfd2a4..51bcc140b4 100644 --- a/crates/engine/src/cmd/contact_sweep_tests.rs +++ b/crates/engine/src/cmd/contact_sweep_tests.rs @@ -84,7 +84,7 @@ fn revolute_cannot_cross_a_blocker_between_clear_endpoints() { let mut s = Session::default(); for (name, corner) in [("Arm", [20, -2, -2]), ("Blocker", [-2, 20, -2])] { run(&mut s, "component.activate", json!({"component": 0})); - run(&mut s, "component.create", json!({"name": name})); + run(&mut s, "component.create", json!({"name": name, "grounded": false})); run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "corner": corner})); } run( diff --git a/crates/engine/src/cmd/delete.rs b/crates/engine/src/cmd/delete.rs index 2367dd8456..fc148bc166 100644 --- a/crates/engine/src/cmd/delete.rs +++ b/crates/engine/src/cmd/delete.rs @@ -1,6 +1,6 @@ //! Delete for everything that can be selected, in one undo step: sketch entities (while //! sketching), timeline features and sketches (with the features that depend on them), -//! construction planes, bodies (a Remove feature from here on in the timeline), occurrences and +//! construction planes and axes, bodies (a Remove feature from here on in the timeline), occurrences and //! components, canvases and joints. `dry_run` reports what would go and what would fail without //! changing anything, so the UI can ask first. @@ -61,6 +61,7 @@ fn apply(s: &mut Session, p: &Value) -> Result { } } let before = s.model.clone(); + let before_doc = s.doc.clone(); let mut joints = p .get("joints") .and_then(Value::as_array) @@ -98,7 +99,10 @@ fn apply(s: &mut Session, p: &Value) -> Result { Some(f) if matches!(f.kind, FeatureKind::ConstructionPlane { .. }) => features.push(f.id), _ => skip("the origin planes can't be deleted".into()), }, - Sel::Axis { .. } => skip("the origin axes can't be deleted".into()), + Sel::Axis { name } => match s.doc.find_feature(name) { + Some(f) if matches!(f.kind, FeatureKind::ConstructionAxis { .. }) => features.push(f.id), + _ => skip("the origin axes can't be deleted".into()), + }, Sel::Body { name } => bodies.push(name.clone()), Sel::Face { body, point, .. } => match faces.iter_mut().find(|(b, _)| b == body) { Some((_, ps)) => ps.push(*point), @@ -182,13 +186,9 @@ fn apply(s: &mut Session, p: &Value) -> Result { } s.refresh(); let deleted: Vec<&String> = features_before.iter().filter(|(id, _)| s.doc.feature(*id).is_none()).map(|(_, n)| n).collect(); - let would_fail: Vec = s - .model - .results - .iter() - .filter(|r| r.error.is_some() && before.result(r.id).is_some_and(|b| b.error.is_none() && !b.skipped)) - .map(|r| r.name.clone()) - .collect(); + // Features after a rolled-back marker count too (#3471). + let would_fail: Vec = + super::edit::newly_broken_in_history(&before_doc, &before, &s.doc, &s.model).into_iter().map(|(name, _)| name).collect(); Ok(json!({ "deleted": deleted, "deleted_joints": joints, "bodies": bodies, "would_fail": would_fail, "skipped": skipped })) } @@ -235,6 +235,36 @@ mod tests { assert_eq!(bodies(&s), 2); } + /// With the history rolled back, the checks before a delete look past the marker too (#3471): + /// deleting the plate (or its extrude) there breaks the hole cut after the marker, and the + /// confirmation must say so instead of the hole's error mark turning up only at the end. + #[test] + fn the_checks_before_a_delete_in_a_rolled_back_history_see_the_features_after_the_marker() { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [40, 20]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance": 5, "name": "Plate"})); + run(&mut s, "solid.cylinder", json!({"base": [20, 10, -1], "radius": 2, "height": 10, "operation": "cut", "name": "Hole"})); + let body = s.model.state().bodies[0].name.clone(); + let plate = s.doc.find_feature("Plate").unwrap().id; + for marker in [None, Some(2)] { + run(&mut s, "timeline.roll_to", marker.map_or(json!({}), |m| json!({"position": m}))); + let doc = s.doc.clone(); + let dry = run(&mut s, "selection.delete", json!({"items": [{"type": "body", "name": body}], "dry_run": true})); + // At the end the plate's Remove goes after the hole; rolled back, before it. + let breaks = if marker.is_some() { json!(["Hole"]) } else { json!([]) }; + assert_eq!(dry["would_fail"], breaks, "marker {marker:?}: {dry}"); + let deps = run(&mut s, "timeline.dependents", json!({"feature": plate})); + assert_eq!(deps["would_fail"], json!(["Hole"]), "marker {marker:?}: {deps}"); + assert_eq!(*s.doc, *doc, "the checks change nothing"); + assert!(s.model.result(s.doc.find_feature("Hole").unwrap().id).is_some_and(|r| r.skipped == marker.is_some() && r.error.is_none())); + } + // Deleting anyway (rolled back) still reports what it broke. + let done = run(&mut s, "selection.delete", json!({"items": [{"type": "body", "name": body}]})); + assert_eq!(done["would_fail"], json!(["Hole"]), "{done}"); + } + #[test] fn mixed_selection_is_one_undo_step() { let (mut s, _) = part(); @@ -254,6 +284,31 @@ mod tests { assert_eq!((bodies(&s), s.doc.features.len()), (2, n)); } + #[test] + fn construction_axis_deletes_with_dry_run_and_undo_but_origin_axes_are_refused() { + let mut s = Session::default(); + run(&mut s, "construct.axis.two_points", json!({"a": {"point": [0, 0, 0]}, "b": {"point": [1, 2, 3]}, "name": "CustomAxis"})); + let axis = s.doc.find_feature("CustomAxis").unwrap().id; + let depth = s.undo.len(); + let items = json!([{"type": "axis", "name": "CustomAxis"}, {"type": "axis", "name": "X"}]); + let dry = run(&mut s, "selection.delete", json!({"items": items, "dry_run": true})); + assert_eq!(dry["deleted"], json!(["CustomAxis"])); + assert_eq!(dry["skipped"], json!(["the origin axes can't be deleted"])); + assert!(s.doc.feature(axis).is_some()); + assert_eq!(s.undo.len(), depth); + let deleted = run(&mut s, "selection.delete", json!({"items": items})); + assert_eq!(deleted, dry); + assert!(s.doc.feature(axis).is_none()); + assert_eq!(s.undo.len(), depth + 1); + run(&mut s, "edit.undo", json!({})); + assert!(s.doc.feature(axis).is_some()); + for name in ["X", "Y", "Z"] { + let error = s.execute("selection.delete", &json!({"items": [{"type": "axis", "name": name}]})).unwrap_err(); + assert!(error.to_string().contains("the origin axes can't be deleted"), "{error}"); + assert!(s.doc.feature(axis).is_some()); + } + } + #[test] fn faces_alone_are_refused_and_hostile_input_too() { let (mut s, _) = part(); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 713ae2c395..fc60ed31e9 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -168,6 +168,10 @@ fn delete(s: &mut Session, p: &Value) -> Result { if s.active_sketch.is_some_and(|a| gone.contains(&a)) { s.active_sketch = None; } + // An inserted file's feature takes its components with it. + if s.active_component != 0 && !s.doc.components.iter().any(|c| c.id == s.active_component) { + s.active_component = 0; + } s.selection.clear(); s.refresh(); let failing: Vec = s @@ -207,6 +211,15 @@ fn rename(s: &mut Session, p: &Value) -> Result { for f in doc.features.iter_mut() { f.kind.remap_refs(&sketches, &names); } + doc.rename_feature_keys(&old, name); + // The hidden list follows in `Session::refresh_from` (undo and redo too). + for sel in &mut s.selection { + if let Sel::Plane { name: n } = sel + && *n == old + { + *n = name.to_string(); + } + } Ok(json!({"feature": id, "name": name})) } @@ -569,6 +582,24 @@ fn feature_ids(s: &Session, p: &Value, cmd: &str) -> Result> { Ok(ids) } +/// The names the features from the first of `ids` to be suppressed on made, for those that made a +/// body numbered by the counter (`BodyN`). Counted bodies take the next free number each time the +/// design is computed, so once a feature that made one is suppressed, every counted body after it +/// would move down one (`Body2` becoming `Body1`) and break what names it, such as a Move of +/// `Body2` (#4302). Stored as their names, they stay as they were, as when a counted body is +/// renamed (#1109). The suppressed feature keeps its own too: the counter passes the names of +/// suppressed features by, so a body made meanwhile does not take `Body1` and lose it again when +/// the feature is back. +fn counted_names_from(s: &Session, ids: &[u64]) -> Vec<(u64, Vec)> { + let made = |f: &Feature| -> Vec { + s.model.result(f.id).map(|r| r.state.bodies.iter().filter(|b| b.feature == f.id).map(|b| b.name.clone()).collect()).unwrap_or_default() + }; + let makes_counted = |f: &Feature| (0..made(f).len()).any(|i| super::browser::counted(f, i)); + let first = s.doc.features.iter().position(|f| ids.contains(&f.id) && !f.suppressed && makes_counted(f)); + let Some(at) = first else { return Vec::new() }; + s.doc.features.iter().skip(at).filter(|f| makes_counted(f)).map(|f| (f.id, made(f))).collect() +} + fn suppress(s: &mut Session, p: &Value) -> Result { let cmd = "timeline.suppress"; let ids = feature_ids(s, p, cmd)?; @@ -578,8 +609,14 @@ fn suppress(s: &mut Session, p: &Value) -> Result { // Toggle: one feature flips; several are all suppressed unless all already are. None => !ids.iter().all(|id| s.doc.feature(*id).is_some_and(|f| f.suppressed)), }; + let keep = if want { counted_names_from(s, &ids) } else { Vec::new() }; // One command, so one undo step however many features it changes. let doc = s.doc_mut(); + for (id, made) in &keep { + if let Some(f) = doc.feature_mut(*id) { + f.body_names.clone_from(made); + } + } for id in &ids { let f = doc.feature_mut(*id).ok_or_else(|| bad(cmd, format!("no feature {id}")))?; f.suppressed = want; @@ -1000,6 +1037,13 @@ fn edit_feature(s: &mut Session, p: &Value) -> Result { nf.edge_names = solidcraft_doc::sheet::sheet_edge_names(&s.model.state_before(id), &solidcraft_doc::plain(&points(a))); } } + // New reference edges for a hole (#37): the names of the edges there now. + if let (FeatureKind::Hole { references: a, position, .. }, FeatureKind::Hole { references: b, .. }) = (&nf.kind, &f.kind) { + let edges = |r: &[solidcraft_doc::HoleReference]| r.iter().map(|x| x.edge.0).collect::>(); + if edges(a) != edges(b) { + nf.edge_names = solidcraft_doc::hole_reference::reference_edge_names(&s.model.state_before(id), position.0, &edges(a)); + } + } // New bend edges for a conversion: the names of the body's edges there now. if let (FeatureKind::SheetConvert { bends: a, body, .. }, FeatureKind::SheetConvert { bends: b, body: was, .. }) = (&nf.kind, &f.kind) && (a != b || body != was) @@ -1068,6 +1112,30 @@ fn newly_broken(before: &solidcraft_doc::Model, after: &solidcraft_doc::Model) - .collect() } +/// [`newly_broken`] over the whole history, from `before` (the model of `before_doc`) to `after` +/// (the model of `after_doc`). With the history rolled back, the features after the marker are +/// evaluated too, on copies of the models (which share their cache): a delete made in the +/// rolled-back history that breaks them says so before it is made, rather than leaving error marks +/// that only show at the end of the timeline (#3471). +pub(super) fn newly_broken_in_history( + before_doc: &solidcraft_doc::Document, + before: &solidcraft_doc::Model, + after_doc: &solidcraft_doc::Document, + after: &solidcraft_doc::Model, +) -> Vec<(String, String)> { + if before_doc.marker.is_none() && after_doc.marker.is_none() { + return newly_broken(before, after); + } + let whole = |doc: &solidcraft_doc::Document, model: &solidcraft_doc::Model| { + let mut doc = doc.clone(); + doc.marker = None; + let mut model = model.clone(); + model.evaluate(&doc); + model + }; + newly_broken(&whole(before_doc, before), &whole(after_doc, after)) +} + fn reorder(s: &mut Session, p: &Value) -> Result { let cmd = "timeline.reorder"; let id = feature_id(s, p.get("feature"), cmd)?; @@ -1229,6 +1297,7 @@ fn redefine(s: &mut Session, p: &Value) -> Result { | "plastic.lip" | "plastic.snap_fit" | "plastic.rest" + | "frame.member" ); if !constructor { return Err(bad(cmd, format!("`{cid}` does not make a feature"))); @@ -1253,6 +1322,14 @@ fn redefine(s: &mut Session, p: &Value) -> Result { { parameters.entry("convention").or_insert_with(|| json!(convention)); } + // Members keep their names through an edit, unless it gives them another name. + if let FeatureKind::FrameMember { names, body_name, .. } = &old.kind + && cid == "frame.member" + && let Some(parameters) = params.as_object_mut() + && parameters.get("body_name").and_then(Value::as_str) == body_name.as_deref() + { + parameters.entry("names").or_insert_with(|| json!(names)); + } if !old.participants.is_empty() && let Some(parameters) = params.as_object_mut() { @@ -1388,7 +1465,7 @@ fn dependents(s: &mut Session, p: &Value) -> Result { let mut after = s.model.clone(); after.evaluate(&next); let names = |ids: &[u64]| -> Vec { ids.iter().filter(|x| **x != id).filter_map(|x| s.doc.feature(*x).map(|f| f.name.clone())).collect() }; - let broken: Vec = newly_broken(&s.model, &after).into_iter().map(|(n, _)| n).collect(); + let broken: Vec = newly_broken_in_history(&s.doc, &s.model, &next, &after).into_iter().map(|(n, _)| n).collect(); Ok(json!({"deleted_with_it": names(&gone), "would_fail": broken})) } diff --git a/crates/engine/src/cmd/edit_tests.rs b/crates/engine/src/cmd/edit_tests.rs index d9c9734759..0e99580f3a 100644 --- a/crates/engine/src/cmd/edit_tests.rs +++ b/crates/engine/src/cmd/edit_tests.rs @@ -340,7 +340,7 @@ fn redefine_cannot_publish_a_second_transfer_that_breaks_existing_ownership() { fn redefine_maps_placement_inputs_in_the_feature_owner_frame() { let mut s = Session::default(); run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "name": "RootBox", "body_name": "RootBody"})); - let owner = run(&mut s, "component.create", json!({"name": "Owner"})); + let owner = run(&mut s, "component.create", json!({"name": "Owner", "grounded": false})); run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "corner": [10, 0, 0], "name": "OwnedBox", "body_name": "OwnedBody"})); run(&mut s, "solid.fillet", json!({"edges": [[12, 0, 4]], "radius": 0.5, "body": "OwnedBody", "name": "OwnerRound"})); run(&mut s, "occurrence.move", json!({"occurrence": owner["occurrence"], "translate": [100, 0, 0]})); @@ -733,6 +733,58 @@ fn suppress_takes_a_list_of_features_as_one_undo_step() { assert!(s.doc.find_feature("B").is_none() && s.doc.find_feature("C").is_none()); } +/// #4302: suppressing a feature that made a counted body (`Body1`) leaves the counted bodies of +/// later features as they were: `Body2` used to become `Body1`, and a Move of `Body2` failed. +#[test] +fn suppressing_a_feature_keeps_later_counted_body_names() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "name": "A"})); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [20, 0, 0], "name": "B"})); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [40, 0, 0], "name": "C"})); + run(&mut s, "solid.move", json!({"bodies": ["Body2"], "translate": [0, 30, 0], "name": "Move"})); + let bodies = |s: &Session| -> Vec<(String, String)> { + let st = s.model.state(); + st.bodies.iter().map(|b| (b.name.clone(), s.doc.feature(b.feature).map(|f| f.name.clone()).unwrap_or_default())).collect() + }; + let named = |pairs: &[(&str, &str)]| pairs.iter().map(|(b, f)| (b.to_string(), f.to_string())).collect::>(); + let works = |s: &Session| s.doc.features.iter().all(|f| s.model.result(f.id).is_some_and(|r| r.error.is_none())); + let moved = |s: &Session| crate::cmd::inspect::measure_json(s.model.state().body("Body2").unwrap())["bbox"]["min"].clone(); + assert_eq!(bodies(&s), named(&[("Body1", "A"), ("Body2", "B"), ("Body3", "C")])); + let at = moved(&s); + + run(&mut s, "timeline.suppress", json!({"feature": "A", "suppressed": true})); + assert_eq!(bodies(&s), named(&[("Body2", "B"), ("Body3", "C")])); + assert!(works(&s), "the Move still finds Body2"); + assert_eq!(moved(&s), at, "and still moves B's body"); + + // Back again: A takes its number back, the others keep theirs. + run(&mut s, "timeline.suppress", json!({"feature": "A", "suppressed": false})); + assert_eq!(bodies(&s), named(&[("Body1", "A"), ("Body2", "B"), ("Body3", "C")])); + assert!(works(&s)); + + // Several at once: the first one keeps the names of everything after it; undo restores all. + run(&mut s, "timeline.suppress", json!({"features": ["B", "A"], "suppressed": true})); + assert_eq!(bodies(&s), named(&[("Body3", "C")])); + run(&mut s, "edit.undo", json!({})); + assert_eq!(bodies(&s), named(&[("Body1", "A"), ("Body2", "B"), ("Body3", "C")])); + assert!(works(&s)); + + // A body made while A is suppressed does not take A's `Body1`, so it keeps its name once A is + // back (it used to be `Body1`, then `Body4`). + run(&mut s, "timeline.suppress", json!({"feature": "A", "suppressed": true})); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [60, 0, 0], "name": "D"})); + assert_eq!(bodies(&s), named(&[("Body2", "B"), ("Body3", "C"), ("Body4", "D")])); + // Also once the design is opened again with the model its file keeps, which does not keep + // the names passed by: D computed again from a kept state is still `Body4`. + let saved = s.model.saved(&s.doc).unwrap(); + let mut reopened = Session::opened(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap(), Some(&saved)); + run(&mut reopened, "timeline.edit", json!({"feature": "D", "set": {"length": "12"}})); + assert_eq!(bodies(&reopened), named(&[("Body2", "B"), ("Body3", "C"), ("Body4", "D")])); + run(&mut s, "timeline.suppress", json!({"feature": "A", "suppressed": false})); + assert_eq!(bodies(&s), named(&[("Body1", "A"), ("Body2", "B"), ("Body3", "C"), ("Body4", "D")])); + assert!(works(&s)); +} + /// The value at a dotted path of a definition. fn dotted<'a>(def: &'a Value, path: &str) -> &'a Value { path.split('.').fold(def, |v, k| &v[k]) @@ -865,3 +917,58 @@ fn renaming_a_feature_evaluates_nothing() { fresh.refresh(); assert_eq!(failed(&s), failed(&fresh)); } + +/// #4353: a construction plane is also named outside the features: by a sketch's projection of +/// it, a canvas placed on it and the hidden list. Renaming it renames it there too, and undo +/// takes the old name back. +#[test] +fn renaming_a_plane_keeps_its_projections_canvases_and_hidden_state() { + let mut s = Session::default(); + run(&mut s, "construct.plane.offset", json!({"base": "XY", "offset": 20, "name": "Shelf"})); + run(&mut s, "construct.plane.offset", json!({"base": "YZ", "offset": -30, "name": "Guide"})); + run(&mut s, "browser.visibility", json!({"items": ["Guide"], "visible": false})); + let mut png = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec(); + png.extend([0, 0, 0, 4, 0, 0, 0, 2, 8, 6, 0, 0, 0, 0, 0, 0, 0]); + run(&mut s, "canvas.insert", json!({"data": solidcraft_doc::canvas::base64_encode(&png), "plane": "Shelf", "width": 40})); + run(&mut s, "sketch.create", json!({"plane": "XZ", "name": "Profile"})); + run(&mut s, "sketch.project", json!({"refs": [{"plane": "Shelf"}]})); + run(&mut s, "sketch.finish", json!({})); + let plane_link = |s: &Session| match &s.doc.find_feature("Profile").unwrap().kind { + solidcraft_doc::FeatureKind::Sketch { sketch, .. } => match &sketch.links[0].source { + solidcraft_sketch::LinkSource::Plane { name } => name.clone(), + other => panic!("{other:?}"), + }, + other => panic!("{other:?}"), + }; + let canvas_plane = |s: &Session| s.doc.canvases[0].plane.clone(); + let projected_z = |s: &mut Session| { + let r = run(s, "sketch.inspect", json!({"sketch": "Profile"})); + r["points"].as_array().unwrap().iter().find(|p| p["link"] == "j1").unwrap()["world"][2].as_f64().unwrap() + }; + let warnings = |s: &Session| s.model.results.iter().filter_map(|r| r.warning.clone()).collect::>(); + run(&mut s, "select.set", json!({"items": [{"type": "plane", "name": "Shelf"}]})); + run(&mut s, "timeline.rename", json!({"feature": "Shelf", "name": "Ledge"})); + run(&mut s, "timeline.rename", json!({"feature": "Guide", "name": "Rail"})); + assert_eq!(plane_link(&s), "Ledge"); + assert_eq!(canvas_plane(&s), solidcraft_doc::PlaneRef::Construction { name: "Ledge".into() }); + assert_eq!(s.visibility.hidden_origin, ["Rail"]); + assert_eq!(s.selection, [Sel::Plane { name: "Ledge".into() }]); + assert!(warnings(&s).is_empty(), "{:?}", warnings(&s)); + // The projection follows the renamed plane. + run(&mut s, "timeline.edit", json!({"feature": "Ledge", "set": {"plane.distance": "35"}})); + assert!((projected_z(&mut s) - 35.0).abs() < 1e-9); + assert!(warnings(&s).is_empty(), "{:?}", warnings(&s)); + // Undo takes the old name back everywhere in the design, and the sketch finds it again. + for _ in 0..3 { + run(&mut s, "edit.undo", json!({})); + } + assert_eq!(plane_link(&s), "Shelf"); + assert_eq!(canvas_plane(&s), solidcraft_doc::PlaneRef::Construction { name: "Shelf".into() }); + assert_eq!(s.visibility.hidden_origin, ["Guide"], "the hidden plane stays hidden"); + assert!(warnings(&s).is_empty(), "{:?}", warnings(&s)); + assert!((projected_z(&mut s) - 20.0).abs() < 1e-9); + run(&mut s, "edit.redo", json!({})); + run(&mut s, "edit.redo", json!({})); + assert_eq!(s.visibility.hidden_origin, ["Rail"]); + assert_eq!(plane_link(&s), "Ledge"); +} diff --git a/crates/engine/src/cmd/face_tests.rs b/crates/engine/src/cmd/face_tests.rs index 73c02e39b7..c180e83e22 100644 --- a/crates/engine/src/cmd/face_tests.rs +++ b/crates/engine/src/cmd/face_tests.rs @@ -84,7 +84,7 @@ fn internal_capture_does_not_bypass_missing_ambiguous_or_invalid_path_picks() { fn generated_profiles_and_press_pull_convert_rotated_occurrence_picks_once() { for hole in [false, true] { let mut s = Session::default(); - let component = run(&mut s, "component.create", json!({"name":"Arm"})); + let component = run(&mut s, "component.create", json!({"name":"Arm","grounded":false})); let cid = component["component"].as_u64().unwrap(); let occ = component["occurrence"].as_u64().unwrap(); run(&mut s, "solid.box", json!({"length":40,"width":30,"height":20,"name":"Base","body_name":"Stock"})); @@ -147,7 +147,7 @@ fn raised_half(s: &mut Session, body: &str) { #[test] fn legacy_internal_capture_refuses_an_uncaptured_source_in_another_component() { let mut s = Session::default(); - let component = run(&mut s, "component.create", json!({"name":"Source"})); + let component = run(&mut s, "component.create", json!({"name":"Source","grounded":false})); let occurrence = component["occurrence"].as_u64().unwrap(); // A face Offset Face raised has no construction certificate or exact history name yet: // the legacy path. diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 216b126683..3391c1ecfc 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -68,7 +68,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SOLID", "CREATE") .icon("hole") .key("H") - .params("position: [x,y,z] on a face (numbers, or length expressions the hole follows) | sketch + points: [sketch point ids] (one hole each, perpendicular to the sketch, into the material on whichever side of the sketch plane it is) | sketch alone (every sketch point: standalone points and circle centres); flip?: bool (sketch points: drill to the other side); single?: bool (with one sketch point: a Single hole that follows the point, drilled into the face there); direction?: [x,y,z] (default: into the face, against its outward normal; one that leaves the face is mirrored into it; ignored with sketch points unless single); diameter | thread (a tapped hole: the tap drill, major − pitch) | clearance: \"M6\" with fit?: close|normal|loose (ISO 273); depth? (default through all) | to?: [x,y,z] (To Object: drill to that face or point); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?; cs_diameter?, cs_angle?; thread?: \"M6\" (cosmetic); targets?: [body] (the bodies to drill; holes at the points of a sketch on a body's face default to that body, found along the holes' direction on it alone; explicit targets or participants override, [\"*\"] every body)"), + .params("position: [x,y,z] on a face (numbers, or length expressions the hole follows) | sketch + points: [sketch point ids] (one hole each, perpendicular to the sketch, into the material on whichever side of the sketch plane it is) | sketch alone (every sketch point: standalone points and circle centres); flip?: bool (sketch points: drill to the other side); single?: bool (with one sketch point: a Single hole that follows the point, drilled into the face there); direction?: [x,y,z] (default: into the face, against its outward normal; one that leaves the face is mirrored into it; ignored with sketch points unless single); diameter | thread (a tapped hole: the tap drill, major − pitch) | clearance: \"M6\" with fit?: close|normal|loose (ISO 273); depth? (default through all) | to?: [x,y,z] (To Object: drill to that face or point); references?: [{edge: [x,y,z] on a straight edge of the face, distance: expr}] (at most two; the centre goes that far from each edge's line, in the face's plane, and follows the edges; with a numeric position only); type?: simple|drilled|counterbore|countersink; tip_angle?; cb_diameter?, cb_depth?; cs_diameter?, cs_angle?; thread?: \"M6\" (cosmetic); targets?: [body] (the bodies to drill; holes at the points of a sketch on a body's face default to that body, found along the holes' direction on it alone; explicit targets or participants override, [\"*\"] every body)"), CommandSpec::new("solid.pipe", "Pipe", pipe) .at("SOLID", "CREATE") .icon("pipe") @@ -520,6 +520,11 @@ fn add_feature_in(s: &mut Session, p: &Value, kind: FeatureKind, world: bool) -> (Vec::new(), solidcraft_doc::sheet::sheet_edge_names(&s.model.state(), &solidcraft_doc::plain(&points))) } FeatureKind::SheetHem { edges, .. } => (Vec::new(), solidcraft_doc::sheet::sheet_edge_names(&s.model.state(), &solidcraft_doc::plain(edges))), + // A hole's reference edges (#37) are edges of the body it is drilled into. + FeatureKind::Hole { position, references, .. } if !references.is_empty() => { + let edges: Vec = references.iter().map(|r| r.edge.0).collect(); + (Vec::new(), solidcraft_doc::hole_reference::reference_edge_names(&s.model.state(), position.0, &edges)) + } // A conversion's bend edges are edges of the body it converts. FeatureKind::SheetConvert { body, bends, .. } if !bends.is_empty() => { (Vec::new(), solidcraft_doc::sheet::bend_edge_names(&s.model.state(), body, &solidcraft_doc::plain(bends))) @@ -1931,6 +1936,11 @@ fn hole(s: &mut Session, p: &Value) -> Result { } None => None, }; + // Reference edges (#37): the centre sits at these distances from straight edges of its face. + let references = hole_reference_params(s, p, cmd)?; + if !references.is_empty() && (points.is_some() || at.is_some()) { + return Err(bad(cmd, "`references` place a hole by its `position` given as numbers, not at sketch points or length expressions")); + } let position = match (&points, at_value.or_else(|| p.get("position").and_then(vec3))) { (Some((_, first, _)), None) => *first, (_, Some(x)) => x, @@ -1948,6 +1958,24 @@ fn hole(s: &mut Session, p: &Value) -> Result { } }, }; + // Placed by its reference edges: drilled where their distances put it, which `position` + // then keeps. The edges must be straight edges at the given points, and cross. + let position = if references.is_empty() { + position + } else { + let st = s.model.state(); + let mut distances = Vec::with_capacity(references.len()); + for r in &references { + distances.push(s.doc.eval(&r.distance, Kind::Length).map_err(|e| bad(cmd, format!("`references`: {e}")))?); + } + let edges: Vec<(Vec3, Option<&str>)> = references.iter().map(|r| (r.edge.0, None)).collect(); + let frame = solidcraft_doc::hole_reference::reference_frame(&st, position, direction, &edges, true).map_err(|e| bad(cmd, e.to_string()))?; + let centre = frame.place(position, &distances).map_err(|e| bad(cmd, e.to_string()))?; + if !solidcraft_doc::hole_reference::on_reference_face(&st, &frame, centre) { + return Err(bad(cmd, "the reference distances put the hole's centre off its face")); + } + centre + }; // The diameter: given, or the tap drill of the thread (major − pitch), or an ISO 273 // clearance hole for a bolt size (close, normal or loose fit). let diameter = match (expr(p, "diameter"), &thread, str_(p, "clearance")) { @@ -2022,10 +2050,33 @@ fn hole(s: &mut Session, p: &Value) -> Result { points: points.map(|x| x.0), thread, to: to.map(FacePick), + references, }, ) } +/// A hole's reference edges (#37): `references: [{edge: [x, y, z], distance: expr}]`, at most +/// two, each distance a length. +fn hole_reference_params(s: &Session, p: &Value, cmd: &str) -> Result> { + let Some(v) = p.get("references") else { return Ok(Vec::new()) }; + let list = v.as_array().ok_or_else(|| bad(cmd, "`references` must be a list of {edge: [x, y, z], distance}"))?; + let most = solidcraft_doc::hole_reference::MAX_HOLE_REFERENCES; + if list.len() > most { + return Err(bad(cmd, format!("a hole takes at most {most} `references`"))); + } + let mut out = Vec::with_capacity(list.len()); + for (i, r) in list.iter().enumerate() { + let edge = r + .get("edge") + .and_then(vec3) + .ok_or_else(|| bad(cmd, format!("`references[{i}].edge` must be a point [x, y, z] on a straight edge of the face")))?; + let distance = expr(r, "distance").ok_or_else(|| bad(cmd, format!("`references[{i}].distance` must be a number or an expression")))?; + check_expr(s, &distance, Kind::Length, cmd, "references")?; + out.push(solidcraft_doc::HoleReference { edge: Position(edge), distance }); + } + Ok(out) +} + /// The body whose face is nearest `p` (within its pick tolerance), when it belongs to another /// component than the active one. fn body_of_other_component_at(s: &Session, p: Vec3) -> Option { diff --git a/crates/engine/src/cmd/features_more_tests.rs b/crates/engine/src/cmd/features_more_tests.rs index 56245159d3..bf608b61d5 100644 --- a/crates/engine/src/cmd/features_more_tests.rs +++ b/crates/engine/src/cmd/features_more_tests.rs @@ -5,7 +5,7 @@ use crate::Session; #[test] fn grouped_face_auto_operation_keeps_implicit_and_explicit_participants() { let mut s = Session::default(); - let part = run(&mut s, "component.create", json!({"name":"Part"})); + let part = run(&mut s, "component.create", json!({"name":"Part","grounded":false})); run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Stock"})); run(&mut s, "component.activate", json!({"component":0})); for offset in [0., 30.] { @@ -417,7 +417,7 @@ fn cylinder_cross_component_emboss_keeps_source_owner_and_placements() { for (pick_offset, auto) in [(0., false), (80., false), (0., true), (80., true)] { let run = |s: &mut Session, id: &str, p: Value| run(s, id, auto_emboss_params(p, auto && matches!(id, "solid.emboss" | "timeline.redefine"))); let mut s = Session::default(); - let a = run(&mut s, "component.create", json!({"name":"Source"}))["component"].as_u64().unwrap(); + let a = run(&mut s, "component.create", json!({"name":"Source","grounded":false}))["component"].as_u64().unwrap(); run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"})); let source_feature = s.model.state().bodies.first().unwrap().feature; let source_names = solidcraft_doc::naming::face_names(s.model.state().body("Sleeve").unwrap()); @@ -532,7 +532,7 @@ fn check_active_second_source(auto: bool) { fn cylinder_pending_leaf_or_ancestor_positions_require_capture_atomically() { for (ancestor, auto) in [(false, false), (true, false), (false, true), (true, true)] { let mut s = Session::default(); - let parent = run(&mut s, "component.create", json!({"name":"Parent"}))["occurrence"].as_u64().unwrap(); + let parent = run(&mut s, "component.create", json!({"name":"Parent","grounded":false}))["occurrence"].as_u64().unwrap(); let leaf = run(&mut s, "component.create", json!({"name":"Source"}))["occurrence"].as_u64().unwrap(); run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"})); run(&mut s, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"})); @@ -561,7 +561,7 @@ fn cylinder_pending_third_component_profile_requires_capture_atomically() { fn check_pending_third_profile(auto: bool) { let mut s = Session::default(); run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"})); - let profile_occurrence = run(&mut s, "component.create", json!({"name":"ProfileOwner"}))["occurrence"].as_u64().unwrap(); + let profile_occurrence = run(&mut s, "component.create", json!({"name":"ProfileOwner","grounded":false}))["occurrence"].as_u64().unwrap(); run(&mut s, "sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"})); run(&mut s, "sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]})); run(&mut s, "sketch.finish", json!({})); @@ -594,7 +594,7 @@ fn check_body_offset(auto: bool) { let mut s = Session::default(); run(&mut s, "solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"})); let before = exact_cylinder_volume(&s); - let holder = run(&mut s, "component.create", json!({"name":"Holder"}))["component"].as_u64().unwrap(); + let holder = run(&mut s, "component.create", json!({"name":"Holder","grounded":false}))["component"].as_u64().unwrap(); run(&mut s, "occurrence.move", json!({"occurrence":"Holder:1","translate":[0,50,0]})); std::sync::Arc::make_mut(&mut s.doc).body_components.insert("Sleeve".into(), holder); std::sync::Arc::make_mut(&mut s.doc) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index ce03a9d1e5..c8d582f26b 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -794,7 +794,7 @@ mod tests { #[test] fn preview_saves_do_not_build_cold_placements_or_persist_transient_edits() { let mut s = Session::default(); - let created = s.execute("component.create", &json!({"name":"PreviewPart"})).unwrap(); + let created = s.execute("component.create", &json!({"name":"PreviewPart","grounded":false})).unwrap(); let occurrence = created["occurrence"].as_u64().unwrap(); s.execute("solid.box", &json!({"length":2,"width":3,"height":4,"body_name":"Block","operation":"new"})).unwrap(); for body in &s.model.state().bodies { diff --git a/crates/engine/src/cmd/file_import.rs b/crates/engine/src/cmd/file_import.rs index fae0a51a00..0a3ea1d103 100644 --- a/crates/engine/src/cmd/file_import.rs +++ b/crates/engine/src/cmd/file_import.rs @@ -290,6 +290,7 @@ pub(crate) fn add_in_component( if let Some(f) = s.doc_mut().feature_mut(id) { f.component = top; f.body_names = body_names; + f.inserted = true; } let moves: Vec<(usize, u64, Mat)> = plan.bodies.iter().filter_map(|(b, slot, offset)| comps.get(*slot).map(|c| (*b, *c, *offset))).collect(); put_bodies(s, &names, &moves); diff --git a/crates/engine/src/cmd/frame.rs b/crates/engine/src/cmd/frame.rs new file mode 100644 index 0000000000..aabd51c72c --- /dev/null +++ b/crates/engine/src/cmd/frame.rs @@ -0,0 +1,276 @@ +//! Frames and weldments (#4003). `frame.member` lays structural members along the lines of a frame +//! sketch, their cross-section a built-in profile or a profile sketch. `frame.profile.list` lists +//! the built-in profiles, `frame.profile.properties` works out a profile's section, and +//! `frame.profile.insert` draws one into a sketch. + +use std::collections::BTreeMap; + +use serde_json::{Value, json}; +use solidcraft_doc::expr::Kind; +use solidcraft_doc::frame::{self, Anchor, MemberProfile, Profile, ProfileSpec}; +use solidcraft_doc::{FeatureKind, SketchId}; +use solidcraft_geom::{Loop2, Seg2, Vec2}; +use solidcraft_sketch::Sketch; + +use super::features::{add_feature, check_expr, sketch_id}; +use super::sketch::edit; +use super::{CommandSpec, in_sketch, not_in_sketch}; +use crate::params::{bad, bool_, expr, num, str_, string_list, vec2}; +use crate::{Result, Session}; + +#[cfg(test)] +#[path = "frame_tests.rs"] +mod tests; + +/// The profile parameter, as every frame command documents it. +macro_rules! profile_doc { + () => { + "profile: {kind: bar|round_bar|rect_tube|round_tube|angle|channel|i_beam, …its dimensions: each a number in the design's units or a length expression over its parameters (\"tube_wall\", \"2 in\")} (frame.profile.list gives each kind's dimensions and an example; dimensions left out take defaults from the profile's size)" + }; +} + +pub static COMMANDS: &[CommandSpec] = &[ + CommandSpec::new("frame.member", "Frame Member", member).at("SOLID", "CREATE").icon("frame_member").enabled(not_in_sketch).params(concat!( + "sketch: the frame sketch (id or name; 2D or 3D); lines?: [curve ids] (one member each; default: every straight line drawn in the sketch: not construction, not projected or otherwise linked, not of no length); ", + profile_doc!(), + " | profile_sketch: a sketch whose profile is the section, in its own x and y around its origin, with profiles?: picks as solid.extrude takes them (default: its material, nested loops alternating, so two circles make a tube); anchor?: origin|center|centroid|top_left|top|top_right|left|right|bottom_left|bottom|bottom_right (the profile point that sits on each line, default origin); angle?: angle expr (the profile turned about the line; default 0: its y as near world up as the line allows, a vertical member's x along world X); mirror?: bool (the profile mirrored across its y axis); name?; body_name? (what the members are named after, then numbered: `Rail`, `Rail (2)`…; default the profile's description, `Rect Tube 40x40x3`, or the profile sketch's name); names?: {line id: name} (names to keep, as Edit Feature keeps them). One body per line, from its start to its end; each member keeps its name when other lines are added or deleted. Returns the feature and the bodies it made" + )), + CommandSpec::new("frame.profile.list", "Profile Library", list).icon("library") + .noundo() + .doc_only() + .params("Returns kinds: [{kind, label, required, optional, example}]: the built-in profiles, each SolidCraft's own parametric definition"), + CommandSpec::new("frame.profile.properties", "Profile Properties", properties).icon("measure").noundo().doc_only().params(concat!( + profile_doc!(), + "; density?: kg/m³ (adds mass_per_m, kg/m). Returns profile (every default filled in), area (mm²), centroid [x,y] (mm, from the bounding box centre), ixx, iyy, ixy (mm⁴ about the centroid), width, height" + )), + CommandSpec::new("frame.profile.insert", "Insert Profile", insert).icon("import").enabled(in_sketch).params(concat!( + profile_doc!(), + "; at?: [x,y] (sketch mm, default the origin); anchor?: origin|center|centroid|top_left|top|top_right|left|right|bottom_left|bottom|bottom_right (the profile point placed on `at`: its origin, the default and a built-in profile's centre, its bounding box's centre, its centroid, or a corner or edge middle of its bounding box); angle?: radians, counter-clockwise; fixed?: bool (default true: the curves are locked, so solving the sketch keeps the shape); sketch?: id|name (default: the sketch being edited). Returns the section as frame.profile.properties does", + created_doc!() + )), +]; + +/// The `profile` parameter: its kind and each dimension as given, a number or an expression. +fn profile_spec(cmd: &str, p: &Value) -> Result { + let v = p.get("profile").ok_or_else(|| bad(cmd, "`profile` is required (frame.profile.list gives the kinds)"))?; + serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("`profile`: {e}"))) +} + +/// The profile a spec makes in this design: a bare number in the design's units, an expression +/// over its parameters. +fn resolve(s: &Session, cmd: &str, spec: &ProfileSpec) -> Result { + let doc = &s.doc; + spec.resolve(|name, e| { + doc.eval(&doc.with_design_unit(e, Kind::Length), Kind::Length) + .map_err(|err| solidcraft_doc::DocError::Invalid(format!("profile `{name}`: {err}"))) + }) + .map_err(|e| bad(cmd, e.to_string())) +} + +/// The profile of `p`, worked out in this design. +fn profile(s: &Session, cmd: &str, p: &Value) -> Result { + resolve(s, cmd, &profile_spec(cmd, p)?) +} + +fn list(_: &mut Session, _: &Value) -> Result { + Ok(json!({"kinds": frame::profile_kinds()})) +} + +/// The profile's section as the commands report it. +fn section_json(cmd: &str, profile: &Profile, density: Option) -> Result { + let region = profile.region().map_err(|e| bad(cmd, e.to_string()))?; + let s = frame::section(®ion); + let mut out = json!({ + "profile": profile.filled(), + "area": s.area, + "centroid": s.centroid, + "ixx": s.ixx, + "iyy": s.iyy, + "ixy": s.ixy, + "width": s.width, + "height": s.height, + }); + if let (Some(rho), Some(o)) = (density, out.as_object_mut()) { + // mm² × 1e-6 m²/mm² × kg/m³ = kg/m. + o.insert("mass_per_m".into(), json!(s.area * 1e-6 * rho)); + } + Ok(out) +} + +fn properties(s: &mut Session, p: &Value) -> Result { + let cmd = "frame.profile.properties"; + let profile = profile(s, cmd, p)?; + let density = match p.get("density") { + None | Some(Value::Null) => None, + Some(_) => Some( + num(p, "density") + .filter(|d| *d > 0.0 && *d <= 1e6) + .ok_or_else(|| bad(cmd, "`density` must be a positive number of kg/m³ (at most 1e6)"))?, + ), + }; + section_json(cmd, &profile, density) +} + +fn insert(s: &mut Session, p: &Value) -> Result { + let cmd = "frame.profile.insert"; + let profile = profile(s, cmd, p)?; + let at = match p.get("at") { + None | Some(Value::Null) => Vec2::ZERO, + Some(v) => vec2(v).ok_or_else(|| bad(cmd, "`at` must be [x, y] in sketch millimetres"))?, + }; + let anchor: Anchor = match p.get("anchor") { + None | Some(Value::Null) => Anchor::default(), + Some(v) => serde_json::from_value(v.clone()).map_err(|_| { + bad(cmd, "`anchor` must be origin, center, centroid, top_left, top, top_right, left, right, bottom_left, bottom or bottom_right") + })?, + }; + let angle = match p.get("angle") { + None | Some(Value::Null) => 0.0, + Some(_) => num(p, "angle").filter(|a| a.abs() <= 1e6).ok_or_else(|| bad(cmd, "`angle` must be a number of radians"))?, + }; + let fixed = match p.get("fixed") { + None | Some(Value::Null) => true, + Some(_) => bool_(p, "fixed").ok_or_else(|| bad(cmd, "`fixed` must be true or false"))?, + }; + let region = profile.region().map_err(|e| bad(cmd, e.to_string()))?; + let placed = frame::placed(®ion, frame::anchor_point(std::slice::from_ref(®ion), anchor), at, angle); + let section = section_json(cmd, &profile, None)?; + let (created, info) = edit(s, p, cmd, false, |sk, _| { + let before = sk.clone(); + for l in std::iter::once(&placed.outer).chain(&placed.holes) { + add_loop(sk, l, fixed, cmd)?; + } + Ok(super::sketch_created(Some(&before), sk)) + })?; + Ok(json!({"created": created, "section": section, "sketch": info})) +} + +/// Draw the closed loop `l` (lines and arcs) into the sketch, each segment sharing its end +/// points with its neighbours; a loop of two half arcs of one circle becomes a circle. +fn add_loop(sk: &mut Sketch, l: &Loop2, fixed: bool, cmd: &str) -> Result<()> { + let mut curves = Vec::new(); + if let [Seg2::Arc { center: c0, radius: r0, sweep: s0, .. }, Seg2::Arc { center: c1, radius: r1, sweep: s1, .. }] = l.segs.as_slice() + && c0.dist(*c1) <= 1e-9 + && (r0 - r1).abs() <= 1e-9 + && ((s0 + s1).abs() - std::f64::consts::TAU).abs() <= 1e-9 + { + curves.push(sk.add_circle(*c0, *r0, None, None)?); + } else { + let pts = l.segs.iter().map(|g| sk.add_point(g.start(), None)).collect::, _>>()?; + let n = pts.len(); + for (i, g) in l.segs.iter().enumerate() { + let (Some(&a), Some(&b)) = (pts.get(i), pts.get((i + 1) % n)) else { + return Err(bad(cmd, "a profile segment is missing its end")); + }; + let c = match *g { + Seg2::Line { .. } => sk.add_line_pts(a, b, None)?, + // The sketch's arcs run counter-clockwise: a clockwise one is drawn from its end. + Seg2::Arc { center, sweep, .. } if sweep >= 0.0 => sk.add_arc(center, g.start(), g.end(), [None, Some(a), Some(b)], None)?, + Seg2::Arc { center, .. } => sk.add_arc(center, g.end(), g.start(), [None, Some(b), Some(a)], None)?, + _ => return Err(bad(cmd, "not supported yet: profiles with free-form edges")), + }; + curves.push(c); + } + } + if fixed { + for c in curves { + if let Some(cu) = sk.curves.get_mut(c) { + cu.fixed = true; + } + } + } + Ok(()) +} + +fn member(s: &mut Session, p: &Value) -> Result { + let cmd = "frame.member"; + let path_sketch = sketch_id(s, p.get("sketch"), cmd, "sketch")?; + let lines = match p.get("lines") { + None | Some(Value::Null) => Vec::new(), + Some(Value::Array(a)) if !a.is_empty() && a.len() <= frame::MAX_MEMBERS && a.iter().all(Value::is_string) => { + let lines = string_list(p, "lines"); + let mut seen = std::collections::HashSet::new(); + if let Some(twice) = lines.iter().find(|l| !seen.insert(l.as_str())) { + return Err(bad(cmd, format!("line `{twice}` is listed twice"))); + } + lines + } + Some(_) => Err(bad(cmd, format!("`lines` must list 1…{} curve ids", frame::MAX_MEMBERS)))?, + }; + let body_name = match p.get("body_name") { + None | Some(Value::Null) => None, + Some(_) => Some( + str_(p, "body_name") + .map(str::trim) + .filter(|n| !n.is_empty() && n.len() <= 128) + .ok_or_else(|| bad(cmd, "`body_name` must be 1…128 characters"))? + .to_string(), + ), + }; + let (profile, default_name) = match (p.get("profile"), p.get("profile_sketch")) { + (Some(_), None) => { + let spec = profile_spec(cmd, p)?; + let pr = resolve(s, cmd, &spec)?; + pr.region().map_err(|e| bad(cmd, e.to_string()))?; + (MemberProfile::Library { profile: spec }, pr.description()) + } + (None, Some(v)) => { + let id = sketch_id(s, Some(v), cmd, "profile_sketch")?; + if id == path_sketch { + return Err(bad(cmd, "the profile sketch must not be the frame sketch")); + } + let name = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_else(|| "Member".into()); + (MemberProfile::Sketch { sketch: SketchId(id), profiles: super::feature_profiles(p, cmd)? }, name) + } + _ => return Err(bad(cmd, "give `profile` (a built-in profile) or `profile_sketch` (a sketch), one of them")), + }; + let anchor: Anchor = match p.get("anchor") { + None | Some(Value::Null) => Anchor::default(), + Some(v) => serde_json::from_value(v.clone()).map_err(|_| { + bad(cmd, "`anchor` must be origin, center, centroid, top_left, top, top_right, left, right, bottom_left, bottom or bottom_right") + })?, + }; + let angle = match p.get("angle") { + None | Some(Value::Null) => None, + Some(_) => { + let e = expr(p, "angle").ok_or_else(|| bad(cmd, "`angle` must be an angle expression"))?; + check_expr(s, &e, solidcraft_doc::expr::Kind::Angle, cmd, "angle")?; + Some(e) + } + }; + let mirror = match p.get("mirror") { + None | Some(Value::Null) => false, + Some(_) => bool_(p, "mirror").ok_or_else(|| bad(cmd, "`mirror` must be true or false"))?, + }; + let kept: BTreeMap = match p.get("names") { + None | Some(Value::Null) => BTreeMap::new(), + Some(Value::Object(m)) if m.len() <= frame::MAX_MEMBERS => m + .iter() + .map(|(line, v)| { + let name = v.as_str().map(str::trim).filter(|n| !n.is_empty() && n.len() <= 128); + name.map(|n| (line.clone(), n.to_string())) + .ok_or_else(|| bad(cmd, format!("`names`: the name of line `{line}` must be 1…128 characters"))) + }) + .collect::>()?, + Some(_) => return Err(bad(cmd, format!("`names` must map up to {} line ids to names", frame::MAX_MEMBERS))), + }; + let base = body_name.clone().unwrap_or(default_name); + let mut kind = + FeatureKind::FrameMember { path_sketch: SketchId(path_sketch), lines, profile, anchor, angle, mirror, body_name, names: BTreeMap::new() }; + // Each member's name is pinned to its line as the feature is made, so deleting or adding a + // line later leaves the other members' names alone. + let st = s.model.state(); + let ids = frame::member_line_ids(&st, &kind).map_err(|e| bad(cmd, e.to_string()))?; + let pinned: BTreeMap = ids.iter().cloned().zip(frame::member_names(&st, &ids, &kept, &base)).collect(); + if let FeatureKind::FrameMember { names, .. } = &mut kind { + *names = pinned; + } + // The feature names its own members (`body_name`, `names`), not by position. + let mut p = p.clone(); + if let Some(o) = p.as_object_mut() { + o.remove("body_name"); + o.remove("body_names"); + o.remove("names"); + } + add_feature(s, &p, kind) +} diff --git a/crates/engine/src/cmd/frame_tests.rs b/crates/engine/src/cmd/frame_tests.rs new file mode 100644 index 0000000000..5559b90935 --- /dev/null +++ b/crates/engine/src/cmd/frame_tests.rs @@ -0,0 +1,640 @@ +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use serde_json::{Value, json}; +use std::f64::consts::PI; + +use crate::Session; + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + match s.execute(id, &p) { + Ok(v) => v, + Err(e) => panic!("{id} {p}: {e}"), + } +} + +fn f(v: &Value) -> f64 { + v.as_f64().unwrap_or_else(|| panic!("not a number: {v}")) +} + +fn rel(a: f64, b: f64) -> f64 { + (a - b).abs() / b.abs().max(1e-12) +} + +fn new_sketch() -> Session { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY"})); + s +} + +/// The areas of the active sketch's profiles, largest first. +fn profile_areas(s: &Session) -> Vec { + let id = s.active_sketch.unwrap(); + let mut a: Vec = s.model.state().sketch(id).unwrap().profiles.iter().map(|p| p.area).collect(); + a.sort_by(|x, y| y.total_cmp(x)); + a +} + +#[test] +fn list_gives_every_kind_with_a_working_example() { + let mut s = Session::default(); + let out = run(&mut s, "frame.profile.list", json!({})); + let kinds = out["kinds"].as_array().unwrap(); + let ids: Vec<&str> = kinds.iter().filter_map(|k| k["kind"].as_str()).collect(); + assert_eq!(ids, ["bar", "round_bar", "rect_tube", "round_tube", "angle", "channel", "i_beam"]); + for k in kinds { + let r = run(&mut s, "frame.profile.properties", json!({"profile": k["example"]})); + assert!(f(&r["area"]) > 0.0, "{}", k["kind"]); + } +} + +#[test] +fn properties_fill_defaults_and_give_mass_per_metre() { + let mut s = Session::default(); + let r = run( + &mut s, + "frame.profile.properties", + json!({"profile": {"kind": "rect_tube", "width": 40, "height": 40, "wall": 3, "outer_radius": 0}, "density": 7850}), + ); + let area = 40.0 * 40.0 - 34.0 * 34.0; + assert!(rel(f(&r["area"]), area) < 1e-12, "{r}"); + assert!(rel(f(&r["mass_per_m"]), area * 1e-6 * 7850.0) < 1e-12, "{r}"); + assert!(rel(f(&r["ixx"]), (40f64.powi(4) - 34f64.powi(4)) / 12.0) < 1e-12, "{r}"); + let r = run(&mut s, "frame.profile.properties", json!({"profile": {"kind": "rect_tube", "width": 40, "height": 40, "wall": 3}})); + assert_eq!(r["profile"]["outer_radius"], json!(6.0), "{r}"); + assert!(r.get("mass_per_m").is_none(), "{r}"); +} + +#[test] +fn bad_profiles_are_refused_with_the_reason() { + let mut s = new_sketch(); + for (p, why) in [ + (json!({}), "`profile` is required"), + (json!({"profile": {"kind": "hexagon"}}), "`profile`"), + (json!({"profile": {"kind": "rect_tube", "width": 10, "height": 10, "wall": 5}}), "wall"), + (json!({"profile": {"kind": "round_bar", "diameter": -1}}), "diameter"), + (json!({"profile": {"kind": "round_bar", "diameter": 10}, "density": -1}), "density"), + (json!({"profile": {"kind": "round_bar", "diameter": 10}, "density": "steel"}), "density"), + ] { + let err = s.execute("frame.profile.properties", &p).unwrap_err().to_string(); + assert!(err.contains(why), "{p}: {err}"); + } + for p in [ + json!({"profile": {"kind": "round_bar", "diameter": 10}, "at": [1e300, 0]}), + json!({"profile": {"kind": "round_bar", "diameter": 10}, "at": "here"}), + json!({"profile": {"kind": "round_bar", "diameter": 10}, "anchor": "middle"}), + json!({"profile": {"kind": "round_bar", "diameter": 10}, "angle": "a lot"}), + json!({"profile": {"kind": "round_bar", "diameter": 10}, "fixed": 3}), + ] { + assert!(s.execute("frame.profile.insert", &p).is_err(), "{p} is refused"); + } + // Nothing was drawn. + assert!(profile_areas(&s).is_empty()); +} + +#[test] +fn insert_needs_a_sketch() { + let mut s = Session::default(); + assert!(s.execute("frame.profile.insert", &json!({"profile": {"kind": "round_bar", "diameter": 10}})).is_err()); +} + +#[test] +fn inserted_tube_is_one_profile_with_its_hole() { + let mut s = new_sketch(); + let r = run(&mut s, "frame.profile.insert", json!({"profile": {"kind": "rect_tube", "width": 40, "height": 40, "wall": 3}})); + let area = f(&r["section"]["area"]); + // Outer radius 6, inner radius 3. + let want = (1600.0 - (4.0 - PI) * 36.0) - (34.0 * 34.0 - (4.0 - PI) * 9.0); + assert!(rel(area, want) < 1e-12, "{r}"); + // Four lines and four arcs outside, the same inside. + assert_eq!(r["created"]["curves"].as_array().unwrap().len(), 16, "{r}"); + // The sketch has the wall and, as a region of its own, the inside. + let areas = profile_areas(&s); + assert_eq!(areas.len(), 2, "{areas:?}"); + assert!(areas.iter().any(|a| rel(*a, want) < 1e-9), "the wall is the tube's section: {areas:?}"); +} + +#[test] +fn inserted_channel_extrudes_to_its_area_times_length() { + let mut s = new_sketch(); + let r = run( + &mut s, + "frame.profile.insert", + json!({"profile": {"kind": "channel", "height": 60, "width": 45, "web": 5, "flange": 6}, "at": [100, 50], "anchor": "bottom_left"}), + ); + let area = f(&r["section"]["area"]); + let areas = profile_areas(&s); + assert!(areas.iter().any(|a| rel(*a, area) < 1e-9), "{areas:?} vs {area}"); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance": 500, "profiles": [{"point": [101.5, 51.5]}]})); + let m = run(&mut s, "inspect.measure", json!({})); + let v = f(&m["total"]["volume_mm3"]); + // The kernel measures arcs through their facets: within 1e-4. + assert!(rel(v, area * 500.0) < 1e-4, "volume {v} vs {}", area * 500.0); + // The bottom-left corner sits on (100, 50): the body spans 100…145 and 50…110. + let bb = &m["bodies"][0]["bbox"]; + let near = |v: &Value, want: f64| (f(v) - want).abs() < 1e-6; + assert!(near(&bb["min"][0], 100.0) && near(&bb["min"][1], 50.0) && near(&bb["max"][0], 145.0) && near(&bb["max"][1], 110.0), "{bb}"); +} + +#[test] +fn inserted_profile_turns_about_its_anchor_and_stays_fixed() { + let mut s = new_sketch(); + let r = run( + &mut s, + "frame.profile.insert", + json!({"profile": {"kind": "angle", "width": 60, "height": 40, "thickness": 5}, "anchor": "bottom_left", "angle": PI / 2.0}), + ); + let id = s.active_sketch.unwrap(); + let sk = s.model.state().sketch(id).unwrap().sketch.clone(); + let created: Vec<&str> = r["created"]["curves"].as_array().unwrap().iter().filter_map(Value::as_str).collect(); + assert!(!created.is_empty()); + for c in sk.curves.iter().filter(|c| created.contains(&c.id.as_str())) { + assert!(c.fixed, "{} is locked", c.id); + } + // Turned a quarter about the heel at the origin: the 60 mm leg runs up the y axis, the 40 mm + // one along −x. + let (mut lo, mut hi) = ([f64::INFINITY; 2], [f64::NEG_INFINITY; 2]); + for p in &sk.points { + lo = [lo[0].min(p.pos.x), lo[1].min(p.pos.y)]; + hi = [hi[0].max(p.pos.x), hi[1].max(p.pos.y)]; + } + assert!((lo[0] + 40.0).abs() < 1e-9 && hi[0].abs() < 1e-9 && lo[1].abs() < 1e-9 && (hi[1] - 60.0).abs() < 1e-9, "{lo:?} {hi:?}"); + // Unlocked on request. + let r = + run(&mut s, "frame.profile.insert", json!({"profile": {"kind": "round_tube", "diameter": 20, "wall": 2}, "at": [200, 0], "fixed": false})); + assert_eq!(r["created"]["curves"].as_array().unwrap().len(), 2, "two circles: {r}"); + let sk = s.model.state().sketch(id).unwrap().sketch.clone(); + assert!(sk.curves.iter().filter(|c| r["created"]["curves"].as_array().unwrap().contains(&json!(c.id))).all(|c| !c.fixed)); +} + +#[test] +fn insert_is_one_undo_step() { + let mut s = new_sketch(); + run(&mut s, "frame.profile.insert", json!({"profile": {"kind": "i_beam", "height": 100, "width": 50, "web": 4.5, "flange": 7}})); + assert_eq!(profile_areas(&s).len(), 1); + run(&mut s, "edit.undo", json!({})); + assert!(profile_areas(&s).is_empty()); +} + +// --------------------------------------------------------------------------------------------- +// Members + +/// How close a measured volume comes: the kernel measures curved faces through their facets. +const MEASURED: f64 = 1e-3; + +/// The section area of a 40 × 40 × 3 rectangular tube (outer radius 6, inner 3). +fn tube_area() -> f64 { + (1600.0 - (4.0 - PI) * 36.0) - (34.0 * 34.0 - (4.0 - PI) * 9.0) +} + +fn tube() -> Value { + json!({"kind": "rect_tube", "width": 40, "height": 40, "wall": 3}) +} + +/// A body's name, volume, bounding box and centre of mass. +type Measured = (String, f64, [f64; 3], [f64; 3], [f64; 3]); + +fn bodies(s: &mut Session) -> Vec { + let m = run(s, "inspect.measure", json!({})); + let v3 = |v: &Value| [f(&v[0]), f(&v[1]), f(&v[2])]; + m["bodies"] + .as_array() + .unwrap() + .iter() + .map(|b| { + (b["name"].as_str().unwrap().to_string(), f(&b["volume_mm3"]), v3(&b["bbox"]["min"]), v3(&b["bbox"]["max"]), v3(&b["center_of_mass"])) + }) + .collect() +} + +fn total_volume(s: &mut Session) -> f64 { + bodies(s).iter().map(|b| b.1).sum() +} + +fn near3(got: [f64; 3], want: [f64; 3]) -> bool { + got.iter().zip(want).all(|(g, w)| (g - w).abs() < 1e-6) +} + +/// A 600 × 400 rectangle on XY, as the frame sketch `Frame`. +fn rectangle_frame(s: &mut Session) { + run(s, "sketch.create", json!({"plane": "XY", "name": "Frame"})); + run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [600, 400]})); + run(s, "sketch.finish", json!({})); +} + +#[test] +fn members_follow_every_line_of_a_rectangle() { + let mut s = Session::default(); + rectangle_frame(&mut s); + let r = run(&mut s, "frame.member", json!({"sketch": "Frame", "profile": tube()})); + let bs = bodies(&mut s); + assert_eq!(bs.len(), 4, "{r}"); + let names: Vec<&str> = bs.iter().map(|b| b.0.as_str()).collect(); + assert_eq!(names, ["Rect Tube 40x40x3", "Rect Tube 40x40x3 (2)", "Rect Tube 40x40x3 (3)", "Rect Tube 40x40x3 (4)"]); + // Each member is the section run the length of its line: 2 × 600 + 2 × 400. + let v = total_volume(&mut s); + assert!(rel(v, tube_area() * 2000.0) < MEASURED, "{v} vs {}", tube_area() * 2000.0); + // The bottom line runs along +X: the profile is centred on it, square to it. + assert!(bs.iter().any(|b| near3(b.2, [0.0, -20.0, -20.0]) && near3(b.3, [600.0, 20.0, 20.0])), "{bs:?}"); + // The right line runs along Y at x = 600. + assert!(bs.iter().any(|b| near3(b.2, [580.0, 0.0, -20.0]) && near3(b.3, [620.0, 400.0, 20.0])), "{bs:?}"); +} + +#[test] +fn members_follow_a_parameter_edit() { + let mut s = Session::default(); + run(&mut s, "parameters.add", json!({"name": "W", "expression": "600 mm"})); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Frame"})); + run(&mut s, "sketch.line", json!({"points": [[0, 0], ["W", 0], ["W", 400], [0, 400]], "closed": true})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "frame.member", json!({"sketch": "Frame", "profile": tube()})); + assert!(rel(total_volume(&mut s), tube_area() * 2000.0) < MEASURED); + run(&mut s, "parameters.change", json!({"name": "W", "expression": "800 mm"})); + // The two 600 mm members are 800 mm now. + let v = total_volume(&mut s); + assert!(rel(v, tube_area() * 2400.0) < MEASURED, "{v} vs {}", tube_area() * 2400.0); +} + +#[test] +fn members_along_3d_sketch_lines() { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Tower"})); + run(&mut s, "sketch.options", json!({"sketch_3d": true})); + run(&mut s, "sketch.line3d", json!({"points": [[0, 0, 0], [0, 0, 1500], [600, 0, 1500]]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "frame.member", json!({"sketch": "Tower", "profile": tube()})); + let bs = bodies(&mut s); + assert_eq!(bs.len(), 2, "{bs:?}"); + // The upright: square to Z, the profile's x along world X. + assert!(bs.iter().any(|b| near3(b.2, [-20.0, -20.0, 0.0]) && near3(b.3, [20.0, 20.0, 1500.0])), "{bs:?}"); + // The rail along X at the top. + assert!(bs.iter().any(|b| near3(b.2, [0.0, -20.0, 1480.0]) && near3(b.3, [600.0, 20.0, 1520.0])), "{bs:?}"); + let v = total_volume(&mut s); + assert!(rel(v, tube_area() * 2100.0) < MEASURED, "{v} vs {} {bs:?}", tube_area() * 2100.0); +} + +#[test] +fn a_profile_sketch_is_the_section() { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Tube section"})); + run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 20})); + run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 17})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "sketch.create", json!({"plane": "XZ", "name": "Post"})); + run(&mut s, "sketch.line", json!({"points": [[0, 0], [1000, 0]]})); + run(&mut s, "sketch.finish", json!({})); + // The material: the ring between the circles (nested loops alternate). + run(&mut s, "frame.member", json!({"sketch": "Post", "profile_sketch": "Tube section"})); + let bs = bodies(&mut s); + assert_eq!(bs.len(), 1); + assert_eq!(bs[0].0, "Tube section"); + let ring = PI * (400.0 - 289.0) * 1000.0; + assert!(rel(bs[0].1, ring) < MEASURED, "{} vs {ring}", bs[0].1); + // Picked: the inner disc alone. + run(&mut s, "frame.member", json!({"sketch": "Post", "profile_sketch": "Tube section", "profiles": [{"point": [0, 0]}]})); + // A second feature of the same profile numbers on from the first. + let disc = bodies(&mut s).into_iter().find(|b| b.0 != "Tube section").unwrap(); + assert_eq!(disc.0, "Tube section (2)"); + assert!(rel(disc.1, PI * 289.0 * 1000.0) < MEASURED, "{disc:?}"); +} + +#[test] +fn anchor_angle_and_mirror_place_the_profile() { + let place = |extra: Value| { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Rail"})); + run(&mut s, "sketch.line", json!({"points": [[0, 0], [1000, 0]]})); + run(&mut s, "sketch.finish", json!({})); + let mut p = json!({"sketch": "Rail", "profile": {"kind": "angle", "width": 60, "height": 40, "thickness": 5}, "anchor": "bottom_left"}); + for (k, v) in extra.as_object().unwrap() { + p[k] = v.clone(); + } + run(&mut s, "frame.member", p); + bodies(&mut s).remove(0) + }; + // Along +X the profile's x is world +Y and its y world +Z: the heel on the line, the legs + // running +Y (60) and +Z (40). + let plain = place(json!({})); + assert!(near3(plain.2, [0.0, 0.0, 0.0]) && near3(plain.3, [1000.0, 60.0, 40.0]), "{plain:?}"); + // Turned a quarter: the 60 mm leg up, the 40 mm one toward −Y. + let b = place(json!({"angle": "90 deg"})); + assert!(near3(b.2, [0.0, -40.0, 0.0]) && near3(b.3, [1000.0, 0.0, 60.0]), "{b:?}"); + // Mirrored: the same box, the heel at the far side (the mass sits past the middle). + let m = place(json!({"mirror": true})); + assert!(near3(m.2, [0.0, 0.0, 0.0]) && near3(m.3, [1000.0, 60.0, 40.0]), "{m:?}"); + assert!(plain.4[1] < 30.0 && m.4[1] > 30.0, "{plain:?} {m:?}"); +} + +#[test] +fn bad_members_are_refused() { + let mut s = Session::default(); + rectangle_frame(&mut s); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Arc"})); + let arc = run(&mut s, "sketch.arc.three_point", json!({"start": [0, 0], "end": [100, 0], "through": [50, 30]})); + let arc_id = arc["created"]["curves"][0].as_str().unwrap().to_string(); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Empty"})); + run(&mut s, "sketch.finish", json!({})); + for (p, why) in [ + (json!({"sketch": "Frame"}), "`profile`"), + (json!({"sketch": "Frame", "profile": tube(), "profile_sketch": "Frame"}), "one of them"), + (json!({"sketch": "Frame", "profile_sketch": "Frame"}), "must not be the frame sketch"), + (json!({"sketch": "Nowhere", "profile": tube()}), "no sketch"), + (json!({"sketch": "Frame", "profile": {"kind": "rect_tube", "width": 10, "height": 10, "wall": 5}}), "wall"), + (json!({"sketch": "Frame", "profile": tube(), "lines": []}), "`lines`"), + (json!({"sketch": "Frame", "profile": tube(), "lines": ["nope"]}), "nope"), + (json!({"sketch": "Arc", "profile": tube(), "lines": [arc_id]}), "not a straight line"), + (json!({"sketch": "Arc", "profile": tube()}), "no lines"), + (json!({"sketch": "Empty", "profile": tube()}), "no lines"), + (json!({"sketch": "Frame", "profile": tube(), "anchor": "middle"}), "`anchor`"), + (json!({"sketch": "Frame", "profile": tube(), "angle": "a lot"}), "angle"), + (json!({"sketch": "Frame", "profile": tube(), "mirror": 3}), "`mirror`"), + (json!({"sketch": "Frame", "profile": tube(), "names": ["Rail"]}), "`names`"), + (json!({"sketch": "Frame", "profile": tube(), "names": {"l1": " "}}), "`names`"), + ] { + let err = s.execute("frame.member", &p).unwrap_err().to_string(); + assert!(err.contains(why), "{p}: {err}"); + } + assert!(bodies(&mut s).is_empty(), "nothing was made"); + // Not while a sketch is open. + run(&mut s, "sketch.create", json!({"plane": "XY"})); + assert!(s.execute("frame.member", &json!({"sketch": "Frame", "profile": tube()})).is_err()); +} + +#[test] +fn members_undo_and_survive_save_and_reopen() { + let mut s = Session::default(); + rectangle_frame(&mut s); + run( + &mut s, + "frame.member", + json!({"sketch": "Frame", "profile": tube(), "anchor": "bottom", "angle": "15 deg", "mirror": true, "body_name": "Rail"}), + ); + let before = bodies(&mut s); + assert_eq!(before.len(), 4); + assert_eq!(before[0].0, "Rail"); + let mut reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + let after = bodies(&mut reopened); + assert_eq!(before.len(), after.len()); + for (b, a) in before.iter().zip(&after) { + assert_eq!(b.0, a.0); + assert!(rel(a.1, b.1) < 1e-9 && near3(a.2, b.2) && near3(a.3, b.3), "{b:?} vs {a:?}"); + } + run(&mut s, "edit.undo", json!({})); + assert!(bodies(&mut s).is_empty()); + run(&mut s, "edit.redo", json!({})); + assert_eq!(bodies(&mut s).len(), 4); +} + +// --------------------------------------------------------------------------------------------- +// Regressions from the adversarial review of #4003 + +/// The lines of sketch `name`, as (id, start, end) in sketch coordinates. +fn sketch_lines(s: &Session, name: &str) -> Vec<(String, [f64; 2], [f64; 2])> { + let id = s.doc.find_feature(name).unwrap().id; + let sk = s.doc.sketch(id).unwrap(); + sk.curves + .iter() + .filter_map(|c| match c.kind { + solidcraft_sketch::CurveKind::Line { a, b } => { + let (p, q) = (sk.points[a].pos, sk.points[b].pos); + Some((c.id.clone(), [p.x, p.y], [q.x, q.y])) + } + _ => None, + }) + .collect() +} + +/// The id of the line of sketch `name` with both ends at x = `x`. +fn line_at_x(s: &Session, name: &str, x: f64) -> String { + sketch_lines(s, name).into_iter().find(|(_, a, b)| (a[0] - x).abs() < 1e-9 && (b[0] - x).abs() < 1e-9).unwrap().0 +} + +/// The id of the line of sketch `name` with both ends at y = `y`. +fn line_at_y(s: &Session, name: &str, y: f64) -> String { + sketch_lines(s, name).into_iter().find(|(_, a, b)| (a[1] - y).abs() < 1e-9 && (b[1] - y).abs() < 1e-9).unwrap().0 +} + +fn delete_line(s: &mut Session, sketch: &str, line: &str) { + run(s, "sketch.edit", json!({"sketch": sketch})); + run(s, "sketch.delete", json!({"entities": [line]})); + run(s, "sketch.finish", json!({})); +} + +/// Each body's bounding box by name. +fn boxes(s: &mut Session) -> std::collections::BTreeMap { + bodies(s).into_iter().map(|b| (b.0, (b.2, b.3))).collect() +} + +#[test] +fn projected_edges_are_not_member_lines() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 100, "width": 100, "height": 10, "body_name": "Plate"})); + // A sketch on the plate's top face projects its edges; the one line drawn is the only member. + run(&mut s, "sketch.create", json!({"plane": {"face": [50, 50, 10]}, "name": "On plate"})); + run(&mut s, "sketch.line", json!({"points": [[10, 10], [90, 10]]})); + run(&mut s, "sketch.finish", json!({})); + // The plate's four edges are in the sketch, projected. + assert_eq!(sketch_lines(&s, "On plate").len(), 5); + run(&mut s, "frame.member", json!({"sketch": "On plate", "profile": tube()})); + let members: Vec = bodies(&mut s).into_iter().filter(|b| b.0 != "Plate").collect(); + assert_eq!(members.len(), 1, "{members:?}"); + assert!(rel(members[0].1, tube_area() * 80.0) < MEASURED, "{members:?}"); +} + +#[test] +fn a_profile_sketch_on_a_face_is_its_drawn_section() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 100, "width": 100, "height": 10, "body_name": "Plate"})); + run(&mut s, "sketch.create", json!({"plane": {"face": [50, 50, 10]}, "name": "Section on plate"})); + run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 20})); + run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 17})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "sketch.create", json!({"plane": "XZ", "name": "Post"})); + run(&mut s, "sketch.line", json!({"points": [[200, 0], [200, 1000]]})); + run(&mut s, "sketch.finish", json!({})); + assert_eq!(sketch_lines(&s, "Section on plate").len(), 4, "the plate's edges, projected"); + run(&mut s, "frame.member", json!({"sketch": "Post", "profile_sketch": "Section on plate"})); + let member = bodies(&mut s).into_iter().find(|b| b.0 != "Plate").unwrap(); + assert!(rel(member.1, PI * (400.0 - 289.0) * 1000.0) < MEASURED, "{member:?}"); +} + +#[test] +fn members_keep_their_names_when_a_line_is_deleted() { + let mut s = Session::default(); + rectangle_frame(&mut s); + run(&mut s, "frame.member", json!({"sketch": "Frame", "profile": tube()})); + let before = boxes(&mut s); + assert_eq!(before.len(), 4); + let right = line_at_x(&s, "Frame", 600.0); + delete_line(&mut s, "Frame", &right); + let after = boxes(&mut s); + assert_eq!(after.len(), 3, "{after:?}"); + // Each member left is still called what it was, and is where it was. + for (name, bb) in &after { + assert_eq!(before.get(name), Some(bb), "{name} moved: {before:?} → {after:?}"); + } +} + +#[test] +fn a_renamed_member_keeps_its_name_through_line_edits() { + let mut s = Session::default(); + rectangle_frame(&mut s); + run(&mut s, "frame.member", json!({"sketch": "Frame", "profile": tube()})); + let top = boxes(&mut s).into_iter().find(|(_, (lo, _))| (lo[1] - 380.0).abs() < 1e-6).unwrap(); + run(&mut s, "body.rename", json!({"body": top.0, "name": "Top rail"})); + let before = boxes(&mut s); + assert_eq!(before.get("Top rail"), Some(&top.1)); + let left = line_at_x(&s, "Frame", 0.0); + delete_line(&mut s, "Frame", &left); + let after = boxes(&mut s); + assert_eq!(after.len(), 3); + for (name, bb) in &after { + assert_eq!(before.get(name), Some(bb), "{name}: {before:?} → {after:?}"); + } + // It survives saving and reopening. + let mut reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + assert_eq!(boxes(&mut reopened), after); +} + +#[test] +fn a_numbered_member_name_is_kept_when_the_first_feature_goes() { + let mut s = Session::default(); + rectangle_frame(&mut s); + let (bottom, top) = (line_at_y(&s, "Frame", 0.0), line_at_y(&s, "Frame", 400.0)); + run(&mut s, "frame.member", json!({"sketch": "Frame", "lines": [bottom], "profile": tube(), "name": "First"})); + run(&mut s, "frame.member", json!({"sketch": "Frame", "lines": [top], "profile": tube(), "name": "Second"})); + assert_eq!(boxes(&mut s).keys().collect::>(), ["Rect Tube 40x40x3", "Rect Tube 40x40x3 (2)"]); + run(&mut s, "timeline.delete", json!({"feature": "First"})); + assert_eq!(boxes(&mut s).keys().collect::>(), ["Rect Tube 40x40x3 (2)"]); +} + +#[test] +fn an_asymmetric_profile_turns_with_its_3d_line() { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Tower"})); + run(&mut s, "sketch.options", json!({"sketch_3d": true})); + run(&mut s, "sketch.line3d", json!({"points": [[0, 0, 0], [0, 0, 1500], [600, 0, 1500]]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "frame.member", json!({"sketch": "Tower", "profile": {"kind": "bar", "width": 60, "height": 10}})); + let bs = bodies(&mut s); + // The post: the bar's width (its x) along world X, its height along Y. + assert!(bs.iter().any(|b| near3(b.2, [-30.0, -5.0, 0.0]) && near3(b.3, [30.0, 5.0, 1500.0])), "{bs:?}"); + // The rail along +X: width along +Y, height up. + assert!(bs.iter().any(|b| near3(b.2, [0.0, -30.0, 1495.0]) && near3(b.3, [600.0, 30.0, 1505.0])), "{bs:?}"); +} + +#[test] +fn a_post_a_hair_off_vertical_is_oriented_as_a_vertical_one() { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Posts"})); + run(&mut s, "sketch.options", json!({"sketch_3d": true})); + run(&mut s, "sketch.line3d", json!({"points": [[0, 0, 0], [1e-5, 0, 1000]]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "frame.member", json!({"sketch": "Posts", "profile": {"kind": "bar", "width": 60, "height": 10}})); + let b = bodies(&mut s).remove(0); + // Width along X as for a true vertical, not along Y. + assert!((b.3[0] - b.2[0] - 60.0).abs() < 1e-3 && (b.3[1] - b.2[1] - 10.0).abs() < 1e-3, "{b:?}"); +} + +#[test] +fn thick_sections_build_with_their_default_radii() { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Rail"})); + run(&mut s, "sketch.line", json!({"points": [[0, 0], [500, 0]]})); + run(&mut s, "sketch.finish", json!({})); + for profile in [ + json!({"kind": "angle", "width": 20, "height": 20, "thickness": 10}), + json!({"kind": "channel", "height": 50, "width": 10, "web": 6, "flange": 2}), + json!({"kind": "channel", "height": 20, "width": 40, "web": 5, "flange": 8}), + json!({"kind": "i_beam", "height": 100, "width": 10, "web": 6, "flange": 5}), + ] { + run(&mut s, "frame.member", json!({"sketch": "Rail", "profile": profile})); + } + assert_eq!(bodies(&mut s).len(), 4); +} + +#[test] +fn profile_dimensions_follow_parameters() { + let mut s = Session::default(); + run(&mut s, "parameters.add", json!({"name": "T", "expression": "3 mm"})); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Rail"})); + run(&mut s, "sketch.line", json!({"points": [[0, 0], [1000, 0]]})); + run(&mut s, "sketch.finish", json!({})); + let square = |t: f64| (1600.0 - (40.0 - 2.0 * t).powi(2)) * 1000.0; + let sharp = json!({"kind": "rect_tube", "width": 40, "height": 40, "wall": "T", "outer_radius": 0}); + let props = run(&mut s, "frame.profile.properties", json!({"profile": sharp})); + assert!(rel(f(&props["area"]), square(3.0) / 1000.0) < 1e-12, "{props}"); + run(&mut s, "frame.member", json!({"sketch": "Rail", "profile": sharp})); + assert!(rel(total_volume(&mut s), square(3.0)) < 1e-9); + run(&mut s, "parameters.change", json!({"name": "T", "expression": "2 mm"})); + assert!(rel(total_volume(&mut s), square(2.0)) < 1e-9, "{}", total_volume(&mut s)); + // A parameter that makes the profile not fit fails the feature, naming the dimension. + let err = s.execute("parameters.change", &json!({"name": "T", "expression": "25 mm"})).err().map(|e| e.to_string()).unwrap_or_default(); + let fid = s.doc.features.iter().find(|f| matches!(f.kind, solidcraft_doc::FeatureKind::FrameMember { .. })).unwrap().id; + let failing = s.model.result(fid).and_then(|r| r.error.clone()).unwrap_or_default(); + assert!(err.contains("wall") || failing.contains("wall"), "{err} / {failing}"); +} + +#[test] +fn bare_profile_numbers_are_in_the_design_units() { + let mut s = Session::default(); + run(&mut s, "document.units", json!({"units": "in"})); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Rail"})); + run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "frame.member", json!({"sketch": "Rail", "profile": {"kind": "bar", "width": 2, "height": 1}})); + let b = bodies(&mut s).remove(0); + // 2 in across (world Y) and 1 in up (world Z). + assert!((b.3[1] - b.2[1] - 50.8).abs() < 1e-6 && (b.3[2] - b.2[2] - 25.4).abs() < 1e-6, "{b:?}"); + let props = run(&mut s, "frame.profile.properties", json!({"profile": {"kind": "round_bar", "diameter": 1}})); + assert!((f(&props["width"]) - 25.4).abs() < 1e-9, "{props}"); +} + +#[test] +fn members_follow_a_frame_sketch_in_another_component() { + let mut s = Session::default(); + rectangle_frame(&mut s); + let c = run(&mut s, "component.create", json!({"name": "Frame parts", "grounded": false}))["component"].as_u64().unwrap(); + let o = s.doc.occurrence_of(c).unwrap().id; + run(&mut s, "occurrence.move", json!({"occurrence": o, "translate": [0, 0, 100], "capture": true})); + // Made in the moved component, the members still lie on the root's frame sketch. + run(&mut s, "frame.member", json!({"sketch": "Frame", "profile": tube()})); + let bs = bodies(&mut s); + assert_eq!(bs.len(), 4); + for b in &bs { + assert!((b.2[2] + 20.0).abs() < 1e-6 && (b.3[2] - 20.0).abs() < 1e-6, "{b:?}"); + } +} + +#[test] +fn listing_a_line_twice_is_refused() { + let mut s = Session::default(); + rectangle_frame(&mut s); + let bottom = line_at_y(&s, "Frame", 0.0); + let err = s.execute("frame.member", &json!({"sketch": "Frame", "profile": tube(), "lines": [bottom, bottom]})).unwrap_err().to_string(); + assert!(err.contains("listed twice"), "{err}"); +} + +/// Edit Feature keeps each member's name, a renamed one too, through a change of profile; giving +/// the members a name renames them all. +#[test] +fn editing_the_feature_keeps_member_names_until_it_names_them() { + let mut s = Session::default(); + rectangle_frame(&mut s); + let fid = run(&mut s, "frame.member", json!({"sketch": "Frame", "profile": tube()}))["feature"].clone(); + let top = boxes(&mut s).into_iter().find(|(_, (lo, _))| (lo[1] - 380.0).abs() < 1e-6).unwrap().0; + run(&mut s, "body.rename", json!({"body": top, "name": "Top rail"})); + let names: Vec = boxes(&mut s).into_keys().collect(); + let bigger = json!({"kind": "rect_tube", "width": 50, "height": 50, "wall": 3}); + run(&mut s, "timeline.redefine", json!({"feature": fid, "command": "frame.member", "params": {"sketch": "Frame", "profile": bigger}})); + let after = boxes(&mut s); + assert_eq!(after.keys().cloned().collect::>(), names); + // The top rail is the 50 mm tube now, centred on the line at y = 400. + assert!(near3(after["Top rail"].0, [0.0, 375.0, -25.0]) && near3(after["Top rail"].1, [600.0, 425.0, 25.0]), "{after:?}"); + let params = json!({"sketch": "Frame", "profile": bigger, "body_name": "Rail"}); + run(&mut s, "timeline.redefine", json!({"feature": fid, "command": "frame.member", "params": params})); + assert_eq!(boxes(&mut s).into_keys().collect::>(), ["Rail", "Rail (2)", "Rail (3)", "Rail (4)"]); + run(&mut s, "edit.undo", json!({})); + assert_eq!(boxes(&mut s).into_keys().collect::>(), names); +} diff --git a/crates/engine/src/cmd/joints_coupling_tests.rs b/crates/engine/src/cmd/joints_coupling_tests.rs index f817150b54..c4e77f56f1 100644 --- a/crates/engine/src/cmd/joints_coupling_tests.rs +++ b/crates/engine/src/cmd/joints_coupling_tests.rs @@ -26,7 +26,7 @@ fn disc(s: &mut Session, name: &str, x: f64, r: &str) { /// The disc alone, on no joint. fn loose_disc(s: &mut Session, name: &str, x: f64, r: &str) { run(s, "component.activate", json!({"component": "root"})); - run(s, "component.create", json!({"name": name})); + run(s, "component.create", json!({"name": name, "grounded": false})); run(s, "solid.cylinder", json!({"base": [x, 0, 0], "axis": [0, 0, 1], "radius": r, "height": 5})); } @@ -123,7 +123,7 @@ fn rack() -> Session { disc(&mut s, "Pinion", 0.0, "8 mm"); for (name, corner, length) in [("Rack", [-20.0, -14.0, 0.0], 40.0), ("Stop", [30.0, -14.0, 0.0], 4.0)] { run(&mut s, "component.activate", json!({"component": "root"})); - run(&mut s, "component.create", json!({"name": name})); + run(&mut s, "component.create", json!({"name": name, "grounded": false})); run(&mut s, "solid.box", json!({"length": length, "width": 4, "height": 4, "corner": corner})); } pin(&mut s, "slider", "Rail", "Base", "Rack", Vec3::new(0.0, -10.0, 0.0), Vec3::X); diff --git a/crates/engine/src/cmd/joints_tests.rs b/crates/engine/src/cmd/joints_tests.rs index 06ec308c40..ab0294ceaf 100644 --- a/crates/engine/src/cmd/joints_tests.rs +++ b/crates/engine/src/cmd/joints_tests.rs @@ -49,7 +49,7 @@ fn deleted_joint_ids_are_not_reused_after_reopening() { /// at y = 33, z = 4. fn leaf(s: &mut Session, name: &str) { run(s, "component.activate", json!({"component": "root"})); - run(s, "component.create", json!({"name": name})); + run(s, "component.create", json!({"name": name, "grounded": false})); run(s, "solid.box", json!({"length": 40, "width": 30, "height": 3, "corner": [0, 0, 0]})); run(s, "solid.cylinder", json!({"base": [0, 33, 4], "axis": [1, 0, 0], "radius": 4, "height": 40, "operation": "new"})); } @@ -128,7 +128,7 @@ fn two_cubes() -> Session { let mut s = Session::default(); for (name, x) in [("A", 0), ("B", 20)] { run(&mut s, "component.activate", json!({"component": "root"})); - run(&mut s, "component.create", json!({"name": name})); + run(&mut s, "component.create", json!({"name": name, "grounded": false})); run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]})); } let o = occ(&s, "A"); @@ -369,7 +369,7 @@ fn interference_box_sweep_matches_every_pair_check() { /// A small block in a new component: a part of a linkage, where only its joints matter. pub(super) fn part(s: &mut Session, name: &str, corner: [f64; 3]) { run(s, "component.activate", json!({"component": "root"})); - run(s, "component.create", json!({"name": name})); + run(s, "component.create", json!({"name": name, "grounded": false})); run(s, "solid.box", json!({"length": 2, "width": 2, "height": 2, "corner": corner})); } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index b26c54e865..af7a1d9d6b 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -44,6 +44,7 @@ pub use view_dxf::sketch::exportable as view_dxf_exportable; mod component_link; mod config; mod file; +mod frame; mod inspect; mod interference; pub(crate) mod joints; @@ -277,6 +278,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> { v.extend(construct::COMMANDS.iter()); v.extend(sheet::COMMANDS.iter()); v.extend(plastic::COMMANDS.iter()); + v.extend(frame::COMMANDS.iter()); v.extend(component::COMMANDS.iter()); v.extend(component_link::COMMANDS.iter()); v.extend(joints::COMMANDS.iter()); diff --git a/crates/engine/src/cmd/motion_sweep_tests.rs b/crates/engine/src/cmd/motion_sweep_tests.rs index d5e767d7d4..4802100d2b 100644 --- a/crates/engine/src/cmd/motion_sweep_tests.rs +++ b/crates/engine/src/cmd/motion_sweep_tests.rs @@ -11,7 +11,7 @@ use crate::Session; /// A part made of one box (`size` from `corner`) in a new component. fn block(s: &mut Session, name: &str, corner: [f64; 3], size: [f64; 3]) { run(s, "component.activate", json!({"component": "root"})); - run(s, "component.create", json!({"name": name})); + run(s, "component.create", json!({"name": name, "grounded": false})); run(s, "solid.box", json!({"length": size[0], "width": size[1], "height": size[2], "corner": corner})); } diff --git a/crates/engine/src/cmd/motion_tests.rs b/crates/engine/src/cmd/motion_tests.rs index e4ac363a12..1ceadf7006 100644 --- a/crates/engine/src/cmd/motion_tests.rs +++ b/crates/engine/src/cmd/motion_tests.rs @@ -62,7 +62,7 @@ enum JoinSelection { fn joined_motion_part(authoring: PartAuthoring, selection: JoinSelection) -> Session { let mut s = Session::default(); if matches!(authoring, PartAuthoring::InComponent) { - run(&mut s, "component.create", json!({"name":"Rotor"})); + run(&mut s, "component.create", json!({"name":"Rotor","grounded":false})); } run(&mut s, "solid.box", json!({"length":10,"width":8,"height":6,"body_name":"Part","name":"Base"})); run(&mut s, "select.set", json!({"items":[]})); @@ -75,12 +75,12 @@ fn joined_motion_part(authoring: PartAuthoring, selection: JoinSelection) -> Ses run(&mut s, "solid.box", p); if !matches!(authoring, PartAuthoring::InComponent) { let parent = if matches!(authoring, PartAuthoring::RootThenNestedTransfer) { - run(&mut s, "component.create", json!({"name":"Group","activate":false})); + run(&mut s, "component.create", json!({"name":"Group","activate":false,"grounded":false})); "Group" } else { "root" }; - run(&mut s, "component.create", json!({"name":"Rotor","parent":parent,"activate":false})); + run(&mut s, "component.create", json!({"name":"Rotor","parent":parent,"activate":false,"grounded":false})); run(&mut s, "component.move_bodies", json!({"bodies":["Part"],"component":"Rotor"})); } let driven = if matches!(authoring, PartAuthoring::RootThenNestedTransfer) { "Group" } else { "Rotor" }; @@ -95,7 +95,7 @@ fn attach_motion_rotor(s: &mut Session, component: &str, joint: &str) { fn unrelated_motion_rotor(s: &mut Session) { run(s, "component.activate", json!({"component":"root"})); - run(s, "component.create", json!({"name":"Unrelated"})); + run(s, "component.create", json!({"name":"Unrelated","grounded":false})); run(s, "solid.box", json!({"length":2,"width":2,"height":2,"corner":[100,100,100],"body_name":"Remote"})); attach_motion_rotor(s, "Unrelated", "Other turn"); } @@ -179,7 +179,7 @@ fn joint_pose_joined_cylinders_keep_mesh_cells_at_unseen_angles() { "solid.cylinder", json!({"radius":2.8,"height":30,"base":[0,36,0],"axis":[0.981627183447664,0,0.1908089953765448],"operation":"join","targets":["Crank"]}), ); - run(&mut s, "component.create", json!({"name":"Crank","activate":false})); + run(&mut s, "component.create", json!({"name":"Crank","activate":false,"grounded":false})); run(&mut s, "component.move_bodies", json!({"bodies":["Crank"],"component":"Crank"})); run(&mut s, "joint.as_built", json!({"type":"revolute","name":"Crank","a":0,"b":"Crank","at":{"occurrence":0,"point":[0,0,0],"z":[0,1,0]}})); assert_joint_only_moves(&mut s, "Crank"); @@ -258,7 +258,7 @@ fn joint_pose_combine_ignores_unrelated_motion() { /// with the target preserves that volume and must preserve the computed definition too. fn cross_component_motion_cut(move_tool_with_target: bool, kind: &str) -> Session { let mut s = Session::default(); - run(&mut s, "component.create", json!({"name":"MovingTarget"})); + run(&mut s, "component.create", json!({"name":"MovingTarget","grounded":false})); run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Target"})); run(&mut s, "component.activate", json!({"component":"root"})); if move_tool_with_target { @@ -280,7 +280,7 @@ fn motion_target_volume(s: &Session) -> f64 { fn motion_combine_cut(kind: &str, together: bool) -> Session { let mut s = Session::default(); - run(&mut s, "component.create", json!({"name":"MovingTarget"})); + run(&mut s, "component.create", json!({"name":"MovingTarget","grounded":false})); run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Target"})); run(&mut s, "component.activate", json!({"component":"root"})); run(&mut s, "component.create", json!({"name":"Tool"})); @@ -440,7 +440,7 @@ fn joint_pose_undo_and_redo_match_fresh_geometry() { fn joint_pose_geometry_edit_invalidates_the_warm_definition_mesh() { let mut s = Session::default(); run(&mut s, "parameters.add", json!({"name":"span","expression":"10 mm"})); - run(&mut s, "component.create", json!({"name":"Rotor"})); + run(&mut s, "component.create", json!({"name":"Rotor","grounded":false})); run(&mut s, "solid.box", json!({"length":"span","width":8,"height":6,"body_name":"Part"})); attach_motion_rotor(&mut s, "Rotor", "Turn"); run(&mut s, "joint.drive", json!({"joint":"Turn","value":5})); @@ -563,7 +563,7 @@ fn contact_check_finds_plates_crossing_face_to_face() { fn contact_uses_preserved_body_placements_after_component_reassignment() { let mut s = Session::default(); run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Transferred"})); - let component = run(&mut s, "component.create", json!({"name": "Carrier"})); + let component = run(&mut s, "component.create", json!({"name": "Carrier", "grounded": false})); run(&mut s, "occurrence.move", json!({"occurrence": component["occurrence"], "translate": [100, 0, 0]})); run(&mut s, "component.move_bodies", json!({"bodies": ["Transferred"], "component": component["component"]})); run(&mut s, "component.activate", json!({"component": "root"})); @@ -581,7 +581,7 @@ fn contact_uses_preserved_body_placements_after_component_reassignment() { fn automatic_explode_centres_follow_reassigned_bodies() { let mut s = Session::default(); run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Transferred"})); - let component = run(&mut s, "component.create", json!({"name": "Carrier"})); + let component = run(&mut s, "component.create", json!({"name": "Carrier", "grounded": false})); run(&mut s, "occurrence.move", json!({"occurrence": component["occurrence"], "translate": [100, 0, 0]})); run(&mut s, "component.move_bodies", json!({"bodies": ["Transferred"], "component": component["component"]})); run(&mut s, "component.activate", json!({"component": "root"})); diff --git a/crates/engine/src/cmd/occurrence_tests.rs b/crates/engine/src/cmd/occurrence_tests.rs index ea5d69f859..b236a8fbff 100644 --- a/crates/engine/src/cmd/occurrence_tests.rs +++ b/crates/engine/src/cmd/occurrence_tests.rs @@ -6,7 +6,7 @@ fn run(s: &mut Session, command: &str, p: Value) -> Value { s.execute(command, &p).unwrap() } fn created(s: &mut Session, name: &str) -> (u64, u64) { - let c = run(s, "component.create", json!({"name": name})); + let c = run(s, "component.create", json!({"name": name, "grounded": false})); (c["component"].as_u64().unwrap(), c["occurrence"].as_u64().unwrap()) } fn near(value: &Value, expected: f64) { @@ -828,3 +828,35 @@ fn joins_into_a_moved_occurrence_meet_the_tool_where_it_is_shown() { let want = 10.0 * 10.0 * 10.0 - 4.0 * 4.0 * 10.0; assert!((volume(&s, "C") - want).abs() < 1e-6 * want, "C: {} vs {want}", volume(&s, "C")); } + +#[test] +fn component_delete_removes_joints_to_its_occurrences_and_undo_restores_them() { + let mut s = Session::default(); + for (name, corner) in [("Arm", [20, -2, -2]), ("Kept", [-2, 20, -2])] { + run(&mut s, "component.activate", json!({"component": 0})); + created(&mut s, name); + run(&mut s, "solid.box", json!({"length": 4, "width": 4, "height": 4, "corner": corner})); + } + run( + &mut s, + "joint.create", + json!({"type": "revolute", "a": {"occurrence": 0, "point": [0,0,0]}, "b": {"occurrence": "Arm", "point": [0,0,0]}, "name": "Pivot"}), + ); + run( + &mut s, + "joint.create", + json!({"type": "rigid", "a": {"occurrence": 0, "point": [0,0,0]}, "b": {"occurrence": "Kept", "point": [0,0,0]}, "name": "Held"}), + ); + let arm = s.doc.find_component("Arm").unwrap(); + let before = s.doc.clone(); + + let reply = run(&mut s, "component.delete", json!({"component": "Arm"})); + let features: Vec = before.features.iter().filter(|f| f.component == arm).map(|f| f.id).collect(); + assert_eq!(reply, json!({"deleted": [arm], "features": features})); + let joints: Vec<&str> = s.doc.assembly.joints.iter().map(|j| j.name.as_str()).collect(); + assert_eq!(joints, ["Held"]); + run(&mut s, "joint.list", json!({})); + + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc, before); +} diff --git a/crates/engine/src/cmd/parts.rs b/crates/engine/src/cmd/parts.rs index 5ded0ce298..0c4bd27ff7 100644 --- a/crates/engine/src/cmd/parts.rs +++ b/crates/engine/src/cmd/parts.rs @@ -396,7 +396,8 @@ fn insert(s: &mut Session, p: &Value) -> Result { }; let name = str_(p, "name").map(str::to_string).unwrap_or_else(|| part_name(&family, &size, &sh)); let active = s.active_component; - let c = run(s, "component.create", json!({"name": name}))?; + // Hardware is fitted to other parts: it starts free even as the design's first part (#2804). + let c = run(s, "component.create", json!({"name": name, "grounded": false}))?; let (comp, occ) = (c["component"].as_u64().unwrap_or(0), c["occurrence"].as_u64().unwrap_or(0)); build(s, &sh)?; s.doc_mut().parts.insert(comp, solidcraft_doc::StandardPart { family: family.clone(), size: size.clone(), length: shape_len(&sh) }); diff --git a/crates/engine/src/cmd/patterns_tests.rs b/crates/engine/src/cmd/patterns_tests.rs index e12284342a..12802150d6 100644 --- a/crates/engine/src/cmd/patterns_tests.rs +++ b/crates/engine/src/cmd/patterns_tests.rs @@ -41,6 +41,65 @@ fn feature_pattern_plate() -> Session { s } +#[test] +fn rectangular_hole_pattern_warns_for_copies_that_miss_and_clears_after_edit() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":40,"width":30,"height":10,"corner":[0,0,0],"body_name":"Plate"})); + run(&mut s, "solid.hole", json!({"position":[10,15,10],"diameter":5,"depth":10,"name":"Hole1"})); + let before = volume(&mut s); + let reply = run( + &mut s, + "solid.pattern.rectangular", + json!({"features":["Hole1"],"dir1":[1,0,0],"count1":2,"spacing1":45, + "dir2":[0,0,1],"count2":2,"spacing2":5,"name":"Pattern"}), + ); + let id = reply["feature"].as_u64().unwrap(); + let check = |session: &Session| { + let result = session.model.result(id).unwrap(); + assert!(result.error.is_none(), "{result:?}"); + assert_eq!(result.warning.as_deref(), Some("3 of 3 cut copies remove no material")); + }; + check(&s); + assert_eq!(reply["warning"], "3 of 3 cut copies remove no material"); + // The remaining copy lands in the existing through hole, so the plate stays intact. + assert!(rel(volume(&mut s), before) < 1e-8); + let reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + check(&reopened); + run( + &mut s, + "timeline.redefine", + json!({"feature":"Pattern","command":"solid.pattern.rectangular","params":{ + "features":["Hole1"],"dir1":[1,0,0],"count1":2,"spacing1":45, + "dir2":[0,1,0],"count2":2,"spacing2":8}}), + ); + assert_eq!(s.model.result(id).unwrap().warning.as_deref(), Some("2 of 3 cut copies remove no material")); + assert!(rel(volume(&mut s), 12000.0 - 2.0 * PI * 2.5_f64.powi(2) * 10.0) < 1e-3); + run( + &mut s, + "timeline.redefine", + json!({"feature":"Pattern","command":"solid.pattern.rectangular","params":{ + "features":["Hole1"],"dir1":[1,0,0],"count1":2,"spacing1":15, + "dir2":[0,1,0],"count2":2,"spacing2":8}}), + ); + assert!(s.model.result(id).unwrap().warning.is_none()); + assert!(rel(volume(&mut s), 12000.0 - 4.0 * PI * 2.5_f64.powi(2) * 10.0) < 1e-3); +} + +#[test] +fn rectangular_cut_pattern_counts_only_target_contacts_and_keeps_landing_copies() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length":40,"width":30,"height":10,"corner":[0,0,0],"body_name":"Plate"})); + run(&mut s, "solid.box", json!({"length":40,"width":30,"height":10,"corner":[45,0,0],"operation":"new","body_name":"Neighbor"})); + run(&mut s, "solid.cylinder", json!({"radius":2,"height":20,"base":[10,15,-5],"operation":"cut","targets":["Plate"],"name":"Cut"})); + let reply = run(&mut s, "solid.pattern.rectangular", json!({"features":["Cut"],"dir1":[1,0,0],"count1":4,"spacing1":20})); + assert_eq!(reply["warning"], "2 of 3 cut copies remove no material"); + let state = s.world_state(); + let plate = &state.body("Plate").unwrap().body; + assert!(!plate.contains(solidcraft_geom::Vec3::new(30., 15., 5.))); + assert!(state.body("Neighbor").unwrap().body.contains(solidcraft_geom::Vec3::new(50., 15., 5.))); + assert!(rel(volume(&mut s), 24000.0 - 2.0 * PI * 4.0 * 10.0) < 1e-3); +} + #[test] fn feature_rectangular_center_flip_and_oblique_direction_keep_source_and_recompute() { use solidcraft_geom::Vec3; diff --git a/crates/engine/src/cmd/render/settings_tests.rs b/crates/engine/src/cmd/render/settings_tests.rs index ba0babac99..e205a9d65b 100644 --- a/crates/engine/src/cmd/render/settings_tests.rs +++ b/crates/engine/src/cmd/render/settings_tests.rs @@ -7,7 +7,7 @@ fn save(s: &mut Session, patch: Value) { #[test] fn settings_history_preserves_geometry_cache_and_dirty_state() { let mut s = Session::default(); - let component = s.execute("component.create", &json!({"name":"Part"})).unwrap(); + let component = s.execute("component.create", &json!({"name":"Part","grounded":false})).unwrap(); s.execute("solid.box", &json!({"length":10,"width":20,"height":5})).unwrap(); s.execute("component.activate", &json!({"component":0})).unwrap(); s.execute("occurrence.move", &json!({"occurrence":component["occurrence"],"translate":[30,0,0]})).unwrap(); diff --git a/crates/engine/src/cmd/sheet_convert_tests.rs b/crates/engine/src/cmd/sheet_convert_tests.rs index ddfa6682cf..1618b1a24e 100644 --- a/crates/engine/src/cmd/sheet_convert_tests.rs +++ b/crates/engine/src/cmd/sheet_convert_tests.rs @@ -124,7 +124,7 @@ fn rotated_component_bend_picks_follow_connected_panels() { let mut sizes = Vec::new(); for rotated in [false, true] { let mut s = Session::default(); - let component = run(&mut s, "component.create", json!({"name": "Bent part"})); + let component = run(&mut s, "component.create", json!({"name": "Bent part", "grounded": false})); rule(&mut s, "Test", 2.0, 0.5, 1.0, 0.5); let corners = bent_section(&mut s, &[(0.0, 0.0), (60.0, 0.0), (60.0, 40.0), (90.0, 40.0)], 2.0, 1.0, 100.0); // The remote bend is picked on its tangent edge, where it meets the web. @@ -145,7 +145,7 @@ fn rotated_component_bend_picks_follow_connected_panels() { #[test] fn rotated_component_disconnected_bend_reports_world_point() { let mut s = Session::default(); - let component = run(&mut s, "component.create", json!({"name": "Bent part"})); + let component = run(&mut s, "component.create", json!({"name": "Bent part", "grounded": false})); rule(&mut s, "Test", 2.0, 0.5, 1.0, 0.5); section(&mut s, &[(0.0, 0.0), (60.0, 0.0), (60.0, 40.0), (90.0, 40.0)], 2.0, 100.0); run(&mut s, "occurrence.move", json!({"occurrence": component["occurrence"], "axis": [1, 0, 0], "angle": "90 deg"})); diff --git a/crates/engine/src/cmd/sheet_dxf_tests.rs b/crates/engine/src/cmd/sheet_dxf_tests.rs index 992bb8c9be..7533e283dc 100644 --- a/crates/engine/src/cmd/sheet_dxf_tests.rs +++ b/crates/engine/src/cmd/sheet_dxf_tests.rs @@ -165,6 +165,40 @@ fn a_spline_cut_out_is_the_splines_it_was() { assert!((areas[0] - plate).abs() < 1e-6, "{areas:?} {plate}"); } +#[test] +fn a_g4_blend_cut_out_is_one_spline_of_degree_nine_and_closes() { + // Three sides of a 20 × 20 square, closed on the right by a G4 blend from (40, 20) to (40, 40): + // one Bézier of degree 9 over ten control points (solidcraft#5269). + let mut s = plate_with(&[]); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Cut"})); + let mut line = + |a: [i32; 2], b: [i32; 2]| run(&mut s, "sketch.line", json!({"points": [a, b]}))["curves"][0].as_str().unwrap_or_default().to_string(); + let (bottom, top) = (line([20, 20], [40, 20]), line([40, 40], [20, 40])); + line([20, 40], [20, 20]); + run(&mut s, "sketch.blend_curve", json!({"a": format!("{bottom}.end"), "b": format!("{top}.start"), "continuity": "g4"})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"sketch": "Cut", "through_all": true, "operation": "cut", "direction": "symmetric"})); + let hole: Vec = loops(&s).get(1).map(|l| l.segs.clone()).unwrap_or_default(); + let blend: Vec = hole.iter().find_map(|g| if let Seg2::Bezier { n: 9, p } = g { Some(p[..10].to_vec()) } else { None }).unwrap_or_default(); + assert_eq!((blend.len(), hole.len()), (10, 4), "{hole:?}"); + + // One SPLINE of degree 9 with all ten control points: its ends are the blend's own. + let dxf = export(&mut s, json!({})); + let es = entities(&dxf); + assert_eq!((count(&es, "LINE"), count(&es, "SPLINE"), es.len()), (7, 1, 8)); + let spline = es.iter().find(|e| e.0 == "SPLINE").unwrap(); + assert_eq!((values(spline, 70), values(spline, 71), values(spline, 72), values(spline, 73)), (vec![8.0], vec![9.0], vec![20.0], vec![10.0])); + let ctrl: Vec = values(spline, 10).into_iter().zip(values(spline, 20)).map(|(x, y)| Vec2::new(x, y)).collect(); + assert!(ctrl.len() == 10 && ctrl.iter().zip(&blend).all(|(a, b)| a.dist(*b) < 1e-9), "{ctrl:?} {blend:?}"); + let ends = [ctrl[0], ctrl[9]]; + assert!(ends.contains(&Vec2::new(40.0, 20.0)) && ends.contains(&Vec2::new(40.0, 40.0)), "{ends:?}"); + + // The cut-out closes, so the same plate and the same cut-out come back. + let areas = reimported_areas(&dxf); + let plate = s.model.state().sheets[0].flat_regions().unwrap_or_default()[0].area(); + assert!(areas.len() == 2 && (areas[0] - plate).abs() < 1e-6 && (areas[1] - (6000.0 - plate)).abs() < 1e-6, "{areas:?} {plate}"); +} + #[test] fn text_is_splines_and_lines_not_chords() { let mut s = plate_with(&[("sketch.text", json!({"text": "AB", "at": [30, 20], "height": 12}))]); diff --git a/crates/engine/src/cmd/sheet_tests.rs b/crates/engine/src/cmd/sheet_tests.rs index e6d9994745..ce496e6d4c 100644 --- a/crates/engine/src/cmd/sheet_tests.rs +++ b/crates/engine/src/cmd/sheet_tests.rs @@ -434,7 +434,7 @@ fn sheet_pocket_transfer_keeps_local_depth_world_pose_and_history() { run(&mut s, "solid.extrude", json!({"sketch":"Pocket","distance":1,"operation":"cut"})); let name = s.model.state().sheets[0].body.clone(); check(&s, &name, 0, Vec3::ZERO, Vec3::Y, false); - let destination = run(&mut s, "component.create", json!({"name":"Machined","parent":0})); + let destination = run(&mut s, "component.create", json!({"name":"Machined","parent":0,"grounded":false})); let component = destination["component"].as_u64().unwrap(); let occurrence = destination["occurrence"].as_u64().unwrap(); run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,20,10],"axis":[1,0,0],"angle":"90 deg","capture":true})); @@ -2269,7 +2269,7 @@ fn partial_depth_intersect_flipped_tool_and_transferred_sheet_keep_depth_side_an run(&mut s, "solid.extrude", json!({"sketch":"RetainDepth","distance":1,"start_offset":0.5,"operation":"intersect","name":"RetainSlab"})); let before = s.world_state().bodies[0].mesh().bounds(); let name = s.model.state().sheets[0].body.clone(); - let destination = run(&mut s, "component.create", json!({"name":"Destination","parent":0})); + let destination = run(&mut s, "component.create", json!({"name":"Destination","parent":0,"grounded":false})); let owner = destination["component"].as_u64().unwrap(); let occurrence = destination["occurrence"].as_u64().unwrap(); run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[30,20,10],"axis":[1,0,0],"angle":"90 deg","capture":true})); @@ -2315,7 +2315,7 @@ fn partial_depth_intersect_full_gauge_in_rotated_frames_does_not_invent_pockets( let mut s = Session::default(); base(&mut s, 100.0, 60.0); let name = s.model.state().sheets[0].body.clone(); - let destination = run(&mut s, "component.create", json!({"name":"Destination","parent":0})); + let destination = run(&mut s, "component.create", json!({"name":"Destination","parent":0,"grounded":false})); let owner = destination["component"].as_u64().unwrap(); run( &mut s, @@ -2493,7 +2493,7 @@ fn partial_depth_intersect_sub_extrusion_resolution_layers_refuse_atomically() { let mut s = Session::default(); base(&mut s, 100.0, 60.0); let name = s.model.state().sheets[0].body.clone(); - let destination = run(&mut s, "component.create", json!({"name":"Far","parent":0})); + let destination = run(&mut s, "component.create", json!({"name":"Far","parent":0,"grounded":false})); let owner = destination["component"].as_u64().unwrap(); run( &mut s, diff --git a/crates/engine/src/cmd/sketch.rs b/crates/engine/src/cmd/sketch.rs index 0551783137..78e742e44c 100644 --- a/crates/engine/src/cmd/sketch.rs +++ b/crates/engine/src/cmd/sketch.rs @@ -7,7 +7,7 @@ use solidcraft_sketch::{Analysis, Budget, ConstraintKind, CurveKind, Hold, Point use super::{CommandSpec, in_sketch}; use crate::params::{bad, bool_, expr, num, req_vec2, str_, string_list, vec2, vec3}; -use crate::{EngineError, Result, Session}; +use crate::{EngineError, Result, Session, SketchEntry}; #[path = "sketch_dimensioned.rs"] mod dimensioned; @@ -32,6 +32,11 @@ pub static COMMANDS: &[CommandSpec] = &[ .params("plane: XY|XZ|YZ | construction plane name or id | {origin?: [x,y,z] numbers in mm, default [0,0,0] (not an expression), x_dir, y_dir} | {face: [world x,y,z], body?: exact world body, index?: face index, path?: complete occurrence ids, fixed?: bool}; new face attachments require a unique certified source (a flat face of a sheet metal body attaches to its panel instead, in the flat pattern's coordinates), fixed:true explicitly freezes the selected plane; offset?: expr, flip?: bool | \"x\" | \"y\" (not on a face: sketch the plane from its other side, so the normal and the default extrude direction are reversed; a half turn about the plane's own x or y axis, true picks the one keeping the sketch's y axis upward; offset still moves along the unflipped normal), project_edges?: bool, name?"), CommandSpec::new("sketch.edit", "Edit Sketch", edit_sketch).noundo().icon("sketch").params("sketch: id or name"), CommandSpec::new("sketch.finish", "Finish Sketch", finish_sketch).at("SKETCH", "FINISH SKETCH").icon("finish").enabled(in_sketch).noundo(), + CommandSpec::new("sketch.cancel", "Cancel Sketch", cancel_sketch) + .at("SKETCH", "FINISH SKETCH") + .icon("sketch_cancel") + .enabled(in_sketch) + .params("Leaves the sketch without keeping what changed since it was created or opened for editing: the design goes back to how it was then (a new sketch is removed), as one step that Undo brings back. Refused when the undo history no longer leads back to that point (use Undo instead)"), CommandSpec::new("sketch.line", "Line", draw_line) .at("SKETCH", "CREATE") .icon("line") @@ -115,7 +120,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .params(concat!("p0, p1: overall ends, width", connect_doc!(), created_doc!())), CommandSpec::new("sketch.point", "Point", draw_point).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).params(concat!("point: [x,y] (each a number or an expression over the design's parameters: the point is then fixed where it comes to and follows them) or a snap: \"mid:\" (held at the line's midpoint), \"on::x,y\" (on the curve near x,y), {vertex: [x,y,z]} or {on_edge: [x,y,z]} of the model; id?", connect_doc!(), created_doc!())), CommandSpec::new("sketch.dimension", "Sketch Dimension", dimension).at("SKETCH", "CREATE").icon("dimension").key("D").enabled(in_sketch).params( - "entities: [refs], type?: auto|distance|horizontal|vertical|length|radius|diameter|angle, value?: number or expression (default: current; a bare number is in the design's units, e.g. 2 means 2 in in an inch design; give a unit such as \"50 mm\" to override), or `name = expression` (e.g. \"width = 40\"), which adds the user parameter `name` with that expression and dimensions with `name` (refused when the name is taken or reserved; the reply's `defined` names it); text_at?: [x, y]. Two points or a line off the axes: with auto, text_at across from them is the distance between them, above or below them horizontal, beside them vertical. Two lines: the angle between them (acute, or 180° − it when the value is nearer that) unless text_at picks a sector. A line and a circle or arc: the distance from the line to its edge (the gap, or how far the edge reaches past a line that cuts it; 0 is tangent); dimension the centre point for the distance to the centre. A dimension the sketch already determines is refused (naming what determines it); if_redundant?: \"driven\" places it as a driven (reference) dimension instead. scale_sketch?: bool (the dimension tool sends true): when this is the sketch's first length dimension and nothing in the sketch fixes a size (no other driving length dimension, nothing fixed or projected), the whole sketch scales about its origin to the value instead of distorting. Returns measured in unit (mm or deg), and scaled: the factor when the sketch was scaled", + "entities: [refs] (a line midpoint is \"mid:line_id\"), type?: auto|distance|horizontal|vertical|length|radius|diameter|angle, value?: number or expression (default: current; a bare number is in the design's units, e.g. 2 means 2 in in an inch design; give a unit such as \"50 mm\" to override), or `name = expression` (e.g. \"width = 40\"), which adds the user parameter `name` with that expression and dimensions with `name` (refused when the name is taken or reserved; the reply's `defined` names it); text_at?: [x, y]. Two points or a line off the axes: with auto, text_at across from them is the distance between them, above or below them horizontal, beside them vertical. Two midpoints: placing text above or below their bounding box measures horizontally, beside it vertically. Two lines: the angle between them (acute, or 180° − it when the value is nearer that) unless text_at picks a sector. A line and a circle or arc: the distance from the line to its edge (the gap, or how far the edge reaches past a line that cuts it; 0 is tangent); dimension the centre point for the distance to the centre. A dimension the sketch already determines is refused (naming what determines it); if_redundant?: \"driven\" places it as a driven (reference) dimension instead. scale_sketch?: bool (the dimension tool sends true): when this is the sketch's first length dimension and nothing in the sketch fixes a size (no other driving length dimension, nothing fixed or projected), the whole sketch scales about its origin to the value instead of distorting. Returns measured in unit (mm or deg), and scaled: the factor when the sketch was scaled", ), CommandSpec::new("sketch.constraint.horizontal_vertical", "Horizontal/Vertical", c_horizontal_vertical) .at("SKETCH", "CONSTRAINTS") @@ -721,6 +726,7 @@ pub(super) fn create_face_profile(s: &mut Session, p: &Value, body: &str, index: } fn create_sketch_with_plane(s: &mut Session, p: &Value, plane: PlaneRef, internal_body: Option<(&str, usize)>) -> Result { + let before = entry_point(s); if face::is_face(p) { face::capture_positions(s, &plane)?; } @@ -738,6 +744,7 @@ fn create_sketch_with_plane(s: &mut Session, p: &Value, plane: PlaneRef, interna f.component = comp; } s.active_sketch = Some(id); + s.sketch_entry = Some(SketchEntry { sketch: id, ..before }); let name = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default(); // Sketching on a face projects the face's edges (unless turned off). let face = p.get("plane").and_then(|v| v.get("face")).and_then(vec3); @@ -788,13 +795,56 @@ fn edit_sketch(s: &mut Session, p: &Value) -> Result { s.active_component = component; s.active_occurrence_path = (!path.is_empty()).then_some(path); } + if s.active_sketch != Some(id) || s.sketch_entry.as_ref().is_none_or(|entry| entry.sketch != id) { + s.sketch_entry = Some(SketchEntry { sketch: id, ..entry_point(s) }); + } s.active_sketch = Some(id); s.revision += 1; Ok(json!({"sketch": id, "name": name})) } +/// The design and undo history as they are now, where a sketch is entered (its id set after). +fn entry_point(s: &Session) -> SketchEntry { + SketchEntry { sketch: 0, doc: s.doc.clone(), undo_len: s.undo.len(), undo_top: s.undo.last().map(|snap| snap.doc.clone()) } +} + +/// Cancel Sketch (#3838): the design as it was when the open sketch was entered. The entry is +/// used only while the undo history still runs through it: the same steps before it, and every +/// step since taken in this sketch. Otherwise the steps since can't be told apart from others, +/// and Undo is the way back. +fn cancel_sketch(s: &mut Session, _p: &Value) -> Result { + let cmd = "sketch.cancel"; + let id = s.active_sketch.ok_or_else(|| bad(cmd, "no sketch is being edited"))?; + let entry = s.sketch_entry.clone().filter(|entry| entry.sketch == id); + let current = entry.filter(|entry| { + let top = entry.undo_len.checked_sub(1).and_then(|i| s.undo.get(i)).map(|snap| &snap.doc); + let same_before = s.undo.len() >= entry.undo_len + && match (top, &entry.undo_top) { + (Some(a), Some(b)) => std::sync::Arc::ptr_eq(a, b), + (None, None) => true, + _ => false, + }; + // The step that created the sketch was taken before it was open: its state is the entry's. + let since = s.undo.get(entry.undo_len..).unwrap_or_default(); + same_before + && since.iter().enumerate().all(|(i, snap)| snap.active_sketch == Some(id) || (i == 0 && std::sync::Arc::ptr_eq(&snap.doc, &entry.doc))) + }); + let Some(entry) = current else { + return Err(bad(cmd, "the undo history no longer leads back to where this sketch was opened: use Undo to go back")); + }; + let created = entry.doc.feature(id).is_none(); + // As Undo does: the session evaluates the design it is given. + s.doc = entry.doc; + s.active_sketch = None; + s.sketch_entry = None; + s.selection.clear(); + s.revision += 1; + Ok(json!({"sketch": id, "removed": created})) +} + fn finish_sketch(s: &mut Session, _p: &Value) -> Result { let id = s.active_sketch.take(); + s.sketch_entry = None; s.revision += 1; let profiles = id.and_then(|i| s.model.state().sketch(i).map(|ss| ss.profiles.len())).unwrap_or(0); Ok(json!({"sketch": id, "profiles": profiles})) @@ -2186,13 +2236,20 @@ fn dimension(s: &mut Session, p: &Value) -> Result { let start = s.doc.clone(); let ((param, kind_name, current), info) = edit(s, p, cmd, true, |sk, doc| { before = Some(dof_now(sk)); - let ent = |r: &str| -> Result<(Option, Option)> { + let mut ent = |r: &str| -> Result<(Option, Option)> { if let Some(c) = sk.curve_index(r) { return Ok((None, Some(c))); } if let Some(q) = sk.resolve_point(r) { return Ok((Some(q), None)); } + if let Some(line) = r.strip_prefix("mid:") { + let l = sk.curve_index(line).ok_or_else(|| bad(cmd, format!("unknown sketch line `{line}`")))?; + if line_pts(sk, l).is_none() { + return Err(bad(cmd, "a midpoint reference needs a line")); + } + return Ok((Some(AlignmentTarget::Midpoint(l).materialize(sk, cmd)?), None)); + } Err(bad(cmd, format!("unknown sketch entity `{r}`"))) }; let e0 = ent(ents.first().map(String::as_str).unwrap_or(""))?; @@ -2250,7 +2307,12 @@ fn dimension(s: &mut Session, p: &Value) -> Result { } ((Some(a), None), Some((Some(b), None))) => { let (pa, pb) = (sk.point(a).unwrap_or_default(), sk.point(b).unwrap_or_default()); - match auto_orientation(&ty, pa, pb, text_at) { + let orientation = if ents.iter().all(|id| id.starts_with("mid:")) { + midpoint_orientation(&ty, pa, pb, text_at) + } else { + auto_orientation(&ty, pa, pb, text_at) + }; + match orientation { "horizontal" => (ConstraintKind::DistanceX { p: a, q: b, value: (pb.x - pa.x).abs() }, (pb.x - pa.x).abs()), "vertical" => (ConstraintKind::DistanceY { p: a, q: b, value: (pb.y - pa.y).abs() }, (pb.y - pa.y).abs()), _ => (ConstraintKind::Distance { p: a, q: b, value: pa.dist(pb) }, pa.dist(pb)), @@ -2464,6 +2526,27 @@ fn auto_orientation(ty: &str, a: Vec2, b: Vec2, text_at: Option) -> &str { } } +/// Midpoint pairs choose an axis as soon as the text passes their bounding box. +/// Keeping the aligned sector inside that box avoids requiring a long pointer move +/// for shallow diagonals; explicit types and already aligned points retain their behavior. +fn midpoint_orientation(ty: &str, a: Vec2, b: Vec2, text_at: Option) -> &str { + let d = b - a; + if ty == "auto" + && d.x.abs() > 1e-9 * d.len() + && d.y.abs() > 1e-9 * d.len() + && let Some(t) = text_at + { + let between = |v: f64, p: f64, q: f64| p.min(q) <= v && v <= p.max(q); + if between(t.x, a.x, b.x) && !between(t.y, a.y, b.y) { + return "horizontal"; + } + if between(t.y, a.y, b.y) && !between(t.x, a.x, b.x) { + return "vertical"; + } + } + auto_orientation(ty, a, b, text_at) +} + /// Keep a dimension's text where it was placed (`at`, sketch coordinates), relative to the /// dimension so it follows the geometry. fn place_text(sk: &mut Sketch, id: &str, at: Option) { diff --git a/crates/engine/src/cmd/sketch_3d_frame_tests.rs b/crates/engine/src/cmd/sketch_3d_frame_tests.rs index 6dfbe7d657..311e8ee7a2 100644 --- a/crates/engine/src/cmd/sketch_3d_frame_tests.rs +++ b/crates/engine/src/cmd/sketch_3d_frame_tests.rs @@ -123,7 +123,7 @@ fn wire_frame_hostile_inputs_rollback_full_session() { fn wire_frame_include_other_component_refresh_break_lost_and_reopen() { let mut s = Session::default(); run(&mut s, "solid.box", json!({"length":10,"width":10,"height":10})); - let component = run(&mut s, "component.create", json!({"name":"Receiver"})); + let component = run(&mut s, "component.create", json!({"name":"Receiver","grounded":false})); let occurrence = component["occurrence"].as_u64().unwrap(); run(&mut s, "occurrence.move", json!({"occurrence":occurrence,"translate":[20,0,0]})); run(&mut s, "sketch.create", json!({"plane":"XY","name":"Include target"})); @@ -235,7 +235,7 @@ fn wire_frame_cross_owner_sweep_follows_world_path_edit_undo_and_reopen() { run(&mut s, "sketch.options", json!({"sketch_3d":true})); let wire = run(&mut s, "sketch.line3d", json!({"points":[[17,32,45],[17,32,55]]}))["curves"][0].as_str().unwrap().to_string(); run(&mut s, "sketch.finish", json!({})); - let owner = run(&mut s, "component.create", json!({"name":"Sweep owner"}))["occurrence"].as_u64().unwrap(); + let owner = run(&mut s, "component.create", json!({"name":"Sweep owner","grounded":false}))["occurrence"].as_u64().unwrap(); run(&mut s, "occurrence.move", json!({"occurrence":owner,"axis":[0,0,1],"angle":"90 deg","translate":[20,30,40]})); let profile = run(&mut s, "sketch.create", json!({"plane":"XY","offset":5,"name":"Profile"}))["sketch"].as_u64().unwrap(); run(&mut s, "sketch.circle.center", json!({"center":[2,3],"radius":2})); @@ -264,7 +264,7 @@ fn wire_frame_cross_owner_sweep_follows_world_path_edit_undo_and_reopen() { #[test] fn wire_frame_three_owner_sweep_profile_move_invalidates_warm_cache() { let mut s = Session::default(); - let profile_owner = run(&mut s, "component.create", json!({"name":"Profile owner","parent":0})); + let profile_owner = run(&mut s, "component.create", json!({"name":"Profile owner","parent":0,"grounded":false})); let profile_occurrence = profile_owner["occurrence"].as_u64().unwrap(); run(&mut s, "occurrence.move", json!({"occurrence":profile_occurrence,"translate":[4,7,5]})); let profile = run(&mut s, "sketch.create", json!({"plane":"XY","name":"Foreign profile"}))["sketch"].as_u64().unwrap(); diff --git a/crates/engine/src/cmd/sketch_constraints_tests.rs b/crates/engine/src/cmd/sketch_constraints_tests.rs index c7abab1507..bf6e30db84 100644 --- a/crates/engine/src/cmd/sketch_constraints_tests.rs +++ b/crates/engine/src/cmd/sketch_constraints_tests.rs @@ -966,3 +966,95 @@ fn coincident_circles_lie_on_each_other() { assert!(sk.point(c).unwrap().dist(Vec2::new(0.0, 0.0)) < 1e-6); assert!((sk.point(start).unwrap().dist(sk.point(c).unwrap()) - 10.0).abs() < 1e-6); } + +#[test] +fn midpoint_edge_dimensions_track_the_midpoint_and_reuse_it() { + for reverse in [false, true] { + let mut s = new_sketch(); + run(&mut s, "sketch.line", json!({"points":[[0,10],[20,10]]})); + run(&mut s, "sketch.line", json!({"points":[[-10,-10],[-10,40]]})); + run(&mut s, "sketch.constraint.fix", json!({"entity":"l2"})); + let length = run(&mut s, "sketch.dimension", json!({"entities":["l1"],"value":20}))["param"].as_str().unwrap().to_string(); + let baseline = s.doc.to_json(); + let entities = if reverse { json!(["l2", "mid:l1"]) } else { json!(["mid:l1", "l2"]) }; + let d = run(&mut s, "sketch.dimension", json!({"entities":entities,"value":25})); + assert_eq!(d["unit"], "mm"); + assert!((d["measured"].as_f64().unwrap() - 25.).abs() < 1e-6); + let param = d["param"].as_str().unwrap().to_string(); + let accepted = s.doc.to_json(); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.to_json(), baseline, "the helper and dimension undo together"); + run(&mut s, "edit.redo", json!({})); + assert_eq!(s.doc.to_json(), accepted); + run(&mut s, "parameters.change", json!({"name":length,"expression":"40 mm"})); + run(&mut s, "parameters.change", json!({"name":param,"expression":"30 mm"})); + run(&mut s, "sketch.dimension", json!({"entities":entities,"driven":true})); + let sk = sketch(&s); + let mids: Vec<_> = sk + .constraints + .iter() + .filter_map(|c| match c.kind { + solidcraft_sketch::ConstraintKind::Midpoint { p, l } if l == sk.curve_index("l1").unwrap() => Some(p), + _ => None, + }) + .collect(); + assert_eq!(mids.len(), 1, "another dimension reuses the constrained midpoint"); + let mid = sk.point(mids[0]).unwrap(); + let a = sk.point(sk.resolve_point("l1.start").unwrap()).unwrap(); + let b = sk.point(sk.resolve_point("l1.end").unwrap()).unwrap(); + assert!(mid.dist((a + b) * 0.5) < 1e-6); + assert!((mid.x - 20.).abs() < 1e-6, "distance from the fixed x=-10 edge is 30 mm"); + assert!((a.dist(b) - 40.).abs() < 1e-6); + let mut reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + reopened.active_sketch = s.active_sketch; + assert_eq!(sketch(&reopened), sk); + } +} + +#[test] +fn invalid_midpoint_dimension_references_leave_the_design_unchanged() { + let mut s = new_sketch(); + run(&mut s, "sketch.line", json!({"points":[[0,10],[20,10]]})); + run(&mut s, "sketch.circle.center", json!({"center":[30,30],"radius":5})); + let baseline = s.doc.to_json(); + for entities in [json!(["mid:missing", "l1"]), json!(["mid:c1", "l1"]), json!(["mid:l1", "missing"])] { + assert!(s.execute("sketch.dimension", &json!({"entities":entities,"value":10})).is_err()); + assert_eq!(s.doc.to_json(), baseline); + } +} + +#[test] +fn midpoint_pair_orientation_uses_the_text_box_and_respects_explicit_types() { + use solidcraft_sketch::ConstraintKind as K; + let mut s = new_sketch(); + run(&mut s, "sketch.line", json!({"points":[[0,10],[20,10]],"infer":false})); + run(&mut s, "sketch.line", json!({"points":[[40,20],[40,40]],"infer":false})); + for (ty, at, axis) in [ + ("auto", [25., 33.], 'x'), + ("auto", [43., 20.], 'y'), + ("auto", [25., 20.], 'd'), + ("distance", [25., 33.], 'd'), + ("horizontal", [43., 20.], 'x'), + ("vertical", [25., 33.], 'y'), + ] { + let mut trial = s.scratch(); + run(&mut trial, "sketch.dimension", json!({"entities":["mid:l1","mid:l2"],"type":ty,"text_at":at,"driven":true})); + let sk = sketch(&trial); + let c = sk.constraints.iter().rev().find(|c| c.kind.is_dimension()).unwrap(); + assert!( + match axis { + 'x' => matches!(c.kind, K::DistanceX { .. }), + 'y' => matches!(c.kind, K::DistanceY { .. }), + _ => matches!(c.kind, K::Distance { .. }), + }, + "{ty} at {at:?}: {:?}", + c.kind + ); + let expected = match axis { + 'x' => 30., + 'y' => 20., + _ => 1300_f64.sqrt(), + }; + assert!((solidcraft_sketch::measure_dimension(&sk, &c.kind).unwrap() - expected).abs() < 1e-6); + } +} diff --git a/crates/engine/src/cmd/sketch_create.rs b/crates/engine/src/cmd/sketch_create.rs index b97d7dd4a4..6cbc9fd3c5 100644 --- a/crates/engine/src/cmd/sketch_create.rs +++ b/crates/engine/src/cmd/sketch_create.rs @@ -59,7 +59,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SKETCH", "CREATE") .icon("arc_slot_center") .enabled(in_sketch) - .params(concat!("center, start: centre arc start, end: direction of the centre arc end (counter-clockwise), width", created_doc!())), + .params(concat!("center, start: centre arc start, end: direction of the centre arc end (counter-clockwise) or sweep: degrees (0 < sweep < 360), width; returns centre_arc", created_doc!())), CommandSpec::new("sketch.ellipse", "Ellipse", ellipse).at("SKETCH", "CREATE").icon("ellipse").enabled(in_sketch).params(concat!( "center, major: end of the major axis [x,y], minor: a point the ellipse passes through on the minor side [x,y] | minor_radius", created_doc!() @@ -937,6 +937,7 @@ fn arc_slot(s: &mut Session, p: &Value, cmd: &str, o: Vec2, r: f64, t0: f64, swe } let t1 = t0 + sweep; let at = |t: f64, rr: f64| o + Vec2::from_angle(t) * rr; + let mut centre_arc = None; let (out, info) = edit(s, p, cmd, false, |sk, _| { let oc = sk.add_point(o, None)?; let ps = sk.add_point(at(t0, r), None)?; @@ -950,6 +951,7 @@ fn arc_slot(s: &mut Session, p: &Value, cmd: &str, o: Vec2, r: f64, t0: f64, swe let centre = sk.add_arc(o, at(t0, r), at(t1, r), [Some(oc), Some(ps), Some(pe)], None)?; if let Some(c) = sk.curves.get_mut(centre) { c.construction = true; + centre_arc = Some(c.id.clone()); } let mut cons = Vec::new(); for (a, b) in [(outer, cap_e), (inner, cap_e), (outer, cap_s), (inner, cap_s)] { @@ -958,7 +960,9 @@ fn arc_slot(s: &mut Session, p: &Value, cmd: &str, o: Vec2, r: f64, t0: f64, swe cons.push(add_c(sk, ConstraintKind::Equal { a: cap_s, b: cap_e })?); Ok((ids_of(sk, &[outer, cap_e, inner, cap_s]), cons)) })?; - Ok(result(out, info)) + let mut out = result(out, info); + out["centre_arc"] = json!(centre_arc); + Ok(out) } fn arc_slot_three(s: &mut Session, p: &Value) -> Result { @@ -975,14 +979,22 @@ fn arc_slot_three(s: &mut Session, p: &Value) -> Result { fn arc_slot_center(s: &mut Session, p: &Value) -> Result { let cmd = "sketch.slot.arc_center"; - let (o, a, b) = (req_vec2(cmd, p, "center")?, req_vec2(cmd, p, "start")?, req_vec2(cmd, p, "end")?); + let (o, a) = (req_vec2(cmd, p, "center")?, req_vec2(cmd, p, "start")?); let w = width(p, cmd)?; let r = o.dist(a); - if r < 1e-9 || o.dist(b) < 1e-9 { + if r < 1e-9 { return Err(bad(cmd, "the points must differ from the centre")); } let t0 = (a - o).angle(); - let sweep = ((b - o).angle() - t0).rem_euclid(std::f64::consts::TAU); + let sweep = if p.get("sweep").is_some() { + num(p, "sweep").ok_or_else(|| bad(cmd, "`sweep` must be finite degrees"))?.to_radians() + } else { + let b = req_vec2(cmd, p, "end")?; + if o.dist(b) < 1e-9 { + return Err(bad(cmd, "the points must differ from the centre")); + } + ((b - o).angle() - t0).rem_euclid(std::f64::consts::TAU) + }; arc_slot(s, p, cmd, o, r, t0, sweep, w) } diff --git a/crates/engine/src/cmd/sketch_dimensioned.rs b/crates/engine/src/cmd/sketch_dimensioned.rs index 62d0897dc0..75e924577b 100644 --- a/crates/engine/src/cmd/sketch_dimensioned.rs +++ b/crates/engine/src/cmd/sketch_dimensioned.rs @@ -17,7 +17,9 @@ pub(super) fn draw(s: &mut Session, p: &Value) -> Result { } "sketch.circle.center" | "sketch.circle.two_point" | "sketch.circle.three_point" => &["radius", "diameter"], "sketch.arc.center_point" => &["radius", "sweep"], - "sketch.slot.center_to_center" | "sketch.slot.overall" => &["length", "width"], + "sketch.slot.center_to_center" | "sketch.slot.overall" | "sketch.slot.center_point" => &["length", "width"], + "sketch.slot.arc_center" => &["radius", "sweep", "width"], + "sketch.slot.arc_three_point" => &["width"], "sketch.polygon.inscribed" | "sketch.polygon.circumscribed" | "sketch.polygon.edge" => &["radius"], _ => return Err(bad(cmd, "unsupported sketch creator")), }; @@ -49,6 +51,9 @@ pub(super) fn draw(s: &mut Session, p: &Value) -> Result { if role == "sweep" && !(evaluated.abs() > 1e-9 && evaluated.abs() < std::f64::consts::TAU - 1e-9) { return Err(bad(cmd, "sweep must be nonzero and less than a full turn")); } + if role == "sweep" && create == "sketch.slot.arc_center" && evaluated <= 0.0 { + return Err(bad(cmd, "arc slot sweep must be greater than zero and less than a full turn")); + } if role == "sweep" { params["sweep"] = json!(evaluated.to_degrees()); if let Some(o) = params.as_object_mut() { @@ -94,9 +99,13 @@ pub(super) fn draw(s: &mut Session, p: &Value) -> Result { (c, "width" | "height") if c.starts_with("sketch.rectangle.") => (vec![curve(usize::from(role == "height"))?], "length"), (c, "radius" | "diameter") if c.starts_with("sketch.circle.") => (vec![curve(0)?], role.as_str()), ("sketch.arc.center_point", "radius" | "sweep") => (vec![curve(0)?], role.as_str()), - ("sketch.slot.center_to_center", "length") => (vec![curve(0)?], "length"), + ("sketch.slot.center_to_center" | "sketch.slot.center_point", "length") => (vec![curve(0)?], "length"), ("sketch.slot.overall", "length") => (vec![curve(1)?, curve(3)?], "overall_length"), - ("sketch.slot.center_to_center" | "sketch.slot.overall", "width") => (vec![curve(1)?], "diameter"), + (c, "width") if c.starts_with("sketch.slot.") => (vec![curve(1)?], "diameter"), + ("sketch.slot.arc_center", "radius" | "sweep") => { + let centre = out.get("centre_arc").filter(|v| v.is_string()).cloned().ok_or_else(|| bad(cmd, "slot centre arc missing"))?; + (vec![centre], role.as_str()) + } (c, "radius") if c.starts_with("sketch.polygon.") => { let support = out.get("support_circle").filter(|v| v.is_string()).cloned().ok_or_else(|| bad(cmd, "polygon support circle missing"))?; diff --git a/crates/engine/src/cmd/sketch_dimensioned_tests.rs b/crates/engine/src/cmd/sketch_dimensioned_tests.rs index b949794c46..391f8e0e5e 100644 --- a/crates/engine/src/cmd/sketch_dimensioned_tests.rs +++ b/crates/engine/src/cmd/sketch_dimensioned_tests.rs @@ -74,9 +74,14 @@ fn signed_arc_sweep_retains_radius_and_logical_endpoint_identity() { #[test] fn slots_and_polygon_keep_permanent_typed_dimensions() { - for command in ["sketch.slot.center_to_center", "sketch.slot.overall"] { + for command in ["sketch.slot.center_to_center", "sketch.slot.overall", "sketch.slot.center_point"] { let mut s = session(); - let out = draw(&mut s, command, json!({"p0":[2,3],"p1":[22,3],"width":4,"connect":false}), json!({"length":20,"width":4})); + let out = draw( + &mut s, + command, + json!({"p0":[2,3],"p1":[22,3],"center":[12,3],"end":[22,3],"width":4,"connect":false}), + json!({"length":20,"width":4}), + ); let length = out["dimensions"]["length"]["param"].as_str().unwrap(); let result = run(&mut s, "parameters.change", json!({"name":length,"expression":30})); assert_eq!(result["errors"], json!([])); @@ -99,6 +104,28 @@ fn slots_and_polygon_keep_permanent_typed_dimensions() { } } +#[test] +fn arc_slot_dimensions_survive_parameter_edits_and_reopening() { + let mut s = session(); + let out = draw( + &mut s, + "sketch.slot.arc_center", + json!({"center":[0,0],"start":[20,0],"end":[0,20],"width":6}), + json!({"radius":20,"sweep":90,"width":6}), + ); + for (role, value) in [("radius", 25), ("sweep", 120), ("width", 8)] { + let name = out["dimensions"][role]["param"].as_str().unwrap(); + let result = run(&mut s, "parameters.change", json!({"name":name,"expression":value})); + assert_eq!(result["errors"], json!([]), "{role}: {result}"); + } + let doc = serde_json::from_value(serde_json::to_value(s.design()).unwrap()).unwrap(); + let mut reopened = Session::new(doc); + reopened.active_sketch = s.active_sketch; + close(measure(&reopened, |k| matches!(k, ConstraintKind::Radius { .. })), 25.0); + close(measure(&reopened, |k| matches!(k, ConstraintKind::ArcSweep { .. })), 120f64.to_radians()); + close(measure(&reopened, |k| matches!(k, ConstraintKind::Diameter { .. })), 8.0); +} + #[test] fn invalid_dimensioned_shapes_are_atomic_including_late_constraint_failure() { let mut s = session(); diff --git a/crates/engine/src/cmd/sketch_import.rs b/crates/engine/src/cmd/sketch_import.rs index 83632df19f..22f8362a5c 100644 --- a/crates/engine/src/cmd/sketch_import.rs +++ b/crates/engine/src/cmd/sketch_import.rs @@ -239,4 +239,18 @@ mod tests { s.execute("sketch.finish", &json!({})).unwrap(); } } + + /// A degree-9 SPLINE (solidcraft#5269) with fewer control points than its degree needs still + /// comes in as one spline over all its points, which the sketch draws at the degree they allow. + #[test] + fn a_degree_nine_spline_with_few_control_points_comes_in_whole() { + let mut s = Session::default(); + for n in 2..=10 { + let points: String = (0..n).map(|i| format!("10\n{i}\n20\n{}\n", i % 2)).collect(); + let dxf = format!("0\nSECTION\n2\nENTITIES\n0\nSPLINE\n71\n9\n{points}0\nENDSEC\n0\nEOF\n"); + let r = s.execute("sketch.insert_dxf", &json!({"text": dxf})).unwrap(); + assert_eq!(r["curves"], 1, "{n} points: {r}"); + s.execute("sketch.finish", &json!({})).unwrap(); + } + } } diff --git a/crates/engine/src/cmd/sketch_modify.rs b/crates/engine/src/cmd/sketch_modify.rs index a70f51e8cf..cead75217c 100644 --- a/crates/engine/src/cmd/sketch_modify.rs +++ b/crates/engine/src/cmd/sketch_modify.rs @@ -866,11 +866,11 @@ struct CornerKept { } /// Keep corner point `c` of curves `ca`, `cb` (whose corner ends were just moved to `na`, `nb`) -/// when anything measures to it: a constraint or dimension on the point, the length of either -/// line, or it is the origin or still ends another curve. It stays as a virtual sharp corner, -/// held on both curves' extensions (their intersection), and the line lengths become distances -/// from the far end to it, so the part keeps its size. Otherwise None: the -/// caller removes the point. +/// when anything measures to it or places it: a constraint or dimension on the point, the length +/// of either line, a fixed or expression-driven position, or it is the origin or still ends another +/// curve. It stays as a virtual sharp corner, held on both curves' extensions (their +/// intersection), and the line lengths become distances from the far end to it, so the part +/// keeps its size. Otherwise None: the caller removes the point. fn keep_corner(sk: &mut Sketch, c: usize, [(ca, na), (cb, nb)]: [(usize, usize); 2]) -> Result> { let used = sk.curves.iter().any(|x| uses(x, c)); let mut moved: Vec = sk.constraints.iter().filter(|k| k.kind.refers_to_point(c)).map(|k| k.id.clone()).collect(); @@ -888,7 +888,8 @@ fn keep_corner(sk: &mut Sketch, c: usize, [(ca, na), (cb, nb)]: [(usize, usize); k.kind = if *a == new_end { ConstraintKind::Distance { p: c, q: *b, value } } else { ConstraintKind::Distance { p: *a, q: c, value } }; moved.push(k.id.clone()); } - if moved.is_empty() && !used && c != 0 { + let positioned = sk.points.get(c).is_some_and(|pt| pt.fixed || pt.exprs.is_some()); + if moved.is_empty() && !used && c != 0 && !positioned { return Ok(None); } for cu in [ca, cb] { diff --git a/crates/engine/src/cmd/sketch_modify_tests.rs b/crates/engine/src/cmd/sketch_modify_tests.rs index d5b5f57c4e..f6d7326ce4 100644 --- a/crates/engine/src/cmd/sketch_modify_tests.rs +++ b/crates/engine/src/cmd/sketch_modify_tests.rs @@ -238,6 +238,41 @@ fn exact_extend_refuses_ambiguous_stop_without_history_change() { assert_eq!(s.undo.len(), history); } +#[test] +fn fillet_keeps_expression_corner_when_parameter_changes() { + let mut s = new_sketch(); + run(&mut s, "parameters.add", json!({"name": "W", "expression": "40 mm"})); + run(&mut s, "parameters.add", json!({"name": "H", "expression": "20 mm"})); + let outline = run(&mut s, "sketch.line", json!({"closed": true, "points": [["0 mm", "0 mm"], ["W", "0 mm"], ["W", "H"], ["0 mm", "H"]]})); + assert_eq!(outline["sketch"]["dof"], 0); + let fillet = run(&mut s, "sketch.fillet", json!({"point": "l2.end", "radius": 5})); + assert_eq!(fillet["sketch"]["dof"], 0, "{fillet}"); + let corners = ids(&fillet["virtual_corners"]); + assert_eq!(corners.len(), 1, "{fillet}"); + + for width in [60.0, 30.0] { + run(&mut s, "parameters.change", json!({"name": "W", "expression": format!("{width} mm")})); + let info = run(&mut s, "sketch.inspect", json!({})); + assert_eq!(info["dof"], 0, "{info}"); + let sk = sketch(&s); + let corner = &sk.points[sk.resolve_point(&corners[0]).unwrap()]; + assert!(corner.fixed && corner.exprs.is_some()); + for (point, expected) in [ + (corners[0].as_str(), Vec2::new(width, 20.0)), + ("l2.start", Vec2::new(width, 0.0)), + ("l2.end", Vec2::new(width, 15.0)), + ("l3.start", Vec2::new(width - 5.0, 20.0)), + ("l3.end", Vec2::new(0.0, 20.0)), + ] { + let actual = sk.point(sk.resolve_point(point).unwrap()).unwrap(); + assert!(actual.dist(expected) < 1e-6, "{point}: {actual:?}, expected {expected:?}"); + } + let area = profiles(&s); + assert_eq!(area.len(), 1); + assert!(close(area[0], width * 20.0 - 25.0 * (1.0 - std::f64::consts::PI / 4.0)), "{area:?}"); + } +} + #[test] fn fillet_and_chamfers() { let mut s = new_sketch(); @@ -1527,6 +1562,50 @@ fn offset_driver_edits_reopens_and_undoes_without_moving_sources() { check(&reopened, 3.0); } +#[test] +fn an_offset_leaves_its_source_lines_free_to_turn() { + // #4464: the Parallel between each source line and its offset line pinned the source to the + // offset's held direction: 8 degrees of freedom became 6, Horizontal and angle edits conflicted. + let mut s = new_sketch(); + let points = json!([[-2, 38.9], [36.7, 17.1], [-5, 0], [57.7, -17.7], [-7.6, -25.7], [-22, 21.3]]); + run(&mut s, "sketch.line", json!({"points": points, "closed": true, "ids": ["l1", "l2", "l3", "l4", "l5", "l6"]})); + let mut angle = String::new(); + for pair in [["l6", "l1"], ["l1", "l2"], ["l2", "l3"], ["l4", "l5"]] { + let d = run(&mut s, "sketch.dimension", json!({"entities": pair, "type": "angle"})); + angle = d["param"].as_str().unwrap().to_string(); + } + let o = run(&mut s, "sketch.offset", json!({"curves": ["l1"], "distance": 2, "side": [5, 5]})); + assert_eq!(o["sketch"]["dof"], 8, "the offset follows its source and takes no freedom away: {o}"); + let outputs = ids(&o["curves"]); + let line = |sk: &solidcraft_sketch::Sketch, id: &str| match sk.curves[sk.curve_index(id).unwrap()].kind { + CurveKind::Line { a, b } => (sk.points[a].pos, sk.points[b].pos), + ref other => panic!("{id} is {other:?}"), + }; + let follows = |s: &Session| { + let sk = sketch(s); + for (source, output) in ["l1", "l2", "l3", "l4", "l5", "l6"].iter().zip(&outputs) { + let ((a, b), (c, d)) = (line(&sk, source), line(&sk, output)); + let u = (b - a).normalized().unwrap(); + assert!(u.cross((d - c).normalized().unwrap()).abs() < 1e-7, "{output} is parallel to {source}"); + assert!(close(u.cross(c - a).abs(), 2.0) && close(u.cross(d - a).abs(), 2.0), "{output} is 2 mm from {source}"); + } + }; + follows(&s); + let h = run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l4", "mode": "horizontal"})); + assert_eq!(h["sketch"]["dof"], 7, "{h}"); + follows(&s); + let (a, b) = line(&sketch(&s), "l4"); + assert!((a.y - b.y).abs() < 1e-7, "l4 is horizontal"); + run(&mut s, "parameters.change", json!({"name": angle, "expression": "90 deg"})); + follows(&s); + // A rounded boundary takes the convex path, whose lines pair with the association's roles. + let polygon = run(&mut s, "sketch.line", json!({"points": [[100, 0], [130, 5], [125, 30], [105, 25]], "closed": true})); + let corners = run(&mut s, "sketch.fillet", json!({"points": polygon["created"]["points"], "radius": "1 mm"})); + let o = run(&mut s, "sketch.offset", json!({"curves": [ids(&polygon["curves"])[0]], "distance": 1, "side": [115, 15]})); + assert!(o["association"].is_string(), "{o}"); + assert_eq!(o["sketch"]["dof"], corners["sketch"]["dof"], "{o}"); +} + #[test] fn offset_driver_covers_disconnected_selections_and_refuses_bad_values_atomically() { let mut s = new_sketch(); diff --git a/crates/engine/src/cmd/sketch_text_move_tests.rs b/crates/engine/src/cmd/sketch_text_move_tests.rs index 7f1a34b5ff..2fa47a9dfc 100644 --- a/crates/engine/src/cmd/sketch_text_move_tests.rs +++ b/crates/engine/src/cmd/sketch_text_move_tests.rs @@ -116,7 +116,7 @@ fn live_text_edit_rejects_bad_values_and_leaves_the_design_as_it_was() { #[test] fn text_move_in_a_rotated_component_preserves_font_and_local_coordinates() { let mut s = Session::default(); - let component = run(&mut s, "component.create", json!({"name":"Label"})); + let component = run(&mut s, "component.create", json!({"name":"Label","grounded":false})); run(&mut s, "occurrence.move", json!({"occurrence":component["occurrence"],"translate":[30,20,10],"axis":[0,0,1],"angle":90})); let created = run(&mut s, "sketch.create", json!({"plane":"XY"})); let sid = created["sketch"].as_u64().unwrap(); diff --git a/crates/engine/src/cmd/sketch_ux_tests.rs b/crates/engine/src/cmd/sketch_ux_tests.rs index da750c1935..27e59e9dd5 100644 --- a/crates/engine/src/cmd/sketch_ux_tests.rs +++ b/crates/engine/src/cmd/sketch_ux_tests.rs @@ -1600,3 +1600,69 @@ fn budget_limited_cursor_progress_requests_a_frame_with_the_same_target() { } assert!(exercised, "exercise continuation after budget-limited progress"); } + +// --------------------------------------------------------------------------------------------- +// Cancel Sketch (#3838) + +#[test] +fn cancel_removes_a_new_sketch_with_its_dimensions_and_undo_brings_it_back() { + let mut s = Session::default(); + let before = s.doc.to_json(); + run(&mut s, "sketch.create", json!({"plane": "XY"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]})); + run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 25})); + let drawn = s.doc.to_json(); + let r = run(&mut s, "sketch.cancel", json!({})); + assert_eq!(r["removed"], true); + assert_eq!(s.active_sketch, None); + assert_eq!(s.doc.to_json(), before, "the sketch and its dimension parameter are gone"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.to_json(), drawn); + assert!(s.active_sketch.is_some(), "undo goes back into the sketch"); + run(&mut s, "edit.redo", json!({})); + assert_eq!(s.doc.to_json(), before); +} + +#[test] +fn cancel_puts_an_edited_sketch_back_as_it_was_opened() { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [20, 10]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance": 5})); + let opened = s.doc.to_json(); + let volume = |s: &mut Session| run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap(); + assert!((volume(&mut s) - 1000.0).abs() < 1e-6); + run(&mut s, "sketch.edit", json!({"sketch": "Base"})); + run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 40})); + run(&mut s, "sketch.line", json!({"points": [[0, 20], [10, 30]]})); + let r = run(&mut s, "sketch.cancel", json!({})); + assert_eq!(r["removed"], false); + assert_eq!(s.doc.to_json(), opened); + assert!((volume(&mut s) - 1000.0).abs() < 1e-6, "the solid is built from the sketch as it was"); + + // Finishing keeps the changes: there is nothing left to cancel. + run(&mut s, "sketch.edit", json!({"sketch": "Base"})); + run(&mut s, "sketch.dimension", json!({"entities": ["l1"], "value": 40})); + run(&mut s, "sketch.finish", json!({})); + assert!((volume(&mut s) - 2000.0).abs() < 1e-6); + assert!(s.execute("sketch.cancel", &json!({})).is_err(), "not in a sketch"); +} + +#[test] +fn cancel_refuses_once_undo_has_gone_back_past_where_the_sketch_was_opened() { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY", "name": "S"})); + run(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "sketch.edit", json!({"sketch": "S"})); + run(&mut s, "sketch.line", json!({"points": [[0, 5], [10, 5]]})); + // Back past the edit: the first line's step is undone too, still inside the sketch. + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.undo", json!({})); + assert!(s.active_sketch.is_some()); + let before = s.doc.to_json(); + let e = s.execute("sketch.cancel", &json!({})).unwrap_err().to_string(); + assert!(e.contains("Undo"), "{e}"); + assert_eq!(s.doc.to_json(), before, "nothing changed"); +} diff --git a/crates/engine/src/cmd/urdf_tests.rs b/crates/engine/src/cmd/urdf_tests.rs index 1c497bc060..50ed4c2906 100644 --- a/crates/engine/src/cmd/urdf_tests.rs +++ b/crates/engine/src/cmd/urdf_tests.rs @@ -383,7 +383,7 @@ fn urdf_names_are_unique_across_links_joints_and_files() { let mut s = Session::default(); for name in ["Arm", "base", "Arm_link1"] { run(&mut s, "component.activate", json!({"component": "root"})); - run(&mut s, "component.create", json!({"name": name})); + run(&mut s, "component.create", json!({"name": name, "grounded": false})); run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [0, 0, 0]})); } run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [50, 50, 0]})); diff --git a/crates/engine/src/cmd/view_navigation.rs b/crates/engine/src/cmd/view_navigation.rs index 2101e4d604..09b5a1cda3 100644 --- a/crates/engine/src/cmd/view_navigation.rs +++ b/crates/engine/src/cmd/view_navigation.rs @@ -308,7 +308,7 @@ mod tests { #[test] fn normal_to_selection_uses_the_world_normal_of_a_moved_component() { let mut s = Session::default(); - s.execute("component.create", &json!({"name":"Part"})).unwrap(); + s.execute("component.create", &json!({"name":"Part","grounded":false})).unwrap(); s.execute("solid.box", &json!({"length":40,"width":30,"height":20})).unwrap(); s.execute("occurrence.move", &json!({"occurrence":"Part:1","translate":[50,20,10],"axis":[1,2,3],"angle":"37 deg"})).unwrap(); let state = s.world_state(); diff --git a/crates/engine/src/construct_tests.rs b/crates/engine/src/construct_tests.rs index 157324a053..204b471254 100644 --- a/crates/engine/src/construct_tests.rs +++ b/crates/engine/src/construct_tests.rs @@ -43,6 +43,61 @@ fn plane(s: &Session, name: &str) -> Plane { construction_planes(s).into_iter().find(|p| p.1 == name).map(|p| p.2).unwrap_or_else(|| panic!("no plane {name}")) } +#[test] +fn renamed_construction_references_survive_edits_and_reopening() { + let mut s = Session::default(); + run(&mut s, "construct.axis.two_planes", json!({"a": "XZ", "b": "YZ", "name": "Spine"})); + run(&mut s, "construct.plane.perpendicular", json!({"plane": "XZ", "line": {"axis": "Spine"}, "name": "Fence"})); + run(&mut s, "construct.point.three_planes", json!({"a": "XY", "b": "XZ", "c": "YZ", "name": "Pin"})); + run(&mut s, "construct.axis.two_points", json!({"a": {"construct_point": "Pin"}, "b": {"point": [30, 30, 30]}, "name": "Brace"})); + run(&mut s, "construct.point.vertex", json!({"point": {"construct_point": "Pin"}, "name": "Copy"})); + let before = (*s.doc).clone(); + run(&mut s, "timeline.rename", json!({"feature": "Spine", "name": "Mast"})); + run(&mut s, "timeline.rename", json!({"feature": "Pin", "name": "Peg"})); + let fence = run(&mut s, "timeline.inspect", json!({"feature": "Fence"})); + assert_eq!(fence["definition"]["plane"]["line"]["name"], "Mast"); + let brace = run(&mut s, "timeline.inspect", json!({"feature": "Brace"})); + assert_eq!(brace["definition"]["def"]["a"]["name"], "Peg"); + let renamed = (*s.doc).clone(); + for _ in 0..2 { + run(&mut s, "edit.undo", json!({})); + } + assert_eq!(*s.doc, before); + for _ in 0..2 { + run(&mut s, "edit.redo", json!({})); + } + assert_eq!(*s.doc, renamed); + run(&mut s, "timeline.edit", json!({"feature": "Mast", "set": {"def.b": {"type": "plane", "plane": {"type": "origin", "name": "XY"}}}})); + let saved = serde_json::to_vec(&*s.doc).unwrap(); + let mut reopened = Session::new(serde_json::from_slice(&saved).unwrap()); + reopened.refresh(); + for session in [&s, &reopened] { + assert!(session.model.results.iter().all(|r| r.error.is_none()), "{:?}", session.model.results); + assert!(parallel(plane(session, "Fence").normal(), Vec3::Z)); + assert!(parallel(axis(session, "Brace").1, Vec3::new(1.0, 1.0, 1.0))); + assert!(near(point(session, "Copy"), Vec3::ZERO)); + } +} + +#[test] +fn renamed_hidden_axes_and_points_stay_hidden_through_undo_redo() { + let mut s = Session::default(); + run(&mut s, "construct.axis.two_planes", json!({"a": "XZ", "b": "YZ", "name": "Spine"})); + run(&mut s, "construct.point.three_planes", json!({"a": "XY", "b": "XZ", "c": "YZ", "name": "Pin"})); + run(&mut s, "browser.visibility", json!({"items": ["Spine", "Pin", "XY"], "visible": false})); + for (old, new) in [("Spine", "Mast"), ("Pin", "Peg")] { + run(&mut s, "timeline.rename", json!({"feature": old, "name": new})); + assert!(!s.construction_visible(new)); + assert!(!s.visibility.hidden_origin.iter().any(|n| n == old)); + run(&mut s, "edit.undo", json!({})); + assert!(!s.construction_visible(old)); + assert!(!s.visibility.hidden_origin.iter().any(|n| n == new)); + run(&mut s, "edit.redo", json!({})); + assert!(!s.construction_visible(new)); + } + assert!(!s.construction_visible("XY")); +} + /// The line through `o` along `d` contains `p`. fn on_line(o: Vec3, d: Vec3, p: Vec3) -> bool { let d = d.normalized().unwrap_or(Vec3::Z); @@ -245,7 +300,7 @@ fn construction_on_a_3d_wire_ignores_its_sketch_plane_and_follows_edits() { #[test] fn plane_on_a_3d_wire_moves_with_its_component() { let mut s = Session::default(); - run(&mut s, "component.create", json!({"name":"Part"})); + run(&mut s, "component.create", json!({"name":"Part","grounded":false})); let sketch = run(&mut s, "sketch.create", json!({"plane":"XY"}))["sketch"].as_u64().unwrap(); run(&mut s, "sketch.options", json!({"sketch_3d":true})); let wire = run(&mut s, "sketch.line3d", json!({"points":[[0,0,10],[0,0,40]]}))["curves"][0].as_str().unwrap().to_string(); @@ -311,7 +366,7 @@ fn a_plane_through_three_vertices_follows_them() { #[test] fn construction_lives_in_the_component_frame() { let mut s = Session::default(); - run(&mut s, "component.create", json!({"name": "Part"})); + run(&mut s, "component.create", json!({"name": "Part", "grounded": false})); run(&mut s, "solid.cylinder", json!({"radius": 5, "height": 10, "body_name": "Pin"})); run(&mut s, "occurrence.move", json!({"occurrence": "Part:1", "translate": [50, 0, 0]})); run(&mut s, "construct.axis.through_cylinder", json!({"face": {"face": [55, 0, 5]}, "name": "PinAxis"})); diff --git a/crates/engine/src/control_auth.rs b/crates/engine/src/control_auth.rs index 0f0af31421..b6162e9059 100644 --- a/crates/engine/src/control_auth.rs +++ b/crates/engine/src/control_auth.rs @@ -10,14 +10,92 @@ use std::path::{Path, PathBuf}; /// Shortest token accepted from `SOLIDCRAFT_CONTROL_TOKEN` (hex characters). pub const MIN_TOKEN_LEN: usize = 32; +/// The port the control channel tries first when `--control` and `SOLIDCRAFT_CONTROL_PORT` give none. +/// Below the ephemeral ranges of Linux (32768+) and Windows/macOS (49152+), so the system never +/// hands it to an outgoing connection. +pub const DEFAULT_PORT: u16 = 27878; + +/// Ports tried from [`DEFAULT_PORT`] upward when it is taken (a second window, another program). +pub const PORT_TRIES: u16 = 16; + +/// Where the control channel listens, from the launch's `--control`/environment port and the +/// "Let AI agents connect" preference. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Listen { + /// Exactly this port (`--control PORT`, `SOLIDCRAFT_CONTROL_PORT`); no fallback. + Exact(u16), + /// [`DEFAULT_PORT`], or the next free one of [`PORT_TRIES`]. + Default, +} + +/// What a launch listens on. An explicit port wins over the preference, so a scripted launch +/// works with the setting off; with the setting off and no port, nothing listens. +pub fn plan(explicit: Option, enabled: bool) -> Option { + match explicit { + Some(port) => Some(Listen::Exact(port)), + None if enabled => Some(Listen::Default), + None => None, + } +} + +/// Bind a loopback-only listener on `first`, or on the next free port when it is taken, up to +/// `tries` ports in all. +pub fn bind_with_fallback(first: u16, tries: u16) -> Result { + let mut last = Error::new(ErrorKind::AddrInUse, "no port to try"); + for port in (first..=u16::MAX).take(usize::from(tries.max(1))) { + match std::net::TcpListener::bind(("127.0.0.1", port)) { + Ok(l) => return Ok(l), + // In use; Windows reports a port another program holds exclusively as denied. + Err(e) if matches!(e.kind(), ErrorKind::AddrInUse | ErrorKind::PermissionDenied) => last = e, + Err(e) => return Err(e), + } + } + Err(last) +} + +/// The folder of the token files: `/control` (`SOLIDCRAFT_CONTROL_DIR` replaces it). +fn control_dir() -> Option { + match solidcraft_geom::env::var_os("CONTROL_DIR").filter(|d| !d.is_empty()) { + Some(d) => Some(PathBuf::from(d)), + None => Some(crate::recovery::data_dir()?.join("control")), + } +} + /// The token file of the app listening on `port`: `/control/.token` /// (`SOLIDCRAFT_CONTROL_DIR` replaces the folder). pub fn token_path(port: u16) -> Option { - let dir = match solidcraft_geom::env::var_os("CONTROL_DIR").filter(|d| !d.is_empty()) { - Some(d) => PathBuf::from(d), - None => crate::recovery::data_dir()?.join("control"), - }; - Some(dir.join(format!("{port}.token"))) + Some(control_dir()?.join(format!("{port}.token"))) +} + +/// The discovery file: the port of the most recently started app, so a client with no port +/// (`solidcraft-cli mcp`) finds it. It holds only the port; the token stays in its own file. +pub fn current_port_path() -> Option { + Some(control_dir()?.join("current.port")) +} + +/// Record `port` as the running app's, readable only by the user like the token files. +pub fn write_current_port(port: u16) -> Result { + let path = current_port_path().ok_or_else(|| Error::new(ErrorKind::NotFound, "no data folder"))?; + write_token_file(&path, &port.to_string())?; + Ok(path) +} + +/// The port the discovery file names, if there is one. +pub fn read_current_port() -> Option { + parse_port(&std::fs::read_to_string(current_port_path()?).ok().filter(|t| t.len() <= 64)?) +} + +fn parse_port(text: &str) -> Option { + text.trim().parse::().ok().filter(|p| *p != 0) +} + +/// Remove the discovery file at exit, unless a later app has taken it over (it names another port). +pub fn remove_current_port(port: u16) { + if read_current_port() == Some(port) + && let Some(path) = current_port_path() + { + let _ = std::fs::remove_file(path); + } } /// A token set on purpose with `SOLIDCRAFT_CONTROL_TOKEN` (test harnesses, sandboxed packages @@ -108,6 +186,41 @@ mod tests { assert!(!tokens_match("", "a")); } + /// The preference starts the listener by default, off it doesn't, and a port given on the + /// command line or in the environment wins over it either way. + #[test] + fn the_preference_decides_unless_a_port_is_given() { + assert_eq!(plan(None, true), Some(Listen::Default)); + assert_eq!(plan(None, false), None); + assert_eq!(plan(Some(7878), false), Some(Listen::Exact(7878))); + assert_eq!(plan(Some(7878), true), Some(Listen::Exact(7878))); + } + + /// A taken port falls through to the next free one, on loopback only; a run of taken ports + /// is an error, not a hang. + #[test] + fn a_taken_port_falls_back_to_the_next_free_one() { + let first = bind_with_fallback(DEFAULT_PORT, PORT_TRIES).unwrap(); + let a = first.local_addr().unwrap(); + assert!(a.ip().is_loopback() && (DEFAULT_PORT..DEFAULT_PORT + PORT_TRIES).contains(&a.port()), "{a}"); + let second = bind_with_fallback(a.port(), PORT_TRIES).unwrap(); + assert!(second.local_addr().unwrap().port() > a.port()); + assert!(bind_with_fallback(a.port(), 1).is_err()); + } + + #[test] + fn the_discovery_file_holds_a_port() { + assert_eq!(parse_port("27879\n"), Some(27879)); + assert_eq!(parse_port("0"), None); + assert_eq!(parse_port("70000"), None); + assert_eq!(parse_port("nope"), None); + let dir = std::env::temp_dir().join(format!("solidcraft-current-port-{}", std::process::id())); + let path = dir.join("current.port"); + write_token_file(&path, "27881").unwrap(); + assert_eq!(parse_port(&std::fs::read_to_string(&path).unwrap()), Some(27881)); + let _ = std::fs::remove_dir_all(&dir); + } + /// The token file is the user's alone, and a client reads it back. #[cfg(unix)] #[test] diff --git a/crates/engine/src/control_pending.rs b/crates/engine/src/control_pending.rs new file mode 100644 index 0000000000..72511bd5a4 --- /dev/null +++ b/crates/engine/src/control_pending.rs @@ -0,0 +1,161 @@ +//! What a client is told about a control request that outlasts its wait (docs/control-protocol.md, +//! #1618): the app's control server and the bridged MCP server both say what the request still +//! does when it finishes: nothing (it only reads), write a file (an export or a save), or change +//! the design. + +use serde_json::Value; + +/// What a request still running does when it finishes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Effect { + /// It only reads (the design, the window or the command list): nothing changes. + ReadsOnly, + /// It writes a file (an export, a save, a render to a path); the design stays as it is. + WritesFile, + /// It changes the design (or may: anything not known to read or only write a file). + Changes, +} + +impl Effect { + /// What happens when the request finishes, for a message: "it only reads: nothing changes", + /// "it was not cancelled and will still write the file when it finishes", … + pub fn when_done(self) -> &'static str { + match self { + Effect::ReadsOnly => "it only reads: nothing changes", + Effect::WritesFile => "it was not cancelled and will still write the file when it finishes; the design does not change", + Effect::Changes => "it was not cancelled and will still apply when it finishes", + } + } +} + +/// Control methods that only read: a timed-out one changes nothing when it finishes. +/// (`ui.screenshot` with a `path`, and `ui.render`, write a file: see [`effect`].) +const READ_METHODS: &[&str] = &[ + "control.pending", + "document.inspect", + "engine.commands", + "ui.at", + "ui.handles", + "ui.inspect", + "ui.selection", + "ui.sketchToScreen", + "ui.triad", + "ui.worldToScreen", +]; + +/// Commands that write a file and leave the design as it is. `file.save` and `file.save_as` +/// count: they mark the design saved, but change nothing in it. +const FILE_COMMANDS: &[&str] = &["file.export", "file.save", "file.save_as", "file.save_mesh"]; + +/// Commands that only read the design (inspection, lists, measurements). +pub fn command_reads_only(id: &str) -> bool { + let id = crate::find_command(id).map_or(id, |c| c.id); + id.starts_with("inspect.") + || id.starts_with("model.") + || id.ends_with(".inspect") + || id.ends_with(".list") + || matches!(id, "document.inspect" | "engine.commands" | "render.status" | "render.backends" | "render.settings.get" | "file.slicers") +} + +/// What the command `id` with `params` does: an export that is given a `path` (`*.export*`, as +/// `sketch.export_dxf` or `parameters.export`) or a save writes a file; without a path an export +/// only answers with its contents. +pub fn command_effect(id: &str, params: &Value) -> Effect { + let id = crate::find_command(id).map_or(id, |c| c.id); + let exports = id.split('.').skip(1).any(|part| part.starts_with("export")); + if FILE_COMMANDS.contains(&id) || (exports && params.get("path").is_some_and(|p| !p.is_null())) { + Effect::WritesFile + } else if command_reads_only(id) || exports { + Effect::ReadsOnly + } else { + Effect::Changes + } +} + +/// What the request `method` with `params` does when it finishes. Renders and screenshots +/// change nothing in the design; given a `path` they write it. +pub fn effect(method: &str, params: &Value) -> Effect { + match method { + "engine.execute" | "command" => match params.get("command").or_else(|| params.get("id")).and_then(Value::as_str) { + Some(id) => command_effect(id, params.get("params").unwrap_or(&Value::Null)), + None => Effect::Changes, + }, + "ui.render" => Effect::WritesFile, + "ui.screenshot" if params.get("path").is_some_and(|p| !p.is_null()) => Effect::WritesFile, + "ui.screenshot" => Effect::ReadsOnly, + m if READ_METHODS.contains(&m) => Effect::ReadsOnly, + _ => Effect::Changes, + } +} + +/// Whether the request `method` with `params` only reads. +pub fn reads_only(method: &str, params: &Value) -> bool { + effect(method, params) == Effect::ReadsOnly +} + +/// The request in a message: "`engine.execute` (solid.fillet)", or just "`ui.inspect`". +pub fn describe(method: &str, params: &Value) -> String { + let command = matches!(method, "engine.execute" | "command") + .then(|| params.get("command").or_else(|| params.get("id")).and_then(Value::as_str)) + .flatten() + .filter(|c| c.len() <= 128); + match command { + Some(c) => format!("`{method}` ({c})"), + None => format!("`{}`", method.chars().take(128).collect::()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn reads_are_told_from_changes() { + for (m, p) in [ + ("ui.inspect", json!({})), + ("ui.screenshot", json!({})), + ("document.inspect", json!({"measure": true})), + ("engine.execute", json!({"command": "sketch.inspect"})), + ("engine.execute", json!({"command": "inspect.measure"})), + ("engine.execute", json!({"command": "model.edges", "params": {"body": "Body1"}})), + ("engine.execute", json!({"command": "parameters.export", "params": {"format": "csv"}})), + ] { + assert!(reads_only(m, &p), "{m} {p}"); + assert_eq!(effect(m, &p), Effect::ReadsOnly, "{m} {p}"); + } + for (m, p) in [ + ("engine.execute", json!({"command": "solid.fillet"})), + ("engine.execute", json!({})), + ("engine.batch", json!({"commands": []})), + ("ui.click", json!({"x": 1, "y": 2})), + ("app.quit", json!({})), + ] { + assert!(!reads_only(m, &p), "{m} {p}"); + assert_eq!(effect(m, &p), Effect::Changes, "{m} {p}"); + } + assert_eq!(describe("engine.execute", &json!({"command": "solid.fillet"})), "`engine.execute` (solid.fillet)"); + assert_eq!(describe("ui.inspect", &json!({})), "`ui.inspect`"); + } + + /// An export or a save writes a file and leaves the design as it is (#1989: a STEP export + /// is not said to "still apply"). + #[test] + fn file_writers_are_told_apart() { + for (m, p) in [ + ("engine.execute", json!({"command": "file.export", "params": {"path": "/tmp/a.step"}})), + ("engine.execute", json!({"command": "file.save", "params": {}})), + ("engine.execute", json!({"command": "file.save_as", "params": {"path": "/tmp/a.solidcraft"}})), + ("engine.execute", json!({"command": "file.save_mesh", "params": {"path": "/tmp/a.stl"}})), + ("engine.execute", json!({"command": "sketch.export_dxf", "params": {"path": "/tmp/a.dxf"}})), + ("ui.render", json!({"path": "/tmp/a.png"})), + ("ui.screenshot", json!({"path": "/tmp/a.png"})), + ] { + assert_eq!(effect(m, &p), Effect::WritesFile, "{m} {p}"); + assert!(!reads_only(m, &p), "{m} {p}"); + } + assert!(Effect::WritesFile.when_done().contains("will still write the file") && !Effect::WritesFile.when_done().contains("apply")); + assert!(Effect::Changes.when_done().contains("will still apply")); + assert!(Effect::ReadsOnly.when_done().contains("nothing changes")); + } +} diff --git a/crates/engine/src/crash_tests.rs b/crates/engine/src/crash_tests.rs index b7a9b5f005..4eafc050a4 100644 --- a/crates/engine/src/crash_tests.rs +++ b/crates/engine/src/crash_tests.rs @@ -75,6 +75,15 @@ fn child() { "abort" => { let _ = s.execute("diagnostics.crash_test", &json!({"mode": "abort"})); } + // Back to the root between two components: only the session's active component changes. + "components" => { + s.execute("component.create", &json!({"name": "Tray"})).unwrap(); + s.execute("solid.box", &json!({"length": 5, "width": 5, "height": 5})).unwrap(); + s.execute("component.activate", &json!({"component": "root"})).unwrap(); + s.execute("component.create", &json!({"name": "Bracket"})).unwrap(); + s.execute("solid.box", &json!({"length": 4, "width": 4, "height": 4})).unwrap(); + let _ = s.execute("diagnostics.crash_test", &json!({"mode": "abort"})); + } _ => {} } end_session(); @@ -205,6 +214,27 @@ fn render_settings_survive_unclean_exit_and_journal_replay() { let _ = fs::remove_dir_all(&d); } +#[test] +fn activating_a_component_is_journaled_so_the_replay_builds_in_the_same_component() { + let d = fresh_dir("components"); + assert!(!run_child(&d, "components").1); + assert!(run_child(&d, "none").1); + let (_, report) = only_report(&d); + let replay: Value = serde_json::from_slice(&fs::read(report["replay_path"].as_str().unwrap()).unwrap()).unwrap(); + let commands = replay["commands"].as_array().unwrap(); + assert_eq!(commands.iter().filter(|c| c["command"] == "component.activate").count(), 1, "{replay}"); + let mut recovered = Session::default(); + for command in commands { + let id = command["command"].as_str().unwrap(); + if id != "diagnostics.crash_test" { + recovered.execute(id, &command["params"]).unwrap(); + } + } + let bracket = recovered.doc.components.iter().find(|c| c.name == "Bracket").unwrap(); + assert_eq!(bracket.parent, 0, "the Bracket was made under the root"); + let _ = fs::remove_dir_all(&d); +} + #[test] fn a_crash_while_starting_up_names_the_step_and_the_renderer() { let d = fresh_dir("startup"); diff --git a/crates/engine/src/face_name_tests.rs b/crates/engine/src/face_name_tests.rs index 89a089ed47..e2e5b9b137 100644 --- a/crates/engine/src/face_name_tests.rs +++ b/crates/engine/src/face_name_tests.rs @@ -174,7 +174,7 @@ fn a_name_that_finds_no_face_is_refused_and_the_design_is_unchanged() { #[test] fn names_find_the_faces_of_a_moved_component() { let mut s = Session::default(); - run(&mut s, "component.create", json!({"name": "Part"})); + run(&mut s, "component.create", json!({"name": "Part", "grounded": false})); run(&mut s, "solid.box", json!({"length": 30, "width": 20, "height": 10, "body_name": "Block"})); run(&mut s, "solid.cylinder", json!({"base": [15, 10, -1], "radius": 3, "height": 12, "operation": "cut"})); run(&mut s, "occurrence.move", json!({"occurrence": "Part:1", "translate": [50, 0, 0]})); diff --git a/crates/engine/src/frames_tests.rs b/crates/engine/src/frames_tests.rs index 46dc008c19..52adb95560 100644 --- a/crates/engine/src/frames_tests.rs +++ b/crates/engine/src/frames_tests.rs @@ -37,7 +37,7 @@ fn volume(s: &mut Session) -> f64 { /// and a construction plane, moved 50 mm along X. fn moved_part() -> Session { let mut s = Session::default(); - run(&mut s, "component.create", json!({"name": "Part"})); + run(&mut s, "component.create", json!({"name": "Part", "grounded": false})); run(&mut s, "sketch.create", json!({"plane": "XY", "name": "Base"})); run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [30, 20]})); run(&mut s, "sketch.finish", json!({})); @@ -211,7 +211,7 @@ fn sketch_world_points(s: &mut Session, sketch: &str) -> Vec { /// A block "C" (40 x 30 x 20) in its own component, the root active again. fn block_in_component() -> Session { let mut s = Session::default(); - run(&mut s, "component.create", json!({"name": "C"})); + run(&mut s, "component.create", json!({"name": "C", "grounded": false})); run(&mut s, "solid.box", json!({"length": 40, "width": 30, "height": 20})); run(&mut s, "component.activate", json!({"component": "root"})); s diff --git a/crates/engine/src/hole_reference_tests.rs b/crates/engine/src/hole_reference_tests.rs new file mode 100644 index 0000000000..da8ac53a67 --- /dev/null +++ b/crates/engine/src/hole_reference_tests.rs @@ -0,0 +1,167 @@ +//! A Single hole placed by distances from reference edges (#37): the centre sits at the typed +//! distances from straight edges of its face, measured in the face's plane, follows the edges +//! when the part changes (also after the design is saved and reopened), and a bad reference is +//! refused with an error. + +use std::f64::consts::PI; + +use serde_json::{Value, json}; +use solidcraft_doc::FeatureKind; +use solidcraft_geom::Vec3; + +use crate::{EngineError, Session}; + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + match s.execute(id, &p) { + Ok(v) => v, + Err(e) => panic!("{id} {p}: {e}"), + } +} + +fn solid_at(s: &Session, p: Vec3) -> bool { + s.model.state().bodies.iter().any(|b| b.body.contains(p)) +} + +/// The bore (5 mm across, 4 mm deep, down from the top face at z = `top`) is about (x, y): +/// empty on its axis and up to 2 mm from it, solid 3 mm from it in every direction (the margins +/// leave room for the facets of a larger body's coarser mesh). +fn bore_at(s: &Session, x: f64, y: f64, top: f64) { + let at = Vec3::new(x, y, top - 2.0); + for k in 0..8 { + let a = PI / 4.0 * f64::from(k); + let p = |r: f64| at + Vec3::new(a.cos(), a.sin(), 0.0) * r; + assert!(!solid_at(s, p(0.0)) && !solid_at(s, p(2.0)), "the bore about ({x}, {y}) is empty {a} rad from +x"); + assert!(solid_at(s, p(3.0)), "the wall about ({x}, {y}) is solid {a} rad from +x"); + } +} + +/// The last feature's position and reference edges. +fn hole(s: &Session) -> (Vec3, Vec) { + match &s.doc.features.last().map(|f| &f.kind) { + Some(FeatureKind::Hole { position, references, .. }) => (position.0, references.clone()), + other => panic!("the last feature is not a hole: {other:?}"), + } +} + +/// A 50 × 40 × 10 box from the origin whose length and width are parameters. +fn plate() -> Session { + let mut s = Session::default(); + run(&mut s, "parameters.add", json!({"name": "len", "expression": "50 mm"})); + run(&mut s, "parameters.add", json!({"name": "wid", "expression": "40 mm"})); + run(&mut s, "solid.box", json!({"length": "len", "width": "wid", "height": 10, "corner": [0, 0, 0]})); + s +} + +/// A Ø5 × 4 hole picked at (30, 25) on the top, with these references. +fn referenced(references: Value) -> Value { + json!({"position": [30, 25, 10], "diameter": 5, "depth": 4, "name": "H", "references": references}) +} + +#[test] +fn a_hole_sits_at_its_distances_from_two_edges() { + let mut s = plate(); + // 10 mm from the left edge (x = 0) and 12 mm from the front edge (y = 0) of the top. + run(&mut s, "solid.hole", referenced(json!([{"edge": [0, 20, 10], "distance": 10}, {"edge": [25, 0, 10], "distance": "12 mm"}]))); + let (position, refs) = hole(&s); + assert!(position.dist(Vec3::new(10.0, 12.0, 10.0)) < 1e-6, "the hole is placed at (10, 12), not {position:?}"); + assert_eq!(refs.len(), 2); + bore_at(&s, 10.0, 12.0, 10.0); + assert!(solid_at(&s, Vec3::new(30.0, 25.0, 8.0)), "nothing is drilled where the face was picked"); + // The distances are the hole's parameters, named like its other inputs. + let f = s.doc.find_feature("H").unwrap(); + let labels: Vec<&str> = f.kind.inputs().iter().map(|(label, _, _)| *label).collect(); + assert_eq!(labels.iter().filter(|l| **l == "Reference distance").count(), 2, "{labels:?}"); + assert_eq!(f.param_names.len(), labels.len()); + assert_eq!(f.edge_names.len(), 2, "each reference edge keeps its persistent name"); + // One reference: the hole moves square to that edge and keeps its place along it. + run(&mut s, "edit.undo", json!({})); + run(&mut s, "solid.hole", referenced(json!([{"edge": [0, 20, 10], "distance": 10}]))); + bore_at(&s, 10.0, 25.0, 10.0); +} + +#[test] +fn a_hole_follows_its_edges_when_the_box_grows() { + let mut s = plate(); + // From the far edges: 10 mm from x = len, 12 mm from y = wid. + run(&mut s, "solid.hole", referenced(json!([{"edge": [50, 20, 10], "distance": 10}, {"edge": [25, 40, 10], "distance": 12}]))); + bore_at(&s, 40.0, 28.0, 10.0); + let id = s.doc.find_feature("H").unwrap().id; + run(&mut s, "parameters.change", json!({"name": "len", "expression": "80 mm"})); + run(&mut s, "parameters.change", json!({"name": "wid", "expression": "65 mm"})); + assert!(s.model.result(id).is_some_and(|r| r.error.is_none()), "{:?}", s.model.result(id).map(|r| &r.error)); + bore_at(&s, 70.0, 53.0, 10.0); + assert!(solid_at(&s, Vec3::new(40.0, 28.0, 8.0)), "the old spot is solid again"); + // A distance is a parameter too: changing it moves the hole. + let d = s.doc.find_feature("H").unwrap().param_names.last().cloned().unwrap(); + run(&mut s, "parameters.change", json!({"name": d, "expression": "20 mm"})); + bore_at(&s, 70.0, 45.0, 10.0); + // Smaller than where the hole was placed: it still follows its edges. + run(&mut s, "parameters.change", json!({"name": "len", "expression": "30 mm"})); + bore_at(&s, 20.0, 45.0, 10.0); + // Saved and reopened, the hole keeps its references and its place. + let mut t = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap()); + t.refresh(); + bore_at(&t, 20.0, 45.0, 10.0); + assert_eq!(hole(&t).1, hole(&s).1); +} + +#[test] +fn a_bad_reference_is_refused() { + let mut s = plate(); + run(&mut s, "solid.hole", json!({"position": [25, 20, 10], "diameter": 10, "name": "Bore"})); + let before = s.doc.features.len(); + let edge = |p: [f64; 3], d: Value| json!({"edge": p, "distance": d}); + for (refs, why) in [ + (json!([edge([25.0, 30.0, 10.0], json!(5))]), "no edge"), + (json!([edge([30.0, 20.0, 10.0], json!(5))]), "not straight"), + (json!([edge([0.0, 0.0, 5.0], json!(5))]), "square to the face"), + (json!([edge([0.0, 20.0, 10.0], json!(5)), edge([50.0, 20.0, 10.0], json!(5))]), "parallel"), + (json!([edge([0.0, 20.0, 10.0], json!(5)), edge([25.0, 0.0, 10.0], json!(5)), edge([50.0, 20.0, 10.0], json!(5))]), "at most 2"), + (json!([edge([0.0, 20.0, 10.0], json!("-5 mm"))]), "0 or more"), + (json!([edge([0.0, 20.0, 10.0], json!(500))]), "off its face"), + (json!([edge([0.0, 20.0, 10.0], json!("5 deg"))]), "references"), + (json!([edge([0.0, 20.0, 10.0], json!("nope"))]), "references"), + (json!([{"edge": [0, 20], "distance": 5}]), "edge"), + (json!([{"edge": [0, 20, 10]}]), "distance"), + (json!({"edge": [0, 20, 10], "distance": 5}), "list"), + ] { + let p = referenced(refs.clone()); + match s.execute("solid.hole", &p) { + Err(EngineError::Internal(..)) | Ok(_) => panic!("{refs} is refused"), + Err(e) => assert!(e.to_string().contains(why), "{refs}: {e}"), + } + assert_eq!(s.doc.features.len(), before, "{refs} adds nothing"); + } + // Not with sketch points or a position given as expressions. + for p in [ + json!({"position": ["30 mm", "25 mm", "10 mm"], "diameter": 5, "references": [edge([0.0, 20.0, 10.0], json!(5))]}), + json!({"sketch": 1, "points": ["p1"], "diameter": 5, "references": [edge([0.0, 20.0, 10.0], json!(5))]}), + ] { + assert!(s.execute("solid.hole", &p).is_err(), "{p}"); + } +} + +#[test] +fn hostile_references_never_panic() { + let mut s = plate(); + let values = crate::tests::hostile_values(); + let mut panicked = Vec::new(); + for v in &values { + for refs in [ + v.clone(), + json!([v]), + json!([v, v]), + json!([{"edge": v, "distance": 5}]), + json!([{"edge": [0, 20, 10], "distance": v}]), + json!([{"edge": [0, 20, 10], "distance": 5}, {"edge": v, "distance": v}]), + ] { + for at in [json!([30, 25, 10]), json!([0, 0, 10]), json!([1e300, 0, 10]), v.clone()] { + let p = json!({"position": at, "diameter": 5, "references": refs}); + if let Err(EngineError::Internal(id, msg)) = s.execute("solid.hole", &p) { + panicked.push(format!("{id} {p}: {msg}")); + } + } + } + } + assert!(panicked.is_empty(), "solid.hole panicked:\n{}", panicked.join("\n")); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d1feb96364..bd8c97d4a8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -10,6 +10,7 @@ pub mod capture; pub mod cmd; pub mod control_auth; +pub mod control_pending; pub mod crash; pub mod frames; pub mod fuzz; @@ -138,7 +139,7 @@ pub enum Sel { Plane { name: String, }, - /// An origin axis (X, Y, Z). + /// An origin axis (X, Y, Z) or a construction axis (by feature name). Axis { name: String, }, @@ -166,6 +167,16 @@ pub struct Snapshot { pub pending_moves: std::collections::BTreeMap, } +/// Where the open sketch was entered (`sketch.create`, `sketch.edit`): the design before it and +/// the undo history then, so `sketch.cancel` can tell the entry still describes the history. +#[derive(Clone)] +pub(crate) struct SketchEntry { + pub(crate) sketch: u64, + pub(crate) doc: Arc, + pub(crate) undo_len: usize, + pub(crate) undo_top: Option>, +} + /// A copy of a session that evaluates commands ahead of it ([`Session::ahead`]). pub struct Ahead(Session); @@ -243,6 +254,8 @@ pub struct Session { pub redo: Vec, /// Sketch feature being edited (sketch mode). pub active_sketch: Option, + /// The design as it was when the open sketch was entered, for Cancel Sketch. + pub(crate) sketch_entry: Option, pub selection: Vec, pub path: Option, /// One-shot display-only notification during doc.open, before authoritative evaluation. @@ -419,6 +432,7 @@ impl Session { undo: Vec::new(), redo: Vec::new(), active_sketch: None, + sketch_entry: None, selection: Vec::new(), path: None, open_preview: None, @@ -675,6 +689,8 @@ impl Session { /// a body or a feature (or undid or redid such a rename) renames it in the model instead, /// which keeps every result and mesh (#3044). fn refresh_from(&mut self, previous: &Arc) { + self.follow_plane_renames(previous); + self.follow_axis_point_renames(previous); if let Some((old, new)) = self.body_rename(previous) && self.model.rename_body(&self.doc, &old, &new) { @@ -694,6 +710,27 @@ impl Session { self.refresh(); } + /// A hidden construction plane stays hidden when the design renames it, or undo or redo + /// gives it another name (#4353): the hidden list keeps planes by name. + fn follow_plane_renames(&mut self, previous: &Document) { + if self.visibility.hidden_origin.is_empty() { + return; + } + let before: std::collections::HashMap = previous + .features + .iter() + .filter(|f| matches!(f.kind, solidcraft_doc::FeatureKind::ConstructionPlane { .. })) + .map(|f| (f.id, f.name.as_str())) + .collect(); + for f in &self.doc.features { + if let Some(old) = before.get(&f.id).filter(|old| **old != f.name) { + for n in self.visibility.hidden_origin.iter_mut().filter(|n| *n == old) { + n.clone_from(&f.name); + } + } + } + } + /// The body rename that turned `previous` into the design (old name, new name), if that is /// the whole change: one this session made (either way round), else one the designs show. fn body_rename(&mut self, previous: &Arc) -> Option<(String, String)> { @@ -830,6 +867,8 @@ impl Session { active_sketch: self.active_sketch, pending_moves: self.pending_moves.clone(), }; + // Where new geometry goes: not in the design, but a replay needs it (component.activate). + let active_before = (self.active_component, self.active_occurrence_path.clone()); let null = Value::Object(Default::default()); let params = if params.is_null() { &null } else { params }; let given = params; @@ -871,6 +910,8 @@ impl Session { let changed = !Arc::ptr_eq(&self.doc, &before.doc) || self.active_sketch != before.active_sketch || self.pending_moves != before.pending_moves + || self.active_component != active_before.0 + || self.active_occurrence_path != active_before.1 || spec.id.starts_with("select."); crash::command_done(self.crash_journal, changed, &before.doc, after, spec.id, given, failed.map_or(Ok(()), Err)); } @@ -984,6 +1025,7 @@ impl Session { undo: Vec::new(), redo: Vec::new(), active_sketch: self.active_sketch, + sketch_entry: None, selection: self.selection.clone(), path: None, open_preview: None, @@ -1216,6 +1258,8 @@ mod hole_face_tests; #[cfg(test)] mod hole_matrix_tests; #[cfg(test)] +mod hole_reference_tests; +#[cfg(test)] mod large_assembly_tests; #[cfg(test)] mod measure_centre_tests; diff --git a/crates/engine/src/occurrences.rs b/crates/engine/src/occurrences.rs index 342f131c87..d50a268d1d 100644 --- a/crates/engine/src/occurrences.rs +++ b/crates/engine/src/occurrences.rs @@ -41,6 +41,26 @@ pub(crate) struct RemovedLaterCache { } impl Session { + /// Visibility keeps construction names, while undo history keeps feature ids. Follow the + /// same axis or point into the new document on rename, undo and redo (#4419). + pub(crate) fn follow_axis_point_renames(&mut self, previous: &solidcraft_doc::Document) { + if self.visibility.hidden_origin.is_empty() { + return; + } + let names: std::collections::HashMap<_, _> = self.doc.features.iter().map(|f| (f.id, &f.name)).collect(); + let renamed: std::collections::HashMap<_, _> = previous + .features + .iter() + .filter(|f| matches!(f.kind, FeatureKind::ConstructionAxis { .. } | FeatureKind::ConstructionPoint { .. })) + .filter_map(|f| names.get(&f.id).filter(|name| **name != &f.name).map(|name| (f.name.as_str(), *name))) + .collect(); + for name in &mut self.visibility.hidden_origin { + if let Some(new) = renamed.get(name.as_str()) { + name.clone_from(new); + } + } + } + /// Document with uncaptured occurrence positions applied, without changing saved geometry. pub fn placed_document(&self) -> std::borrow::Cow<'_, solidcraft_doc::Document> { if self.pending_moves.is_empty() { diff --git a/crates/engine/src/product_scene.rs b/crates/engine/src/product_scene.rs index e4e3321b4e..43fa52f644 100644 --- a/crates/engine/src/product_scene.rs +++ b/crates/engine/src/product_scene.rs @@ -62,7 +62,7 @@ mod tests { #[test] fn product_scene_keeps_placed_appearances_and_meshes_without_design_overlays() { let mut s = Session::default(); - let component = s.execute("component.create", &json!({"name":"Part"})).unwrap(); + let component = s.execute("component.create", &json!({"name":"Part","grounded":false})).unwrap(); s.execute("solid.box", &json!({"length":10,"width":10,"height":10,"body_name":"Block"})).unwrap(); s.execute("appearance.assign", &json!({"bodies":["Block"],"color":"#20a080","opacity":0.4})).unwrap(); s.execute("appearance.assign", &json!({"faces":[[5,5,10]],"color":"#ff0000","opacity":0.7})).unwrap(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 7f1f58e459..5e1f23df9f 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -822,6 +822,11 @@ fn hostile_params_never_panic() { "keep_with", "stitch", "extend", + "profile", + "anchor", + "density", + "names", + "references", ]; // Commands are independent; worker threads take them one at a time (their costs vary a lot). let ids: Vec<&str> = command_specs() @@ -1254,6 +1259,55 @@ fn body_volume(st: &solidcraft_doc::ModelState) -> f64 { st.bodies.iter().map(|b| solidcraft_kernel::measure(&b.body).unwrap().volume).sum() } +/// Deleting an inserted assembly frees its body names without leaving assembly ownership behind. +#[test] +fn step_insert_delete_removes_assembly_and_undo_restores_it() { + let dir = std::env::temp_dir().join(format!("solidcraft-step-delete-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("parts.step"); + let mut source = Session::default(); + for (name, x) in [("Part", 0), ("Other", 20)] { + run(&mut source, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0], "operation": "new", "body_name": name})); + } + run(&mut source, "component.from_bodies", json!({"bodies": ["Part", "Other"]})); + run(&mut source, "file.export", json!({"path": path.to_string_lossy()})); + + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Part"})); + let kept = s.doc_mut().add_component(Some("Kept"), 0).unwrap(); + let kept_occurrence = s.doc.occurrence_of(kept).unwrap().clone(); + run(&mut s, "file.insert_step", json!({"path": path.to_string_lossy(), "name": "Imported"})); + let reopened = solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap(); + assert!(reopened.find_feature("Imported").unwrap().inserted, "the insert keeps its mark through save"); + run(&mut s, "solid.mirror", json!({"bodies": ["Part"], "plane": {"origin": [0, 30, 0], "normal": [0, 1, 0]}})); + assert!(s.model.state().body("Part (3)").is_some()); + assert!(!s.doc.assembly.joints.is_empty()); + assert!(!s.doc.body_components.is_empty()); + let before = s.doc.clone(); + let history = s.undo.len(); + + run(&mut s, "timeline.delete", json!({"feature": "Imported"})); + assert_eq!(s.undo.len(), history + 1); + assert_eq!(s.doc.components.iter().map(|c| c.id).collect::>(), [kept]); + assert_eq!(s.doc.occurrences, [kept_occurrence]); + assert!(s.doc.assembly.joints.is_empty()); + assert!(s.doc.body_components.is_empty()); + assert!(s.doc.body_offsets.is_empty()); + let state = s.model.state(); + let mirrored = state.body("Part (2)").unwrap(); + assert_eq!(s.doc.model_body_component(mirrored), 0); + run(&mut s, "solid.box", json!({"length": 5, "width": 5, "height": 5, "corner": [40, 0, 0], "operation": "new", "body_name": "Other"})); + assert_eq!(s.doc.model_body_component(s.model.state().body("Other").unwrap()), 0); + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc, before); + assert!(s.model.state().body("Part (3)").is_some()); + run(&mut s, "edit.redo", json!({})); + assert!(s.doc.body_components.is_empty()); + assert!(s.doc.assembly.joints.is_empty()); + std::fs::remove_dir_all(dir).unwrap(); +} + /// Previews evaluate on a scratch copy: the document, undo history and model are untouched. #[test] fn preview_has_no_side_effects() { @@ -1819,7 +1873,7 @@ fn components_occurrences_and_world_placement() { let mut s = Session::default(); run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Base"})); // A component, active: its box is authored in its frame. - let c = run(&mut s, "component.create", json!({"name": "Arm"})); + let c = run(&mut s, "component.create", json!({"name": "Arm", "grounded": false})); let (cid, occ) = (c["component"].as_u64().unwrap(), c["occurrence"].as_u64().unwrap()); run(&mut s, "solid.box", json!({"corner": [20, 0, 0], "length": 10, "width": 4, "height": 4, "body_name": "ArmBody"})); run(&mut s, "component.activate", json!({"component": 0})); @@ -4018,6 +4072,64 @@ fn renaming_an_imported_assembly_body_reads_nothing_again() { let _ = std::fs::remove_dir_all(&dir); } +/// #2467: renaming a body that later features change (a hole patterned over it, then a chamfer) +/// evaluates nothing, and leaves the bodies, volumes and face names evaluating the renamed +/// design gives; undo and redo do the same. +#[test] +fn renaming_a_body_later_features_change_evaluates_nothing() { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [80, 50]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance": 10})); + run( + &mut s, + "solid.hole", + json!({"position": [15, 12, 10], "diameter": 5, "type": "counterbore", "cb_diameter": 9, "cb_depth": 3, "name": "Hole1"}), + ); + run( + &mut s, + "solid.pattern.rectangular", + json!({"features": ["Hole1"], "dir1": [1, 0, 0], "count1": 3, "spacing1": 25, "dir2": [0, 1, 0], "count2": 2, "spacing2": 25}), + ); + run(&mut s, "solid.chamfer", json!({"edges": [[40, 0, 10], [80, 25, 10]], "distance": 1})); + let looks = |st: &solidcraft_doc::ModelState| { + st.bodies.iter().map(|b| (b.name.clone(), (*solidcraft_doc::naming::face_names(b)).clone())).collect::>() + }; + let before = looks(&s.model.state()); + let v = body_volume(&s.model.state()); + let old = before.first().map(|b| b.0.clone()).unwrap(); + s.evaluations = 0; + run(&mut s, "body.rename", json!({"body": old, "name": "Plate"})); + assert_eq!(s.evaluations, 0, "a rename evaluates nothing"); + let after = looks(&s.model.state()); + assert_eq!(after.first().map(|b| b.0.as_str()), Some("Plate")); + let mut fresh = Session::new((*s.doc).clone()); + fresh.refresh(); + assert_eq!(looks(&fresh.model.state()), after, "the bodies and face names evaluating the renamed design gives"); + assert!(rel(body_volume(&fresh.model.state()), v) < 1e-9); + run(&mut s, "edit.undo", json!({})); + assert_eq!(looks(&s.model.state()), before); + run(&mut s, "edit.redo", json!({})); + assert_eq!(looks(&s.model.state()), after); + assert_eq!(s.evaluations, 0, "nor do its undo and redo"); +} + +/// A body whose name later bodies' names are worked out from (`X` patterned into `X (2)`) is still +/// renamed by evaluating the design, so the copies follow it as they would on opening it. +#[test] +fn renaming_a_body_whose_copies_take_its_name_evaluates_the_design() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "X"})); + run(&mut s, "solid.pattern.rectangular", json!({"bodies": ["X"], "dir1": [1, 0, 0], "count1": 3, "spacing1": 20})); + let names = |s: &Session| s.model.state().bodies.iter().map(|b| b.name.clone()).collect::>(); + assert!(names(&s).iter().any(|n| n.starts_with("X (")), "{:?}", names(&s)); + run(&mut s, "body.rename", json!({"body": "X", "name": "First"})); + let mut fresh = Session::new((*s.doc).clone()); + fresh.refresh(); + assert_eq!(names(&fresh), names(&s)); +} + /// #3044: on a design with thousands of bodies the cheap commands stay cheap: showing, hiding, /// selecting, colouring and renaming a body or a feature, and undoing them, recompute no feature /// (renames don't even look over the timeline) and leave every body's display mesh as it was, so diff --git a/crates/engine/src/tests/component_frames.rs b/crates/engine/src/tests/component_frames.rs index 20514c2f34..75d1d3794e 100644 --- a/crates/engine/src/tests/component_frames.rs +++ b/crates/engine/src/tests/component_frames.rs @@ -14,7 +14,7 @@ use crate::*; fn moved_component_picks_and_frames() { let mut s = Session::default(); run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Base"})); - let c = run(&mut s, "component.create", json!({"name": "Arm"})); + let c = run(&mut s, "component.create", json!({"name": "Arm", "grounded": false})); let (cid, occ) = (c["component"].as_u64().unwrap(), c["occurrence"].as_u64().unwrap()); run(&mut s, "solid.box", json!({"corner": [20, 0, 0], "length": 10, "width": 4, "height": 4, "body_name": "ArmBody"})); run(&mut s, "component.activate", json!({"component": 0})); @@ -61,6 +61,7 @@ fn extrude_in_a_moved_component_from_a_root_sketch() { run(&mut s, "solid.extrude", json!({"distance": 10, "body_name": "Jaw"})); let made = run(&mut s, "component.from_bodies", json!({"bodies": ["Jaw"]})); let jaw = made["components"][0]["component"].as_u64().unwrap(); + run(&mut s, "occurrence.ground", json!({"occurrence": "Jaw:1", "grounded": false})); // Turned half a turn about Z: the jaw now lies along +X (x 0..40, y -5..5). run(&mut s, "occurrence.move", json!({"occurrence": "Jaw:1", "axis": [0, 0, 1], "angle": "180 deg", "origin": [0, 0, 0]})); run(&mut s, "component.activate", json!({"component": 0})); @@ -89,6 +90,7 @@ fn extrude_to_a_face_in_a_moved_component() { run(&mut s, "solid.extrude", json!({"distance": 10, "body_name": "Jaw"})); let made = run(&mut s, "component.from_bodies", json!({"bodies": ["Jaw"]})); let jaw = made["components"][0]["component"].as_u64().unwrap(); + run(&mut s, "occurrence.ground", json!({"occurrence": "Jaw:1", "grounded": false})); run(&mut s, "occurrence.move", json!({"occurrence": "Jaw:1", "axis": [0, 0, 1], "angle": "180 deg", "origin": [0, 0, 0]})); // Lifted too, so the jaw's own frame and the world differ in height (x 0..40, z 5..15). run(&mut s, "occurrence.move", json!({"occurrence": "Jaw:1", "translate": [0, 0, 5]})); @@ -113,7 +115,7 @@ fn extrude_to_a_face_in_a_moved_component() { /// frame), its occurrence moved by `moves`, then a new component `Post`, active, with a 6 x 6 mm /// sketch `Pad` centred at the origin (#928). fn ramp_and_pad(s: &mut Session, moves: &[serde_json::Value]) { - run(s, "component.create", json!({"name": "Target"})); + run(s, "component.create", json!({"name": "Target", "grounded": false})); run(s, "sketch.create", json!({"plane": "YZ", "name": "Wedge"})); run(s, "sketch.line", json!({"points": [[-20, -30], [20, -30], [20, -10], [-20, -30]]})); run(s, "sketch.finish", json!({})); @@ -202,7 +204,7 @@ fn extrude_to_a_face_in_another_component_follows_it() { #[test] fn revolve_to_a_cylinder_in_another_moved_component() { let mut s = Session::default(); - run(&mut s, "component.create", json!({"name": "Pin"})); + run(&mut s, "component.create", json!({"name": "Pin", "grounded": false})); run(&mut s, "solid.cylinder", json!({"base": [0, 14, -5], "radius": 4, "height": 15, "body_name": "PostBody"})); run(&mut s, "component.activate", json!({"component": "root"})); // From 90° round to 45° about Z: the post's axis is at (14 sin 45°, 14 cos 45°). @@ -236,7 +238,7 @@ fn revolve_to_a_cylinder_in_another_moved_component() { fn extrude_to_names_the_face_it_is_on() { for moved in [false, true] { let mut s = Session::default(); - run(&mut s, "component.create", json!({"name": "Other"})); + run(&mut s, "component.create", json!({"name": "Other", "grounded": false})); let z = if moved { -40.02 + 10.0 } else { -40.02 }; run(&mut s, "solid.box", json!({"corner": [-50, -50, z], "length": 100, "width": 100, "height": 20, "body_name": "Shim"})); run(&mut s, "component.activate", json!({"component": "root"})); @@ -264,7 +266,7 @@ fn extrude_to_names_the_face_it_is_on() { #[test] fn extrude_to_a_named_face_keeps_it_when_another_part_touches_it() { let mut s = Session::default(); - run(&mut s, "component.create", json!({"name": "Other"})); + run(&mut s, "component.create", json!({"name": "Other", "grounded": false})); // A plate whose top face is z = 50, out of the way for now. run(&mut s, "solid.box", json!({"corner": [-50, -50, 30], "length": 100, "width": 100, "height": 20, "body_name": "Plate"})); run(&mut s, "component.activate", json!({"component": "root"})); diff --git a/crates/engine/src/tests/import_component.rs b/crates/engine/src/tests/import_component.rs index 84fa22a02e..b94313ffed 100644 --- a/crates/engine/src/tests/import_component.rs +++ b/crates/engine/src/tests/import_component.rs @@ -221,6 +221,8 @@ fn an_imported_assembly_moves_as_one_until_it_is_unlocked() { for o in &inside { assert_eq!(dof.get(o), dof.get(&rig), "occurrence {o} moves only with the import: {r}"); } + // The design's first part is grounded when it comes in; free, it moves. + run(&mut s, "occurrence.ground", json!({"occurrence": "rig:1", "grounded": false})); // Moving the import's component moves everything in it. run(&mut s, "occurrence.move", json!({"occurrence": "rig:1", "translate": [0, 0, 50]})); @@ -322,7 +324,7 @@ fn a_design_with_a_loose_import_rebuilds_unchanged() { /// first placement turned and moved), with `Outer` itself turned about X and lifted. fn nested_file(dir: &std::path::Path) -> String { let mut s = Session::default(); - let outer = run(&mut s, "component.create", json!({"name": "Outer"}))["occurrence"].clone(); + let outer = run(&mut s, "component.create", json!({"name": "Outer", "grounded": false}))["occurrence"].clone(); run(&mut s, "solid.box", json!({"length": 30, "width": 30, "height": 5, "body_name": "Plate"})); let inner = run(&mut s, "component.create", json!({"name": "Inner"}))["occurrence"].clone(); run(&mut s, "solid.box", json!({"length": 40, "width": 20, "height": 10, "body_name": "Block"})); diff --git a/crates/engine/src/tests/mirror_placed.rs b/crates/engine/src/tests/mirror_placed.rs index 17bcebc5ab..68ee1a398c 100644 --- a/crates/engine/src/tests/mirror_placed.rs +++ b/crates/engine/src/tests/mirror_placed.rs @@ -25,7 +25,7 @@ fn assert_box(got: (Vec3, Vec3), want: (Vec3, Vec3)) { /// A component "Arm" holding "ArmBody", its occurrence turned and moved; the root is active. fn moved_arm(s: &mut Session) -> (u64, u64) { - let c = run(s, "component.create", json!({"name": "Arm"})); + let c = run(s, "component.create", json!({"name": "Arm", "grounded": false})); let (component, occurrence) = (c["component"].as_u64().unwrap(), c["occurrence"].as_u64().unwrap()); run(s, "solid.box", json!({"corner": [10, 0, 0], "length": 20, "width": 4, "height": 6, "body_name": "ArmBody"})); run(s, "component.activate", json!({"component": 0})); @@ -171,6 +171,9 @@ fn moving_an_unrelated_occurrence_does_not_evaluate_the_mirror_again() { run(&mut s, "solid.box", json!({"length": 8, "width": 8, "height": 8, "corner": [30, 0, 0], "body_name": "B"})); run(&mut s, "component.from_bodies", json!({"bodies": ["A", "B"]})); let occurrences: Vec = s.doc.occurrences.iter().map(|o| o.id).collect(); + for occurrence in &occurrences { + run(&mut s, "occurrence.ground", json!({"occurrence": occurrence, "grounded": false})); + } assert_eq!(occurrences.len(), 2, "{:?}", s.doc.occurrences); let a = s.doc.body_frames(&s.model.state()).into_iter().find(|(name, ..)| name == "A").map(|(_, _, path, _)| path).unwrap(); let (mine, other) = if a.contains(&occurrences[0]) { (occurrences[0], occurrences[1]) } else { (occurrences[1], occurrences[0]) }; @@ -190,7 +193,7 @@ fn moving_an_unrelated_occurrence_does_not_evaluate_the_mirror_again() { #[test] fn mirrored_copies_of_a_placed_component_intersect() { let mut s = Session::default(); - let c = run(&mut s, "component.create", json!({"name": "Pair"})); + let c = run(&mut s, "component.create", json!({"name": "Pair", "grounded": false})); let occurrence = c["occurrence"].as_u64().unwrap(); run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [10, 0, 0], "body_name": "P"})); run(&mut s, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [15, 5, 5], "body_name": "Q"})); diff --git a/crates/engine/src/tests/primitive_points.rs b/crates/engine/src/tests/primitive_points.rs index 55ab180d66..717646aa25 100644 --- a/crates/engine/src/tests/primitive_points.rs +++ b/crates/engine/src/tests/primitive_points.rs @@ -106,7 +106,7 @@ fn point_parameters_rename_and_store() { fn point_expressions_in_a_moved_component() { let mut s = Session::default(); run(&mut s, "parameters.change", json!({"name": "x0", "expression": "10 mm"})); - let c = run(&mut s, "component.create", json!({"name": "Arm"})); + let c = run(&mut s, "component.create", json!({"name": "Arm", "grounded": false})); let (cid, occ) = (c["component"].as_u64().unwrap(), c["occurrence"].as_u64().unwrap()); run(&mut s, "component.activate", json!({"component": 0})); run(&mut s, "occurrence.move", json!({"occurrence": occ, "translate": [0, 50, 0]})); diff --git a/crates/engine/src/world_placement_cache_tests.rs b/crates/engine/src/world_placement_cache_tests.rs index 9bd84a77ec..f1528176f4 100644 --- a/crates/engine/src/world_placement_cache_tests.rs +++ b/crates/engine/src/world_placement_cache_tests.rs @@ -182,7 +182,7 @@ fn adopted_scratch_keeps_its_warm_geometry_and_history_stages_invalidate_it() { #[test] fn moved_component_bodies_are_not_placed_again_when_something_else_changes() { let mut session = Session::default(); - let created = run(&mut session, "component.create", json!({"name":"Wheel"})); + let created = run(&mut session, "component.create", json!({"name":"Wheel","grounded":false})); let occurrence = created["occurrence"].as_u64().unwrap(); run(&mut session, "solid.box", json!({"length":60,"width":60,"height":5,"body_name":"Rim"})); run(&mut session, "component.activate", json!({"component":"root"})); @@ -220,7 +220,7 @@ fn moved_component_bodies_are_not_placed_again_when_something_else_changes() { fn joint_drive_reuses_reference_tessellation_across_rigid_poses() { let mut session = Session::default(); run(&mut session, "solid.cylinder", json!({"radius":3,"height":8,"base":[4,0,0],"body_name":"Crank"})); - run(&mut session, "component.create", json!({"name":"Crank","parent":"root","activate":false})); + run(&mut session, "component.create", json!({"name":"Crank","parent":"root","activate":false,"grounded":false})); run(&mut session, "component.move_bodies", json!({"bodies":["Crank"],"component":"Crank"})); run( &mut session, diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index fac7296ffa..840aafba3b 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -22,14 +22,12 @@ serde_json = { workspace = true } sha2 = { workspace = true } chardetng = { workspace = true } encoding_rs = { workspace = true } +# FreeCAD and Rhino import (pure Rust, so the browser build has it too). +cadmpeg-core = { workspace = true } +cadmpeg-ir = { workspace = true } +cadmpeg-registry = { workspace = true } # A plain fsync where a network share refuses Apple's full flush (src/safe_write.rs); already in # the tree through tempfile. [target.'cfg(target_vendor = "apple")'.dependencies] rustix = { version = "1", default-features = false, features = ["std", "fs"] } - -# FreeCAD and Rhino import. Not in the browser build: it compiles the zstd C library. -[target.'cfg(not(target_arch = "wasm32"))'.dependencies] -cadmpeg-core = { workspace = true } -cadmpeg-ir = { workspace = true } -cadmpeg-registry = { workspace = true } diff --git a/crates/io/src/dxf.rs b/crates/io/src/dxf.rs index df792bd7bd..45ec371279 100644 --- a/crates/io/src/dxf.rs +++ b/crates/io/src/dxf.rs @@ -7,7 +7,7 @@ use std::collections::HashMap; -use solidcraft_geom::Vec2; +use solidcraft_geom::{MAX_BEZIER_DEGREE, Vec2}; use crate::sketch2d::{Geom2, MAX_DRAWING_BYTES, MAX_ENTITIES, bulge_arc}; use crate::{IoError, Result}; @@ -265,7 +265,8 @@ fn convert(ents: &[Ent], k: f64, blocks: &HashMap)>, dep } } "SPLINE" => { - let degree = e.get(71).unwrap_or(3.0).clamp(1.0, 7.0) as u8; + // The degrees a sketch spline takes (a G4 blend's Bézier is degree 9). + let degree = e.get(71).unwrap_or(3.0).clamp(1.0, MAX_BEZIER_DEGREE as f64) as u8; let (fx, fy) = (e.all(11), e.all(21)); let (cx, cy) = (e.all(10), e.all(20)); if fx.len() >= 2 && fx.len() == fy.len() { @@ -473,6 +474,10 @@ mod tests { "0\nSECTION\n2\nENTITIES\n0\nLWPOLYLINE\n90\n99999999\n0\nENDSEC\n0\nEOF\n".into(), "0\nSECTION\n2\nENTITIES\n0\nCIRCLE\n10\nnan\n20\ninf\n40\n-1\n0\nENDSEC\n0\nEOF\n".into(), "0\nSECTION\n2\nENTITIES\n0\nCIRCLE\n10\n1e400\n20\n1e400\n40\n1e400\n0\nENDSEC\n0\nEOF\n".into(), + // SPLINE degrees past any spline's, below 1, and above its control points' count. + "0\nSECTION\n2\nENTITIES\n0\nSPLINE\n71\n255\n10\n0\n20\n0\n10\n1\n20\n1\n0\nENDSEC\n0\nEOF\n".into(), + "0\nSECTION\n2\nENTITIES\n0\nSPLINE\n71\n-5\n10\n0\n20\n0\n10\n1\n20\n1\n0\nENDSEC\n0\nEOF\n".into(), + "0\nSECTION\n2\nENTITIES\n0\nSPLINE\n71\n1e400\n10\n0\n20\n0\n0\nENDSEC\n0\nEOF\n".into(), format!("0\nSECTION\n2\nBLOCKS\n{}0\nENDSEC\n0\nEOF\n", "0\nBLOCK\n2\nb\n0\nENDBLK\n".repeat(300)), "0\nSECTION\n2\nBLOCKS\n0\nBLOCK\n2\na\n1\nINSERT\n2\na\n0\nENDBLK\n0\nENDSEC\n0\nEOF\n".into(), format!("0\nSECTION\n2\nENTITIES\n{}0\nENDSEC\n0\nEOF\n", "0\nLINE\n10\n0\n20\n0\n11\n1\n21\n1\n".repeat(5000)), @@ -489,6 +494,24 @@ mod tests { } } + /// A SPLINE keeps its degree up to the 9 a sketch spline (a G4 blend) has (solidcraft#5269); a + /// degree past that is held to 9, and one that isn't a finite number (which the reader drops) + /// reads as a missing one, 3. + #[test] + fn a_splines_degree_is_read_up_to_nine() { + let spline = |degree: Option<&str>| { + let points: String = (0..10).map(|i| format!("10\n{i}\n20\n{}\n", i % 3)).collect(); + let degree = degree.map(|d| format!("71\n{d}\n")).unwrap_or_default(); + format!("0\nSECTION\n2\nENTITIES\n0\nSPLINE\n{degree}{points}0\nENDSEC\n0\nEOF\n") + }; + for (given, read) in + [(Some("9"), 9), (Some("8"), 8), (Some("7"), 7), (Some("10"), 9), (Some("0"), 1), (Some("nan"), 3), (Some("inf"), 3), (None, 3)] + { + let back = read_dxf(&spline(given)).unwrap(); + assert!(matches!(&back[..], [Geom2::Spline { pts, control: true, degree }] if *degree == read && pts.len() == 10), "{given:?}: {back:?}"); + } + } + #[test] fn round_trip_and_polyline_bulges() { let g = vec![ diff --git a/crates/io/src/dxf_out.rs b/crates/io/src/dxf_out.rs index fff56ea64d..7fa080f1fe 100644 --- a/crates/io/src/dxf_out.rs +++ b/crates/io/src/dxf_out.rs @@ -13,7 +13,7 @@ use std::f64::consts::TAU; use std::fmt::Write as _; -use solidcraft_geom::{Loop2, Seg2, Vec2}; +use solidcraft_geom::{Loop2, MAX_BEZIER_DEGREE, Seg2, Vec2}; /// A layer's linetype, defined in the file's LTYPE table so a viewer needs nothing else. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -112,7 +112,8 @@ pub struct DxfEntity { } /// The entity of one profile segment, exactly: a line, an arc (a circle when it is a whole -/// turn), an elliptical arc for a conic of weight below 1, a spline for a Bézier or any other conic. +/// turn), an elliptical arc for a conic of weight below 1, a spline for any other conic, and for a +/// Bézier a spline of its own degree over all its control points. pub fn seg_shape(s: &Seg2) -> DxfShape { match *s { Seg2::Line { a, b } => DxfShape::Line(a, b), @@ -128,7 +129,7 @@ pub fn seg_shape(s: &Seg2) -> DxfShape { None => bezier_spline(&[a, apex, b], ((w - 1.0).abs() > 1e-12).then(|| vec![1.0, w, 1.0])), }, Seg2::Cubic { p0, p1, p2, p3 } => bezier_spline(&[p0, p1, p2, p3], None), - Seg2::Bezier { n, p } => bezier_spline(p.get(..=(n as usize).clamp(1, 7)).unwrap_or(&p), None), + Seg2::Bezier { n, p } => bezier_spline(p.get(..=(n as usize).clamp(1, MAX_BEZIER_DEGREE)).unwrap_or(&p), None), } } @@ -676,6 +677,26 @@ mod tests { assert!(knots[..6].iter().all(|k| *k == 0.0) && knots[6..].iter().all(|k| *k == 1.0) && ctrl[5] == q[5]); } + #[test] + fn a_bezier_is_a_spline_of_its_own_degree_over_all_its_control_points() { + let mut q = [Vec2::ZERO; solidcraft_geom::MAX_BEZIER_CTRL]; + for (i, s) in q.iter_mut().enumerate() { + *s = Vec2::new(i as f64, (i * i) as f64); + } + // Every degree a Bézier piece can have, up to a G4 blend's 9: n + 1 control points, all of + // them, and 2(n + 1) clamped knots. + for n in 4..=solidcraft_geom::MAX_BEZIER_DEGREE as u8 { + let k = usize::from(n) + 1; + let DxfShape::Spline { degree, knots, ctrl, weights } = seg_shape(&Seg2::Bezier { n, p: q }) else { panic!("degree {n}") }; + assert_eq!((degree, knots.len(), weights), (n, 2 * k, None), "degree {n}"); + assert_eq!(ctrl, q[..k], "degree {n}"); + } + // A degree no piece can have (hostile data) is held to what the piece holds, never past it. + let shape = |n: u8| seg_shape(&Seg2::Bezier { n, p: q }); + assert!(matches!(shape(0), DxfShape::Spline { degree: 1, ref ctrl, .. } if ctrl.len() == 2)); + assert!(matches!(shape(u8::MAX), DxfShape::Spline { degree: 9, ref ctrl, .. } if ctrl[..] == q[..])); + } + #[test] fn drawings_carry_version_units_extents_and_handles() { let ents = vec![ @@ -760,6 +781,17 @@ mod tests { // The cubic: degree and control points. let Geom2::Spline { pts, control: true, degree: 3 } = &back[5] else { panic!("{:?}", back[5]) }; assert!(pts.iter().zip(&cubic).all(|(a, b)| a.dist(*b) < 1e-9) && pts.len() == 4); + // A degree-9 Bézier (a G4 blend) comes back at its own degree over all ten points. + let mut q = [Vec2::ZERO; solidcraft_geom::MAX_BEZIER_CTRL]; + for (i, s) in q.iter_mut().enumerate() { + *s = Vec2::new(i as f64, (i % 3) as f64); + } + let nine = write_dxf_drawing(&[], &[DxfEntity { layer: "0".into(), shape: seg_shape(&Seg2::Bezier { n: 9, p: q }) }]); + let back_nine = read_dxf(&nine).unwrap(); + assert!( + matches!(&back_nine[..], [Geom2::Spline { pts, control: true, degree: 9 }] if pts.len() == 10 && pts.iter().zip(&q).all(|(a, b)| a.dist(*b) < 1e-9)), + "{back_nine:?}" + ); // The numbers in the file itself, which the reader does not keep: the ellipse... let file = entities(&dxf); diff --git a/crates/io/src/formats.rs b/crates/io/src/formats.rs index 114daccec2..a75c5d78c8 100644 --- a/crates/io/src/formats.rs +++ b/crates/io/src/formats.rs @@ -45,7 +45,8 @@ pub struct ImportSpec { /// Options offered at import time; each `param` is a parameter of `doc.open` and of the /// matching `file.insert_*` command. pub options: &'static [OptionSpec], - /// Read through cadmpeg, which is not built for wasm (see `crate::native_cad`). + /// Read through cadmpeg (FreeCAD `.FCStd`, Rhino `.3dm`). Every current host builds it; + /// `native = false` leaves these rows out for a host that cannot. pub native_only: bool, } @@ -452,9 +453,13 @@ mod tests { assert!(for_path("nodot").is_none()); } + /// cadmpeg is pure Rust now, so the browser build offers its rows too; `native = false` + /// stays for a host that cannot build it. #[test] - fn wasm_filtering_hides_the_cadmpeg_rows() { + fn a_host_without_cadmpeg_leaves_its_rows_out() { assert!(importable(true).any(|f| f.id == "fcstd")); + assert!(open_extensions(true).contains(&"fcstd")); + assert!(open_extensions(true).contains(&"3dm")); assert!(importable(false).all(|f| f.id != "fcstd" && f.id != "3dm")); assert!(!open_extensions(false).contains(&"fcstd")); assert!(open_extensions(false).contains(&"igs")); diff --git a/crates/io/src/native_cad.rs b/crates/io/src/native_cad.rs index 97aa26d505..87c521c1ac 100644 --- a/crates/io/src/native_cad.rs +++ b/crates/io/src/native_cad.rs @@ -9,9 +9,6 @@ //! This module is the importer seam: the one place cadmpeg is called, returning STEP text plus //! warnings. A cadmpeg-based STEP reader could be added here later and chosen by a setting, to //! compare with our own STEP reader, without changing that reader. -//! -//! cadmpeg is not built for the browser (it compiles the zstd C library); there these formats -//! return "not supported in the browser yet". /// File extensions read through cadmpeg (lower case). pub const NATIVE_CAD_EXTS: [&str; 2] = ["fcstd", "3dm"]; @@ -23,7 +20,6 @@ pub fn is_native_cad_path(path: &str) -> bool { /// Restate a FreeCAD or Rhino file (`file` is its name) as STEP text, with the losses /// and warnings cadmpeg reported. Panics inside cadmpeg become errors. -#[cfg(not(target_arch = "wasm32"))] pub fn native_cad_to_step(bytes: &[u8], file: &str) -> Result<(String, Vec), String> { let name = std::path::Path::new(file).file_name().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| file.to_string()); solidcraft_kernel::guard("cadmpeg import", || convert::to_step(&name, bytes).map_err(solidcraft_kernel::KernelError::Invalid)).map_err( @@ -34,17 +30,10 @@ pub fn native_cad_to_step(bytes: &[u8], file: &str) -> Result<(String, Vec Result<(String, Vec), String> { - Err(format!("{file}: not supported in the browser yet (FreeCAD and Rhino files open in the desktop app)")) -} - -#[cfg(not(target_arch = "wasm32"))] mod convert { use std::io::Cursor; - use cadmpeg_core::decode::{DecodeArena, DecodeContext, DecodeMode, DecodePolicy, ResourceLimits, View}; + use cadmpeg_core::decode::{DecodeArena, DecodeContext, DecodeDepth, DecodeMode, DecodePolicy, ResourceLimits, View}; use cadmpeg_ir::codec::write::{EncodeInput, target::TargetRequest}; use cadmpeg_ir::codec::{DecodeFailure, DecodeOptions, DecodeResult}; use cadmpeg_ir::validate::validate_neutral_for_decode; @@ -53,7 +42,10 @@ mod convert { /// The codecs we accept (cadmpeg codec ids), whatever else the registry recognises. const ACCEPTED: [&str; 2] = ["fcstd", "rhino"]; - /// Caps for hostile input: cadmpeg's tight `service` profile, with our STEP size limit. + /// Caps for hostile input: cadmpeg's tight `service` profile, with our STEP size limit on + /// the input. The service profile already admits a detailed part's 8 million entities: + /// cadmpeg 0.6.3 dropped the collection-item budget that refused one at a million (#1783) + /// and the work-unit budget that went with it. pub(super) fn policy() -> DecodePolicy { DecodePolicy { mode: DecodeMode::Salvage, @@ -92,8 +84,9 @@ mod convert { return Err(format!("{name}: {format} files are not supported (FreeCAD .FCStd and Rhino .3dm only)")); } - // Decode in memory under the caps. - let options = DecodeOptions { container_only: false, policy }; + // Decode in memory under the caps, as deep as the STEP writer reads: solids and products, + // not the design history, which the import does not keep. + let options = DecodeOptions { depth: DecodeDepth::Geometry, policy }; let decoded = codec.decode(&mut Cursor::new(bytes), &options).map_err(|e| match e { DecodeFailure::Codec(e) => format!("{name}: could not read this {format} file: {e}"), other => format!("{name}: {other}"), @@ -142,7 +135,6 @@ mod convert { /// cadmpeg's STEP writer calls a product it has no name for `cadmpeg_model`; name it after the /// file instead (as plain STEP text: letters, digits, spaces, `-`, `_` and `.` only). -#[cfg(not(target_arch = "wasm32"))] fn name_unnamed_products(step: String, file: &str) -> String { const PLACEHOLDER: &str = "'cadmpeg_model'"; let stem: String = std::path::Path::new(file) diff --git a/crates/io/src/native_cad/tests.rs b/crates/io/src/native_cad/tests.rs index 28bec0ecf5..0d2337a20a 100644 --- a/crates/io/src/native_cad/tests.rs +++ b/crates/io/src/native_cad/tests.rs @@ -101,12 +101,15 @@ fn hostile_native_cad_never_panics() { } /// cadmpeg decodes under its tight `service` caps, with our STEP size limit on the input. +/// cadmpeg 0.6.3 has no work-unit or collection-item budgets, and its service profile already +/// admits the 8 million entities a detailed part needed (#1783). #[test] fn decoding_is_capped() { let limits = convert::policy().limits; let service = cadmpeg_core::decode::ResourceLimits::service(); assert_eq!(limits.max_input_bytes, crate::MAX_STEP_BYTES as u64); - assert_eq!(cadmpeg_core::decode::ResourceLimits { max_input_bytes: service.max_input_bytes, ..limits }, service); + let same = cadmpeg_core::decode::ResourceLimits { max_input_bytes: service.max_input_bytes, ..limits }; + assert_eq!(same, service); } /// A FreeCAD archive whose Document.xml inflates past the per-entry cap (a ZIP bomb, 300 MB of diff --git a/crates/io/src/threemf.rs b/crates/io/src/threemf.rs index ef48cc6fe6..ac4f47de22 100644 --- a/crates/io/src/threemf.rs +++ b/crates/io/src/threemf.rs @@ -10,8 +10,10 @@ //! items may name an object in another model part of the package (`p:path`, the 3MF Production //! extension, which slicers use for multi-part projects); those parts are read too. Only the root //! model's build counts (readers must ignore the others), and a part that can't be read fails -//! only the placements that need it. Packages are untrusted: sizes, entry counts, model parts, -//! mesh sizes and component nesting are capped. +//! only the placements that need it. Bodies are named after their objects; a slicer project whose +//! objects have no name in the model names them in its settings (Bambu Studio's +//! `Metadata/model_settings.config`), and those names are used. Packages are untrusted: sizes, +//! entry counts, model parts, mesh sizes and component nesting are capped. use std::collections::{BTreeMap, HashMap}; use std::fmt::Write as _; @@ -679,6 +681,69 @@ fn in_part(name: &str, e: IoError) -> String { } } +/// Where Bambu Studio keeps a project's per-object settings, among them the objects' names (its +/// root model's objects have none): ``, +/// with `N` the root model object a build item places. The `` elements inside describe the +/// object's volumes. +const SLICER_SETTINGS: &str = "Metadata/model_settings.config"; + +/// The object names in slicer settings `xml` ([`SLICER_SETTINGS`]), by object id. Only an +/// object's own `name` counts (the first, if there are several); its parts' names and all other +/// settings are left alone. +fn slicer_names(xml: &[u8]) -> std::result::Result, String> { + let mut rd = Reader::from_reader(xml); + rd.config_mut().trim_text(true); + let mut buf = Vec::new(); + let mut names = HashMap::new(); + let mut depth = 0usize; + let mut elements = 0usize; + // The object being read: its id (if it has a usable one) and the depth of its element. + let mut object: Option<(Option, usize)> = None; + loop { + let ev = rd.read_event_into(&mut buf).map_err(|e| e.to_string())?; + let start = matches!(ev, Event::Start(_)); + match &ev { + Event::Start(e) | Event::Empty(e) => { + let at = depth + 1; + if start { + depth = at; + if depth > MAX_XML_DEPTH { + return Err("XML nested too deeply".into()); + } + } + elements += 1; + if elements > MAX_ELEMENTS { + return Err("too many elements".into()); + } + match (e.local_name().as_ref(), object) { + (b"object", None) if at == 2 && start => { + object = Some((attrs(e).get("id").and_then(|v| v.trim().parse::().ok()), at)); + } + (b"metadata", Some((Some(id), d))) if at == d + 1 => { + let a = attrs(e); + if a.get("key").is_some_and(|k| k == "name") + && let Some(v) = a.get("value").map(|v| v.trim()).filter(|v| !v.is_empty()) + { + names.entry(id).or_insert_with(|| v.to_string()); + } + } + _ => {} + } + } + Event::End(_) => { + if object.is_some_and(|(_, d)| d == depth) { + object = None; + } + depth = depth.saturating_sub(1); + } + Event::Eof => break, + _ => {} + } + buf.clear(); + } + Ok(names) +} + /// Append object `id` of model part `part` (and its components) placed by `xf` to `out`. fn flatten<'a>( pkg: &'a Package, @@ -840,12 +905,17 @@ pub fn read_3mf(bytes: &[u8]) -> Result<(Vec, Vec)> { let mut out = Vec::new(); let mut warnings = Vec::new(); let mut total = 0usize; + // Bodies of root model objects without a name of their own: (index in `out`, object id). + let mut unnamed = Vec::new(); for (k, item) in model.build.iter().enumerate() { let part = item.part.as_ref().map_or(pkg.root.as_str(), |p| p.key.as_str()); let mut mo = MeshObject::default(); match flatten(&pkg, part, item.id, &item.xf, &mut mo, &mut Vec::new(), &mut total) { Ok(()) if !mo.triangles.is_empty() => { let name = pkg.parts.get(part).and_then(|m| m.objects.get(&item.id)).map(|o| o.name.trim().to_string()).filter(|n| !n.is_empty()); + if name.is_none() && part == pkg.root { + unnamed.push((out.len(), item.id)); + } mo.name = name.unwrap_or_else(|| format!("Mesh{}", k + 1)); out.push(mo); } @@ -853,6 +923,28 @@ pub fn read_3mf(bytes: &[u8]) -> Result<(Vec, Vec)> { Err(e) => warnings.push(format!("build item {}: {e}", k + 1)), } } + // A slicer project names its objects in its settings (#3858). Those are read only when a body + // needs a name, within what the model parts left of the budget, and settings that can't be + // read only leave the generic names. + if !unnamed.is_empty() + && let Some(&idx) = entries.get(&part_key(SLICER_SETTINGS)) + { + let names = match read_entry(&mut zip, idx, SLICER_SETTINGS, MAX_PART.saturating_sub(model_bytes)) { + Ok(Some(xml)) => slicer_names(&xml), + Ok(None) => Err("it expands past what the model parts left of the 1 GB budget".into()), + Err(e) => Err(e.to_string()), + }; + match names { + Ok(names) => { + for (i, id) in unnamed { + if let (Some(o), Some(name)) = (out.get_mut(i), names.get(&id)) { + o.name.clone_from(name); + } + } + } + Err(e) => warnings.push(format!("object names in `{SLICER_SETTINGS}` not read: {e}")), + } + } if out.is_empty() { return bad(warnings.first().cloned().unwrap_or_else(|| "no meshes to build".into())); } @@ -1088,9 +1180,9 @@ pub(crate) mod tests { /// A package shaped like a slicer project (Bambu Studio, OrcaSlicer): object UUIDs, printable /// build items, thumbnails, slicer configs, and the meshes in a part of their own with an empty - /// build (T3). Each object on the plate is one body with all its volumes. The slicer's config - /// (names, which volumes are modifiers) isn't read, so the bodies are named `MeshN` and the - /// modifier comes in as solid. + /// build (T3). Each object on the plate is one body with all its volumes, named as in the + /// slicer's config (#3858). The rest of the config (which volumes are modifiers) isn't read, so + /// the modifier comes in as solid. #[test] fn slicer_project_package() { let ns = r#"xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02" xmlns:BambuStudio="http://schemas.bambulab.com/package/2021" xmlns:p="http://schemas.microsoft.com/3dmanufacturing/production/2015/06" requiredextensions="p""#; @@ -1164,13 +1256,78 @@ pub(crate) mod tests { let (objs, warnings) = read_3mf(&package).unwrap(); assert!(warnings.is_empty(), "{warnings:?}"); let names: Vec<&str> = objs.iter().map(|o| o.name.as_str()).collect(); - assert_eq!(names, ["Mesh1", "Mesh2"]); + assert_eq!(names, ["Cube", "Bracket"]); assert_eq!((objs[0].triangles.len(), objs[1].triangles.len()), (4, 8)); assert!((volume(&objs[0]) - 1000.0 / 6.0).abs() < 1e-6, "{}", volume(&objs[0])); assert!((volume(&objs[1]) - (8000.0 + 64.0) / 6.0).abs() < 1e-6, "{}", volume(&objs[1])); assert!(objs[0].positions.iter().all(|p| p.x >= 128.0 - 1e-9 && p.y >= 128.0 - 1e-9)); } + /// The body names and warnings of a package with slicer settings `settings` (or none): root + /// objects 2 and 6 without a name and 4 named `Own`, each placing the tetrahedron of the + /// objects part, one build item each, and a fourth build item placing that tetrahedron itself. + fn named_by_settings(settings: Option<&str>) -> (Vec, Vec) { + let path = "/3D/Objects/object_1.model"; + let own = assembly(4, &[(path, 1)]).replace(r#"type="model""#, r#"type="model" name="Own""#); + let root = root_model( + &format!("{}{own}{}", assembly(2, &[(path, 1)]), assembly(6, &[(path, 1)])), + &format!(r#""#), + ); + let part = model_part("millimeter", &tet_object(1, "", 1.0)); + let mut parts = vec![("3D/3dmodel.model", root.as_str()), (OBJECTS_PART, part.as_str())]; + parts.extend(settings.map(|s| (SLICER_SETTINGS, s))); + let (objs, warnings) = read_3mf(&package_parts(&parts)).unwrap(); + (objs.into_iter().map(|o| o.name).collect(), warnings) + } + + /// Slicer settings name the root objects that have no name of their own, by object id, with + /// the object's first name, trimmed (#3858). A name in the model comes first; the names of + /// an object's parts, settings outside an object, objects the build doesn't place and objects + /// of other model parts change nothing. + #[test] + fn slicer_settings_name_unnamed_objects() { + let settings = r#" + + + + + + + + + +"#; + let (names, warnings) = named_by_settings(Some(settings)); + assert!(warnings.is_empty(), "{warnings:?}"); + assert_eq!(names, ["Left wall", "Own", "Mesh3", "Mesh4"]); + // Without settings, the generic names as before. + assert_eq!(named_by_settings(None), (vec!["Mesh1".into(), "Own".into(), "Mesh3".into(), "Mesh4".into()], Vec::new())); + } + + /// Settings that can't be read never fail the import: the bodies keep the generic names, with + /// a warning saying why. They are read only when a body needs a name, so a package whose + /// objects all have one opens without a word about them. + #[test] + fn broken_slicer_settings_only_warn() { + let generic: Vec = ["Mesh1", "Own", "Mesh3", "Mesh4"].map(String::from).into(); + let deep = format!("{}", "".repeat(MAX_XML_DEPTH + 1)); + for (settings, why) in + [(r#""#, "config"), (deep.as_str(), "nested too deeply")] + { + let (names, warnings) = named_by_settings(Some(settings)); + assert_eq!(names, generic); + assert!( + warnings.len() == 1 + && warnings[0].starts_with("object names in `Metadata/model_settings.config` not read: ") + && warnings[0].contains(why), + "{warnings:?}" + ); + } + let [(r, root), (o, objects)] = production_parts(); + let (objs, warnings) = read_3mf(&package_parts(&[(r, &root), (o, &objects), (SLICER_SETTINGS, "")])).unwrap(); + assert!(warnings.is_empty() && objs[0].name == "Pair", "{warnings:?}"); + } + /// Placements in every part draw on one budget (T4): three parts fanning out 300 ways each /// (27 million placements of an empty object) stop at it, and only the build item that went /// over fails. diff --git a/crates/kernel/src/body.rs b/crates/kernel/src/body.rs index 81887545fe..9ea0627e74 100644 --- a/crates/kernel/src/body.rs +++ b/crates/kernel/src/body.rs @@ -562,6 +562,36 @@ pub(crate) fn shell_volume(shell: &mt::Shell) -> Option { v.is_finite().then_some(v) } +/// A solid's volume off a mesh at `tol`, negative when it faces inwards, and how many faces that +/// mesh left out. +fn signed_volume(solid: &Solid, tol: f64) -> Result<(f64, usize)> { + let orient = |tol: f64| { + guard("orient", || { + let shells = mesh_shells(solid, tol); + let volume = shells.iter().map(|s| s.to_polygon().volume()).sum::(); + let missing = + shells.iter().flat_map(|s| s.face_iter()).filter(|f| f.surface().is_none_or(|m| m.faces().triangle_iter().next().is_none())).count(); + Ok((volume, missing)) + }) + }; + let (mut v, missing) = orient(tol)?; + // A long thin round part (a through hole's tool: a cylinder whose vertices all lie on + // its seam, so they span its length only) meshed that coarsely can come out with a + // negative volume when it is outward already: check finely before turning it inside out. + let fine = (tol * 0.02).max(1e-4); + if v < 0.0 && fine < tol { + (v, _) = orient(fine)?; + } + Ok((v, missing)) +} + +/// Whether a closed shell faces inwards (a void), by the check [`Body::new`] orients a solid by. +/// Unlike [`shell_volume`], a negative coarse volume is checked again on a finer mesh. +pub(crate) fn faces_inward(shell: &mt::Shell) -> Result { + let solid = guard("shell", || Solid::try_new(vec![shell.clone()]).map_err(|e| KernelError::Failed(format!("shell: {e}"))))?; + Ok(signed_volume(&solid, orientation_tolerance(span_of(&solid)))?.0 < 0.0) +} + /// Deflection the orientation mesh uses: coarse enough for a large solid, fine enough that a /// small one still tessellates. `unmeshed_faces` counts at the same value, so the count the /// orientation mesh produced is reused instead of meshing the body again. @@ -616,30 +646,11 @@ impl Body { // every face comes out: a face missing from a coarse mesh can flip the sign). let span = span_of(&solid); let tol = orientation_tolerance(span); - let orient = |tol: f64| { - guard("orient", || { - let shells = mesh_shells(&solid, tol); - let volume = shells.iter().map(|s| s.to_polygon().volume()).sum::(); - let missing = shells - .iter() - .flat_map(|s| s.face_iter()) - .filter(|f| f.surface().is_none_or(|m| m.faces().triangle_iter().next().is_none())) - .count(); - Ok((volume, missing)) - }) - }; #[cfg(test)] ORIENTED.with(|n| n.set(n.get() + 1)); // The faces missing from the coarse mesh are the same whichever way the solid turns out // to face, so the count is kept for `unmeshed_faces` either way. - let (mut v, missing) = orient(tol)?; - // A long thin round part (a through hole's tool: a cylinder whose vertices all lie on - // its seam, so they span its length only) meshed that coarsely can come out with a - // negative volume when it is outward already: check finely before turning it inside out. - let fine = (tol * 0.02).max(1e-4); - if v < 0.0 && fine < tol { - (v, _) = orient(fine)?; - } + let (v, missing) = signed_volume(&solid, tol)?; if v < 0.0 { solid.not(); } diff --git a/crates/kernel/src/build.rs b/crates/kernel/src/build.rs index 994a73ef86..6f3ce61d91 100644 --- a/crates/kernel/src/build.rs +++ b/crates/kernel/src/build.rs @@ -434,40 +434,9 @@ fn revolved(plane: &Plane, regions: &[Region2], axis_origin: Vec2, axis_dir: Vec }; let given = numbered_region(r)?.map_loops(|l| reseam(snap_loop(l).merged_lines(axis_run))); let r = &given.region(); - // A half disc on the axis revolved fully is a sphere: build it without poles. Every - // patch is swept from the half disc's arcs. if angle.abs() >= std::f64::consts::TAU - 1e-9 && r.holes.is_empty() - && let Some((c, rad)) = half_disc(&r.outer, axis_origin, d) - { - let arcs: Vec = r - .outer - .segs - .iter() - .enumerate() - .filter(|(_, s)| matches!(s, Seg2::Arc { .. })) - .flat_map(|(i, _)| given.outer.numbers(i).to_vec()) - .collect(); - out.push(sphere_solid(plane.to_world(c), rad, [Vec3::X, Vec3::Y, Vec3::Z], &arcs)?); - continue; - } - // A full turn of a profile that comes back to the axis along a curve (a half slot about - // its centreline, a dome at the far end): truck's cone gives the faces - // swept from it the wrong region (their pole is at the end of their curve), and cuts with - // such a body failed (#1853). It is built as part turns are, a third of a turn at a time. - if angle.abs() >= std::f64::consts::TAU - 1e-9 - && r.holes.is_empty() - && pole_at_end(&r.outer, axis_origin, d) - && let Some(b) = revolve_on_axis(plane, &given.outer, axis_origin, d, angle)? - { - out.push(b); - continue; - } - // A full revolution of a profile with one edge on the axis: sweep the rest of the loop - // as a cone-like shell (no zero-area faces, which booleans can't handle). - if angle.abs() >= std::f64::consts::TAU - 1e-9 - && r.holes.is_empty() - && let Some(b) = revolve_touching_axis(plane, &given.outer, axis_origin, d)? + && let Some(b) = full_turn_on_axis(plane, &given.outer, axis_origin, d, angle)? { out.push(b); continue; @@ -1407,6 +1376,67 @@ fn half_disc(lp: &Loop2, axis_origin: Vec2, d: Vec2) -> Option<(Vec2, f64)> { (same && (total - std::f64::consts::PI).abs() < 1e-6).then_some((c, r)) } +/// A full turn of a loop with edges on the axis, built without the zero-area faces truck sweeps +/// from them (booleans can't handle those). `None` when no builder here takes the loop. +fn full_turn_on_axis(plane: &Plane, nl: &NumberedLoop, axis_origin: Vec2, d: Vec2, angle: f64) -> Result> { + let lp = &nl.lp; + // A half disc on the axis revolved fully is a sphere: build it without poles. Every + // patch is swept from the half disc's arcs. + if let Some((c, rad)) = half_disc(lp, axis_origin, d) { + let arcs: Vec = + lp.segs.iter().enumerate().filter(|(_, s)| matches!(s, Seg2::Arc { .. })).flat_map(|(i, _)| nl.numbers(i).to_vec()).collect(); + return sphere_solid(plane.to_world(c), rad, [Vec3::X, Vec3::Y, Vec3::Z], &arcs).map(Some); + } + // A full turn of a profile that comes back to the axis along a curve (a half slot about + // its centreline, a dome at the far end): truck's cone gives the faces + // swept from it the wrong region (their pole is at the end of their curve), and cuts with + // such a body failed (#1853). It is built as part turns are, a third of a turn at a time. + if pole_at_end(lp, axis_origin, d) + && let Some(b) = revolve_on_axis(plane, nl, axis_origin, d, angle)? + { + return Ok(Some(b)); + } + // A full revolution of a profile with one edge on the axis: sweep the rest of the loop + // as a cone-like shell (no zero-area faces, which booleans can't handle). + if let Some(b) = revolve_touching_axis(plane, nl, axis_origin, d)? { + return Ok(Some(b)); + } + // Several edges on the axis (a spherical shell's two radial lines): each run of the loop + // from the axis back to it, closed along the axis, turns into a shell of its own, a solid + // where it runs the way the loop does and a void where it runs the other way. Swept whole, + // the edges on the axis became zero-area faces joining the shells, and a cut through a pole + // there failed (#451). + let on_axis = |s: &Seg2| matches!(*s, Seg2::Line { a, b } if d.cross(a - axis_origin).abs() < 1e-9 && d.cross(b - axis_origin).abs() < 1e-9); + let n = lp.segs.len(); + let axis_segs: Vec = (0..n).filter(|i| lp.segs.get(*i).is_some_and(on_axis)).collect(); + let [k0, _, ..] = axis_segs[..] else { return Ok(None) }; + let mut parts = Vec::new(); + for (i, &k) in axis_segs.iter().enumerate() { + let run: Vec = (k + 1..axis_segs.get(i + 1).copied().unwrap_or(k0 + n)).map(|j| j % n).collect(); + let mut segs: Vec = run.iter().filter_map(|j| lp.segs.get(*j).copied()).collect(); + let (Some(first), Some(last)) = (segs.first(), segs.last()) else { return Ok(None) }; + segs.push(Seg2::Line { a: last.end(), b: first.start() }); + let sub = NumberedLoop { lp: Loop2 { segs }, from: run.iter().map(|j| nl.numbers(*j).to_vec()).chain([Vec::new()]).collect() }; + let void = sub.lp.is_ccw() != lp.is_ccw(); + let Some(b) = full_turn_on_axis(plane, &if void { sub.reversed() } else { sub }, axis_origin, d, angle)? else { return Ok(None) }; + parts.push((b, void)); + } + let faces: Option> = + parts.iter().map(|(b, _)| b.history().map(|h| h.faces.clone())).collect::>>().map(|f| f.concat()); + let solid = guard("revolve", || { + let mut shells = Vec::new(); + for (b, void) in &parts { + let mut s = b.solid.as_ref().clone(); + if *void { + s.not(); + } + shells.extend(s.boundaries().iter().cloned()); + } + Solid::try_new(shells).map_err(|e| KernelError::Failed(format!("revolve: {e}"))) + })?; + record::attach(Body::new(solid)?, &[], faces, Default::default()).map(Some) +} + /// Whether the loop has one edge on the axis and comes back to it along a curve (the segment /// before the axis edge is not a line). A line there (a drill point) sweeps a cone, which /// truck's cone builds right. diff --git a/crates/kernel/src/face_source_tests.rs b/crates/kernel/src/face_source_tests.rs index 506b31086f..59a1dd834f 100644 --- a/crates/kernel/src/face_source_tests.rs +++ b/crates/kernel/src/face_source_tests.rs @@ -309,3 +309,36 @@ fn constructed_large_arc_split_and_reversal_preserve_one_source_segment() { } } } + +/// A certified block cut by, or joined with, a spherical shell (r 18.5 to 40) through both its +/// spheres: booleans with a tool that has a void (#451) keep the sources of the block's ends, +/// which they leave as they were. +#[test] +fn hollow_tool_booleans_keep_planar_support_certificates() { + use std::f64::consts::{PI, TAU}; + let ring = Region2 { + outer: Loop2 { + segs: vec![ + Seg2::Line { a: Vec2::new(18.5, 0.), b: Vec2::new(40., 0.) }, + Seg2::Arc { center: Vec2::ZERO, radius: 40., start: 0., sweep: PI }, + Seg2::Line { a: Vec2::new(-40., 0.), b: Vec2::new(-18.5, 0.) }, + Seg2::Arc { center: Vec2::ZERO, radius: 18.5, start: PI, sweep: -PI }, + ], + }, + holes: vec![], + }; + let shell = crate::revolve(&Plane::XY, &[ring], Vec2::ZERO, Vec2::X, TAU).unwrap().remove(0); + assert_eq!(shell.shell_count(), 2, "a solid with a void"); + let block = crate::box_solid(Vec3::new(8., -4., -4.), Vec3::new(50., 4., 4.)).unwrap().with_box_certificate(42).unwrap(); + for op in [crate::BoolOp::Cut, crate::BoolOp::Union] { + let r = crate::boolean(&block, &shell, op).unwrap().unwrap(); + let ends: Vec = (0..r.face_count()) + .filter(|i| { + matches!(r.face_sources().and_then(|s| s.get(*i)).and_then(Option::as_ref), Some(FaceSource::Box { producer: Some(42), axis: 0, .. })) + }) + .map(|i| r.planar_face(i).unwrap().origin.x) + .collect(); + assert!([8., 50.].iter().all(|x| ends.iter().any(|e| (e - x).abs() < 1e-9)), "{op:?}: {ends:?}"); + assert_exact_planes(&r); + } +} diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs index 49df105d78..37891c4ca2 100644 --- a/crates/kernel/src/ops.rs +++ b/crates/kernel/src/ops.rs @@ -921,6 +921,9 @@ pub fn boolean(a: &Body, b: &Body, op: BoolOp) -> Result> { } fn boolean_untimed(a: &Body, b: &Body, op: BoolOp) -> Result> { + if let Some(r) = with_hollow_tool(a, b, op) { + return finished(a, b, op, Ok(r)); + } // Earlier coarse bores can leave a junction a later pattern copy cannot intersect. // Resolve conical-cut junctions from the first copy, while the operands are unchanged. if op == BoolOp::Cut && !APART.with(|c| c.get()) && conical_cut_junction(a, b) { @@ -1008,14 +1011,7 @@ fn boolean_via(a: &Body, b: &Body, op: BoolOp, whole: &dyn Fn(&Body, &Body, Bool )), }; } - r = r.map(|result| result.map(|body| body.inherit_regularization_protection(a).inherit_regularization_protection(b))); - match op { - BoolOp::Cut => r = r.and_then(|result| result.map(|body| body.with_preserved_boolean_planar_sources(a, b, true)).transpose()), - BoolOp::Union => r = r.and_then(|result| result.map(|body| body.with_preserved_union_planar_sources(a, b)).transpose()), - // Every face of an intersection lies on one operand's face, as a union's does: the same - // roles carry over (#565). - BoolOp::Intersect => r = r.and_then(|result| result.map(|body| body.with_preserved_union_planar_sources(a, b)).transpose()), - } + r = finished(a, b, op, r); // A retry's result is held to what the operation can do at all; one that fails is refused // rather than returned wrong (issue #616). if (!diagnostics.events.is_empty() || diagnostics.truncated) @@ -1034,6 +1030,19 @@ fn boolean_via(a: &Body, b: &Body, op: BoolOp, whole: &dyn Fn(&Body, &Body, Bool r } +/// What a boolean's result keeps of its operands: their regularization protection, and the +/// sources of the planar faces it keeps. +fn finished(a: &Body, b: &Body, op: BoolOp, r: Result>) -> Result> { + let r = r.map(|result| result.map(|body| body.inherit_regularization_protection(a).inherit_regularization_protection(b))); + match op { + BoolOp::Cut => r.and_then(|result| result.map(|body| body.with_preserved_boolean_planar_sources(a, b, true)).transpose()), + BoolOp::Union => r.and_then(|result| result.map(|body| body.with_preserved_union_planar_sources(a, b)).transpose()), + // Every face of an intersection lies on one operand's face, as a union's does: the same + // roles carry over (#565). + BoolOp::Intersect => r.and_then(|result| result.map(|body| body.with_preserved_union_planar_sources(a, b)).transpose()), + } +} + /// The most shells [`cut_around_voids`] takes apart. const MAX_VOID_SHELLS: usize = 64; @@ -1100,6 +1109,81 @@ fn cut_around_voids(a: &Body, b: &Body, whole: &dyn Fn(&Body, &Body, BoolOp) -> Ok(Some(guard("voids back", || Body::new(Solid::try_new(all).map_err(|e| KernelError::Failed(format!("voids back: {e}")))?))?)) } +/// A cut or union with `b` when `b` is one outer shell with sealed voids, from booleans with the +/// outer shell and with the solids the voids are: `a` cut by `b` is `a` cut by the outer shell +/// beside `a` within each void, and `a` joined with `b` is `a` joined with the outer shell, with +/// what of each void `a` does not fill as a void. truck takes a tool's shells one at a time, each +/// as if it were a body of its own, which is neither: such cuts came out empty, or kept only what +/// lies in the void (a block crossing both spheres of a spherical shell, #451). Planar tools are +/// left to the exact paths. `None` for other operations and tools, a void not inside the outer +/// shell, and a result that fails the membership samples the general way's results are held to +/// (a piece that failed or came out empty): the bodies then go the general way. +fn with_hollow_tool(a: &Body, b: &Body, op: BoolOp) -> Option> { + let shells = b.solid.boundaries(); + if op == BoolOp::Intersect || b.is_mesh() || shells.len() < 2 || shells.len() > MAX_VOID_SHELLS || is_planar(b) { + return None; + } + let (mut outer, mut voids) = (None, Vec::new()); + for sh in shells { + if crate::body::faces_inward(sh).ok()? { + voids.push(sh.clone()); + } else if outer.replace(sh.clone()).is_some() { + return None; + } + } + // Each shell a body of its own, a void turned outwards: the solid it holds. + let parts: Vec = std::iter::once(outer?) + .chain(voids) + .map(|sh| guard("hollow tool", || Body::new(Solid::try_new(vec![sh]).map_err(|e| KernelError::Failed(format!("hollow tool: {e}")))?))) + .collect::>() + .ok()?; + let t = a.size().max(b.size()) * 5e-4; + let (shell, holes) = parts.split_first()?; + let mesh = shell.tessellate(t).ok()?; + let inside = mesh.inside_index(); + if !holes.iter().all(|h| h.solid.vertex_iter().all(|v| inside.contains(from_p3(v.point())))) { + return None; + } + let r = by_outer_and_voids(a, b, parts, op).ok()?; + let mr = match &r { + Some(r) => r.tessellate_with(t, true).ok()?, + None => solidcraft_geom::Mesh::default(), + }; + mismatch(&a.tessellate_with(t, true).ok()?, &b.tessellate_with(t, true).ok()?, &mr, op).is_some_and(|f| f < 0.004).then_some(r) +} + +/// [`with_hollow_tool`]'s booleans, with `b` as its outer shell's solid, then its voids'. +fn by_outer_and_voids(a: &Body, b: &Body, parts: Vec, op: BoolOp) -> Result> { + let counts = record::counts(&[a, b]); + let traced: Vec = recorded::parts_of(&parts, b).into_iter().zip(parts).map(|(h, p)| (p, recorded::placed(h, 1, 2))).collect(); + let pair = |(x, hx): &Traced, (y, hy): &Traced, op: BoolOp| -> Result> { + Ok(boolean(x, y, op)?.map(|r| { + let h = recorded::of_pair(&r, hx, hy); + (r, h) + })) + }; + let a = (a.clone(), recorded::kept(a, 0, 2)); + let Some((shell, voids)) = traced.split_first() else { return Ok(None) }; + if op == BoolOp::Cut { + let mut pieces: Vec = pair(&a, shell, BoolOp::Cut)?.into_iter().collect(); + for v in voids { + pieces.extend(pair(&a, v, BoolOp::Intersect)?); + } + return recorded::join(pieces, &counts); + } + let Some(mut joined) = pair(&a, shell, BoolOp::Union)? else { return Ok(None) }; + // What of a void `a` leaves empty lies inside the outer shell, clear of the outside: a void again. + for v in voids { + if let Some((left, hl)) = pair(v, &a, BoolOp::Cut)? + && let Some(r) = void(&joined.0, &left, &record::counts(&[&joined.0, &left]))? + { + let h = recorded::of_pair(&r, &joined.1, &hl); + joined = (r, h); + } + } + Ok(Some(joined.0.with_checked_history(joined.1, &counts))) +} + /// Every face of an all-planar union must remain on an operand's original plane. /// Curved operands keep their existing surface-specific boolean handling. fn preserves_planar_surfaces(a: &Body, b: &Body, result: &Body) -> bool { diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index 585a2b0904..1e168ca41a 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -2,15 +2,18 @@ use std::io::{BufRead, BufReader, Read, Write}; use std::net::{TcpStream, ToSocketAddrs}; -use std::time::Duration; +use std::time::{Duration, Instant}; use serde_json::{Value, json}; use solidcraft_engine::Session; +use solidcraft_engine::control_pending; use solidcraft_engine::render::StandardView; use solidcraft_engine::view::Guides; /// Something that answers control-channel methods: `engine.execute`, `engine.commands`, -/// `document.inspect`, `ui.view`, `ui.render`, `ui.screenshot`, `control.running`. +/// `document.inspect`, `ui.view`, `ui.render`, `ui.screenshot`, `control.running`, and +/// `control.pending` (the result of a request that was still running when its call returned: +/// `{pending: what | null, done?, reply?, lost?, running_s?}`). pub trait Backend { fn call(&mut self, method: &str, params: Value) -> Result; /// True when a real window is attached (`ui.screenshot` can capture it). @@ -34,8 +37,79 @@ const MAX_REPLY: u64 = 64 << 20; /// How long a reply may take: kernel operations (booleans, fillets) on big parts can take a while. const READ_TIMEOUT: Duration = Duration::from_secs(300); -/// How long the app may take to run an `engine.batch` (its control server waits as long). -const BATCH_TIMEOUT: Duration = Duration::from_secs(3600); +/// How long the app may take to run an `engine.batch`: a little longer than its control server +/// waits (3600 s), so its "still running" reply arrives first. +const BATCH_TIMEOUT: Duration = Duration::from_secs(3720); + +/// 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; #1618), before it says so itself. +const PENDING_WAIT: Duration = Duration::from_secs(240); + +/// How long a later call, or the `pending` tool, waits for a request still running from an +/// earlier call. +const SETTLE_WAIT: Duration = Duration::from_secs(60); + +/// How often `control.pending` is asked while waiting. +const POLL_EVERY: Duration = Duration::from_millis(500); + +/// How often a long wait is reported on stderr. +const PROGRESS_EVERY: Duration = Duration::from_secs(30); + +/// How a tool 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; the `pending` tool gets it. +/// `batch` uses it to tell "still running" from "failed". +pub(crate) const STILL_RUNNING: &str = "still running:"; + +/// A request the app said is still running (#1618). 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 bridge did not send). + effect: Option, + 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 the `pending` tool. +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, +} + +/// How long to wait for a request still running (tests shorten them). +#[derive(Clone, Copy)] +pub(crate) struct Waits { + pub pending: Duration, + pub settle: Duration, + pub poll: Duration, +} + +const WAITS: Waits = Waits { pending: PENDING_WAIT, settle: SETTLE_WAIT, poll: POLL_EVERY }; + +/// A control reply as a call's result. +fn outcome(v: &Value) -> Result { + 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 running SolidCraft app reached through its loopback control port /// (`solidcraft --control PORT`). @@ -47,6 +121,11 @@ pub struct Remote { /// `--token`, when given. Otherwise each request looks the token up again, since every /// launch of the app makes a new one (docs/control-protocol.md). explicit_token: Option, + /// A request still running in the app; its reply waits on `conn`. + outstanding: Option, + /// An outstanding request's end, once a later call waited for it (for `pending`). + settled: Option, + waits: Waits, } /// How a tool error starts when no app listens at the bridged address. The agent reads it, and @@ -72,7 +151,21 @@ impl Remote { pub fn new(addr: &str, token: Option<&str>) -> Self { let port = addr.rsplit(':').next().and_then(|p| p.parse::().ok()).unwrap_or(0); let explicit_token = token.map(str::to_string); - Self { addr: addr.to_string(), port, conn: None, next_id: 1, explicit_token } + Self { addr: addr.to_string(), port, conn: None, next_id: 1, explicit_token, outstanding: None, settled: None, waits: WAITS } + } + + /// Shorter waits for requests still running (tests). + #[cfg(test)] + pub(crate) fn set_waits(&mut self, waits: Waits) { + self.waits = waits; + } + + /// Close the connection. A request still running loses its reply with it. + fn drop_conn(&mut self) { + self.conn = None; + if let Some(o) = self.outstanding.take() { + self.settled = Some(Settled::Lost { what: o.what }); + } } /// Connect now, if the app is there (`new` does not); the error says why not. @@ -83,7 +176,7 @@ impl Remote { /// Failing to reach the app at all is `NotConnected`, so [`Remote::explain`] can tell it from /// a connection lost during a call. fn reconnect(&mut self) -> std::io::Result<()> { - self.conn = None; + self.drop_conn(); 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)? { @@ -125,7 +218,7 @@ impl Remote { // 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.conn = None; + self.drop_conn(); return Err(e); } let mut reply = String::new(); @@ -133,12 +226,12 @@ impl Remote { Ok(n) => n, Err(e) => { // A timed-out read leaves a late reply on the stream: start afresh next time. - self.conn = None; + self.drop_conn(); return Err(e); } }; if n == 0 || !reply.ends_with('\n') { - self.conn = None; + self.drop_conn(); return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "connection closed")); } Ok(reply) @@ -162,8 +255,9 @@ impl Remote { } } -impl Backend for Remote { - fn call(&mut self, method: &str, params: Value) -> Result { +impl Remote { + /// The request line for `method`: a fresh id, the agent's folders and the app's token. + fn request_line(&mut self, method: &str, params: Value) -> String { let id = self.next_id; self.next_id += 1; let mut params = params; @@ -176,7 +270,121 @@ impl Backend for Remote { if let Some(t) = solidcraft_engine::control_auth::client_token(self.port, self.explicit_token.as_deref()) { msg["token"] = json!(t); } - let line = msg.to_string(); + msg.to_string() + } + + /// Ask the app, on this connection only, about the request it still runs for it. + fn ask_pending(&mut self) -> Result { + if self.conn.is_none() { + self.drop_conn(); + return Err("the connection to the app was lost".into()); + } + let line = self.request_line("control.pending", json!({})); + let reply = self.roundtrip(&line).map_err(|e| self.explain(&e))?; + let v: Value = serde_json::from_str(&reply).map_err(|e| format!("bad reply from the app: {e}"))?; + outcome(&v).inspect_err(|_| self.drop_conn()) + } + + /// Wait up to `limit` for the outstanding request, asking `control.pending` now and then and + /// saying on stderr how long it takes. An error: the connection, and so the reply, was lost. + fn wait_outstanding(&mut self, limit: Duration) -> Result { + 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); + eprintln!("solidcraft-mcp: {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 tool error for the outstanding request still running after its wait. + fn still_running(&self, earlier: bool) -> String { + let Some(o) = &self.outstanding else { return format!("{STILL_RUNNING} a request is still running in the app") }; + let effect = o.effect.map_or("it was not cancelled and may still change the design when it finishes", control_pending::Effect::when_done); + let settle = self.waits.settle.as_secs(); + 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<(), String> { + 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 bridge: the end of the request still running from an earlier + /// call, waiting for it up to the settle wait. Never sent on a fresh connection, which has + /// nothing pending. + fn pending_status(&mut self) -> Result { + if let Some(s) = self.settled.take() { + return Ok(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 Ok(json!({"pending": null})) }; + match self.wait_outstanding(self.waits.settle) { + Ok(Waited::Done(reply)) => Ok(json!({"pending": what, "done": true, "reply": reply})), + Ok(Waited::Nothing) => Ok(json!({"pending": null})), + Ok(Waited::Running) => Ok(json!({"pending": what, "done": false, "running_s": self.outstanding.as_ref().map_or(0, Outstanding::secs)})), + Err(_) => { + self.settled = None; + Ok(json!({"pending": what, "lost": true})) + } + } + } + + fn call_once(&mut self, method: &str, params: Value, retry_busy: bool) -> Result { + // The app answers `control.running` at once, even behind a request still running: it is + // not held back for that one as other calls are. + if method != "control.running" { + self.settle_earlier()?; + } + let what = control_pending::describe(method, ¶ms); + let effect = control_pending::effect(method, ¶ms); + let again = retry_busy.then(|| params.clone()); + let sent = Instant::now(); + let line = self.request_line(method, params); let reply = if method == "engine.batch" { self.send_batch(&line) } else { @@ -189,11 +397,41 @@ impl Backend for Remote { } .map_err(|e| self.explain(&e))?; let v: Value = serde_json::from_str(&reply).map_err(|e| format!("bad reply from the app: {e}"))?; - 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()) + // Timed out in the app, still running (#1618): 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 }); + eprintln!( + "solidcraft-mcp: {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() + ); + return match self.wait_outstanding(self.waits.pending)? { + Waited::Done(reply) => outcome(&reply), + Waited::Nothing => { + Err(format!("{what} timed out in the app, which kept no result for it: inspect the design before sending it again")) + } + Waited::Running => Err(self.still_running(false)), + }; } + // Refused unrun behind a request this bridge 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.call_once(method, params, false); + } + outcome(&v) + } +} + +impl Backend for Remote { + fn call(&mut self, method: &str, params: Value) -> Result { + if method == "control.pending" { + return self.pending_status(); + } + self.call_once(method, params, true) } fn has_ui(&self) -> bool { true @@ -301,8 +539,10 @@ impl Backend for Headless { })) } "ui.screenshot" => Err("no window in headless mode; use ui.render".into()), - // Calls run here one at a time: none is running while this one is asked. + // Calls run here one at a time: none is running while this one is asked, and nothing + // is ever left running. "control.running" => Ok(json!({"running": []})), + "control.pending" => Ok(json!({"pending": null})), other => Err(format!("unknown method `{other}`")), } } diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs index adfe1567a6..a40c00f72f 100644 --- a/crates/mcp/src/server.rs +++ b/crates/mcp/src/server.rs @@ -31,8 +31,9 @@ and degrees in expressions (internally radians), Z is up. Model like this: `sket and dimension (`sketch.dimension {entities, value}`; values may name parameters), `sketch.finish`, then features \ (`solid.extrude {distance, operation?: new|join|cut|intersect}`, `solid.revolve`, `solid.fillet {edges: [[x,y,z] point on \ edge], radius}`, `solid.chamfer`, patterns, mirror, primitives). Use `batch` for several steps at once, \ -`list_commands` for ids and parameter docs, `describe_command` for a command's schemas, `inspect_design` / `measure` to verify, `body_topology` to find edge points, \ -`set_parameter` to drive the design and `screenshot` to look at it."; +`list_commands` for ids and parameter docs, `describe_command` for a command's schemas, `inspect_design` / `measure` to verify, \ +`body_topology` to find edge points, `set_parameter` to drive the design and `screenshot` to look at it. A call whose error \ +starts `still running:` was not cancelled: don't send it again; `pending` waits for its result."; pub struct Server { backend: Box, diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index dd664b40f3..3d82bfc116 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -1523,7 +1523,7 @@ fn batch_reports_a_failed_engine_batch_like_before() { assert!(is_error); assert_eq!( body, - json!({"ok": false, "completed": 1, "failed": {"index": 1, "command": "a.two", "error": "boom"}, "rolled_back": true, "undone": 1, "results": [{"x": 1}]}) + json!({"ok": false, "completed": 1, "failed": {"index": 1, "command": "a.two", "error": "boom"}, "rolled_back": true, "undone": 1, "applied": [], "not_run": [], "results": [{"x": 1}]}) ); } @@ -1707,7 +1707,9 @@ fn tool_annotations_tell_the_whole_truth() { for name in ["execute", "batch", "save", "export", "open", "screenshot"] { assert_eq!(hints(name), (false, true, true), "{name}"); } - for name in ["list_commands", "describe_command", "inspect_design", "measure", "body_topology", "crash_reports", "skill_reference", "running"] { + for name in + ["list_commands", "describe_command", "inspect_design", "measure", "body_topology", "crash_reports", "skill_reference", "running", "pending"] + { assert_eq!(hints(name), (true, false, false), "{name}"); } assert_eq!(hints("new_design"), (false, true, false)); @@ -1776,6 +1778,319 @@ fn batch_reports_timings_and_profile() { assert!(body["timings"][0].get("profile").is_none(), "{body}"); } +/// A fake app on a loopback port that answers every request line with `answer(request)`. The +/// shared state counts connections and records each request's method and command. +#[allow(clippy::type_complexity)] +fn scripted_app(answer: impl Fn(&Value) -> Value + Send + Sync + 'static) -> (String, std::sync::Arc)>>) { + use std::io::{BufRead, Write}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let seen = std::sync::Arc::new(std::sync::Mutex::new((0usize, Vec::::new()))); + let log = seen.clone(); + let answer = std::sync::Arc::new(answer); + std::thread::spawn(move || { + for stream in listener.incoming().flatten() { + log.lock().unwrap().0 += 1; + let (log, answer) = (log.clone(), answer.clone()); + std::thread::spawn(move || { + let mut out = stream.try_clone().unwrap(); + let mut r = std::io::BufReader::new(stream); + let mut line = String::new(); + while r.read_line(&mut line).is_ok_and(|n| n > 0) { + let req: Value = serde_json::from_str(&line).unwrap(); + let what = match req["params"]["command"].as_str() { + Some(c) => format!("{} {c}", req["method"].as_str().unwrap_or("")), + None => req["method"].as_str().unwrap_or("").to_string(), + }; + log.lock().unwrap().1.push(what); + let mut reply = answer(&req); + reply["id"] = req["id"].clone(); + if writeln!(out, "{reply}").is_err() { + return; + } + line.clear(); + } + }); + } + }); + (addr, seen) +} + +fn quick_waits(pending_ms: u64, settle_ms: u64) -> crate::backend::Waits { + use std::time::Duration; + crate::backend::Waits { pending: Duration::from_millis(pending_ms), settle: Duration::from_millis(settle_ms), poll: Duration::from_millis(5) } +} + +fn running() -> Value { + json!({"ok": false, "running": true, "error": "timeout: still running in the app"}) +} + +/// #1618 over MCP: a request the app says is still running is waited for on the same +/// connection (asking `control.pending`), and the call returns its real result. +#[test] +fn a_timed_out_request_is_waited_for_and_returns_its_result() { + use crate::backend::Backend; + use std::sync::atomic::{AtomicUsize, Ordering}; + let polls = AtomicUsize::new(0); + let (addr, seen) = scripted_app(move |req| match req["method"].as_str() { + Some("engine.execute") => running(), + Some("control.pending") if polls.fetch_add(1, Ordering::SeqCst) < 2 => { + json!({"ok": true, "result": {"pending": 1, "method": "engine.execute", "done": false}}) + } + Some("control.pending") => { + json!({"ok": true, "result": {"pending": 1, "method": "engine.execute", "done": true, "reply": {"ok": true, "result": {"filleted": true}}}}) + } + _ => json!({"ok": true, "result": null}), + }); + let mut remote = crate::Remote::connect(&addr).unwrap(); + remote.set_waits(quick_waits(5000, 1000)); + let r = remote.call("engine.execute", json!({"command": "solid.fillet"})).unwrap(); + assert_eq!(r, json!({"filleted": true})); + let (connections, methods) = seen.lock().unwrap().clone(); + assert_eq!(connections, 1, "the reply waits on the connection: it is kept"); + assert_eq!(methods, ["engine.execute solid.fillet", "control.pending", "control.pending", "control.pending"]); + // Nothing is outstanding any more. + assert_eq!(remote.call("control.pending", json!({})).unwrap(), json!({"pending": null})); +} + +/// #3654 with #1618: `control.running` is sent at once while a request still runs, not held +/// back behind it as other calls are, and asking it leaves that request outstanding. +#[test] +fn running_is_asked_at_once_behind_a_request_still_running() { + use crate::backend::{Backend, STILL_RUNNING}; + let (addr, seen) = scripted_app(|req| match req["method"].as_str() { + Some("engine.execute") => running(), + Some("control.pending") => json!({"ok": true, "result": {"pending": 1, "method": "engine.execute", "done": false}}), + Some("control.running") => json!({"ok": true, "result": {"running": [{"id": 1, "method": "engine.execute", "self": true}]}}), + _ => json!({"ok": true, "result": "ran"}), + }); + let mut remote = crate::Remote::connect(&addr).unwrap(); + remote.set_waits(quick_waits(20, 20)); + assert!(remote.call("engine.execute", json!({"command": "solid.fillet"})).unwrap_err().starts_with(STILL_RUNNING)); + std::mem::take(&mut seen.lock().unwrap().1); + let r = crate::call_tool(&mut remote, "running", &json!({})); + assert_ne!(r["isError"], true, "{r}"); + assert_eq!(seen.lock().unwrap().1, ["control.running"], "not behind the request still running"); + let e = remote.call("document.inspect", json!({})).unwrap_err(); + assert!(e.starts_with(STILL_RUNNING) && e.contains("earlier request"), "still outstanding: {e}"); +} + +/// A request still running after the wait is left to the `pending` tool: the error says so +/// (and that it will still apply, or only write its file), later calls are not sent behind it, and `pending` returns +/// its result once it lands, on the same connection. +#[test] +fn a_request_still_running_is_left_to_the_pending_tool() { + use crate::backend::{Backend, STILL_RUNNING}; + use std::sync::atomic::{AtomicBool, Ordering}; + let done = std::sync::Arc::new(AtomicBool::new(false)); + let finished = done.clone(); + let (addr, seen) = scripted_app(move |req| match req["method"].as_str() { + Some("engine.execute") if req["params"]["command"] != "inspect.measure" => running(), + Some("control.pending") if finished.load(Ordering::SeqCst) => { + json!({"ok": true, "result": {"pending": 1, "method": "engine.execute", "done": true, "reply": {"ok": true, "result": {"r": 1}}}}) + } + Some("control.pending") => json!({"ok": true, "result": {"pending": 1, "method": "engine.execute", "done": false}}), + _ => json!({"ok": true, "result": "ran"}), + }); + let mut remote = crate::Remote::connect(&addr).unwrap(); + remote.set_waits(quick_waits(60, 60)); + let e = remote.call("engine.execute", json!({"command": "solid.fillet"})).unwrap_err(); + assert!(e.starts_with(STILL_RUNNING) && e.contains("solid.fillet") && e.contains("will still apply") && e.contains("`pending`"), "{e}"); + // Not sent behind it. + let e = remote.call("document.inspect", json!({})).unwrap_err(); + assert!(e.starts_with(STILL_RUNNING) && e.contains("earlier request") && e.contains("not sent"), "{e}"); + assert!(!seen.lock().unwrap().1.iter().any(|m| m == "document.inspect"), "{:?}", seen.lock().unwrap().1); + // `pending` while it still runs. + let r = crate::call_tool(&mut remote, "pending", &json!({})); + assert_ne!(r["isError"], true, "{r}"); + let body: Value = serde_json::from_str(r["content"][0]["text"].as_str().unwrap()).unwrap(); + assert_eq!(body["done"], false, "{body}"); + // It lands: `pending` returns its own result. + done.store(true, Ordering::SeqCst); + let r = crate::call_tool(&mut remote, "pending", &json!({})); + let body: Value = serde_json::from_str(r["content"][0]["text"].as_str().unwrap()).unwrap(); + assert_eq!((&body["done"], &body["result"]), (&json!(true), &json!({"r": 1})), "{body}"); + assert_eq!(remote.call("document.inspect", json!({})).unwrap(), "ran"); + + // Waited out by a later call instead: that call runs, and `pending` still has the result. + done.store(false, Ordering::SeqCst); + assert!(remote.call("engine.execute", json!({"command": "solid.chamfer"})).is_err()); + done.store(true, Ordering::SeqCst); + assert_eq!(remote.call("engine.execute", json!({"command": "inspect.measure"})).unwrap(), "ran"); + let r = crate::call_tool(&mut remote, "pending", &json!({})); + let body: Value = serde_json::from_str(r["content"][0]["text"].as_str().unwrap()).unwrap(); + assert_eq!((&body["done"], &body["result"]), (&json!(true), &json!({"r": 1})), "{body}"); + assert_eq!(seen.lock().unwrap().0, 1, "one connection throughout"); + + // A request that only reads is not said to apply. + done.store(false, Ordering::SeqCst); + let e = remote.call("engine.execute", json!({"command": "sketch.inspect"})).unwrap_err(); + assert!(e.contains("only reads") && !e.contains("will still apply"), "{e}"); + + // An export only writes its file (#1989): it is not said to apply either. + let mut other = crate::Remote::connect(&addr).unwrap(); + other.set_waits(quick_waits(60, 60)); + let e = other.call("engine.execute", json!({"command": "file.export", "params": {"path": "/tmp/wall.step"}})).unwrap_err(); + assert!(e.starts_with(STILL_RUNNING) && e.contains("will still write the file") && !e.contains("apply"), "{e}"); + let e = other.call("document.inspect", json!({})).unwrap_err(); + assert!(e.contains("earlier request") && e.contains("will still write the file"), "{e}"); +} + +/// The headless session never leaves a request running. +#[test] +fn pending_is_empty_headless() { + let mut s = server(); + assert_eq!(tool(&mut s, "pending", json!({}))["pending"], Value::Null); +} + +/// A backend for the per-step batch path: `fail` maps commands to their errors, and +/// `edit.restore_checkpoint` works or not. +struct Stub { + calls: Vec<(String, Value)>, + fail: Vec<(&'static str, String)>, + restore_ok: bool, +} + +impl crate::backend::Backend for Stub { + fn call(&mut self, method: &str, params: Value) -> Result { + self.calls.push((method.to_string(), params.clone())); + match (method, params["command"].as_str()) { + ("engine.execute", Some("edit.checkpoint")) => Ok(json!({"checkpoint": 7})), + ("engine.execute", Some("edit.restore_checkpoint")) if self.restore_ok => Ok(json!({})), + ("engine.execute", Some("edit.restore_checkpoint")) => Err("busy: request 3 is still running".into()), + ("engine.execute", Some(c)) => match self.fail.iter().find(|(f, _)| *f == c) { + Some((_, e)) => Err(e.clone()), + None => Ok(json!({"ran": c})), + }, + (m, _) => Err(format!("unknown method `{m}`")), + } + } + fn has_ui(&self) -> bool { + true + } + fn describe(&self) -> String { + "stub".into() + } +} + +fn stub_batch(fail: Vec<(&'static str, String)>, restore_ok: bool, rollback: bool) -> (Stub, Value) { + let mut b = Stub { calls: Vec::new(), fail, restore_ok }; + let args = json!({"commands": [{"command": "a.one"}, {"command": "a.two"}, {"command": "a.three"}], "rollback": rollback}); + let r = crate::tools::call_tool(&mut b, "batch", &args); + assert_eq!(r["isError"], true, "{r}"); + let body = serde_json::from_str(r["content"][0]["text"].as_str().unwrap()).unwrap(); + (b, body) +} + +/// A failed batch says what really happened: `rolled_back` only when the checkpoint was restored, +/// which steps' changes are in the design (`applied`) and which were never sent (`not_run`). A +/// step still running in the app counts as applied unless the rollback took it back. +#[test] +fn a_failed_batch_reports_what_really_happened() { + let still = format!("{} `engine.execute` (a.two) is still running in the app", crate::backend::STILL_RUNNING); + // Still running, and the rollback could not restore the checkpoint. + let (b, body) = stub_batch(vec![("a.two", still.clone())], false, true); + assert_eq!((&body["rolled_back"], &body["undone"]), (&json!(false), &json!(0)), "{body}"); + assert_eq!((&body["applied"], &body["not_run"]), (&json!([0, 1]), &json!([2])), "{body}"); + assert_eq!(body["failed"]["still_running"], true, "{body}"); + assert!(!b.calls.iter().any(|(_, p)| p["command"] == "a.three"), "a step after the failure is never sent"); + // Still running, and the rollback (after the backend waited for it) restored the checkpoint. + let (_, body) = stub_batch(vec![("a.two", still)], true, true); + assert_eq!((&body["rolled_back"], &body["undone"]), (&json!(true), &json!(1)), "{body}"); + assert_eq!((&body["applied"], &body["not_run"]), (&json!([]), &json!([2])), "{body}"); + assert!(body["failed"].get("still_running").is_none(), "{body}"); + // An ordinary failure whose rollback failed. + let (_, body) = stub_batch(vec![("a.two", "boom".into())], false, true); + assert_eq!((&body["rolled_back"], &body["applied"]), (&json!(false), &json!([0])), "{body}"); + // No rollback asked for: nothing is restored, and the reply does not say it was. + let (b, body) = stub_batch(vec![("a.two", "boom".into())], true, false); + assert_eq!((&body["rolled_back"], &body["applied"], &body["not_run"]), (&json!(false), &json!([0]), &json!([2])), "{body}"); + assert!(!b.calls.iter().any(|(_, p)| p["command"] == "edit.restore_checkpoint"), "{:?}", b.calls); + + // The app's own engine.batch: its `rolled_back` is believed; an older app's is inferred. + let reply = json!({"completed": 1, "results": [{"x": 1}], "failed": {"index": 1, "command": "a.two", "error": "boom"}, "undone": 0, "rolled_back": false}); + let (_, body, _) = recorded_batch(Ok(reply), json!({"commands": [{"command": "a.one"}, {"command": "a.two"}, {"command": "a.three"}]})); + assert_eq!((&body["rolled_back"], &body["applied"], &body["not_run"]), (&json!(false), &json!([0]), &json!([2])), "{body}"); + let reply = json!({"completed": 1, "results": [{"x": 1}], "failed": {"index": 1, "command": "a.two", "error": "boom"}, "undone": 0}); + let (_, body, _) = recorded_batch(Ok(reply), json!({"commands": [{"command": "a.one"}, {"command": "a.two"}]})); + assert_eq!(body["rolled_back"], false, "undone 0 of 1 completed: not rolled back: {body}"); + // A whole engine.batch still running in the app is not "may have run": `pending` gets it. + let still = format!("{} `engine.batch` is still running in the app", crate::backend::STILL_RUNNING); + let (b, body, is_error) = recorded_batch(Err(still), json!({"commands": [{"command": "a.one"}]})); + assert!(is_error && body.as_str().is_some_and(|t| t.starts_with(crate::backend::STILL_RUNNING) && !t.contains("may have run")), "{body}"); + assert_eq!(methods(&b), ["engine.batch"]); +} + +/// Batch steps may name the tools an agent knows: `list_commands` and `set_parameter` run as +/// engine.commands (filtered as the tool filters) and parameters.change; tools that can't be +/// steps fail the batch before anything is sent. +#[test] +fn batch_steps_may_name_tools() { + let catalog = json!([{"id": "solid.box", "label": "Box", "tab": "SOLID", "panel": "CREATE"}, {"id": "sketch.line", "label": "Line", "tab": "SKETCH", "panel": "CREATE"}]); + let reply = json!({"completed": 3, "results": [catalog, {"value": 40}, {"value": 20}], "undone": 0}); + let steps = json!([ + {"command": "list_commands", "params": {"filter": "box"}}, + {"command": "set_parameter", "params": {"name": "w", "value": 40}}, + {"command": "set_parameter", "params": {"name": "h", "expression": "w / 2", "unit": "mm"}}, + ]); + let (b, body, is_error) = recorded_batch(Ok(reply), json!({"commands": steps})); + assert!(!is_error, "{body}"); + assert_eq!( + b.calls[0].1["commands"], + json!([ + {"command": "engine.commands", "params": {}}, + {"command": "parameters.change", "params": {"name": "w", "expression": 40}}, + {"command": "parameters.change", "params": {"name": "h", "expression": "w / 2", "unit": "mm"}}, + ]) + ); + assert_eq!(body["results"][0], json!([{"id": "solid.box", "label": "Box", "tab": "SOLID", "panel": "CREATE"}]), "{body}"); + + let (b, body, is_error) = + recorded_batch(Ok(json!({})), json!({"commands": [{"command": "solid.box"}, {"command": "screenshot", "params": {"view": "iso"}}]})); + assert!(is_error, "{body}"); + assert!(b.calls.is_empty(), "nothing is sent: {:?}", b.calls); + assert_eq!((&body["failed"]["index"], &body["not_run"], &body["applied"]), (&json!(1), &json!([0, 1]), &json!([])), "{body}"); + assert!(body["failed"]["error"].as_str().is_some_and(|e| e.contains("screenshot")), "{body}"); + let (_, body, _) = recorded_batch(Ok(json!({})), json!({"commands": [{"command": "body_topology", "params": {"body": "Body1"}}]})); + assert!(body["failed"]["error"].as_str().is_some_and(|e| e.contains("model.edges") && e.contains("model.faces")), "{body}"); + + // Headless, step by step. + let mut s = server(); + let r = tool( + &mut s, + "batch", + json!({"commands": [ + {"command": "set_parameter", "params": {"name": "w", "value": "40 mm"}}, + {"command": "solid.box", "params": {"length": "w", "width": 10, "height": 10}}, + {"command": "list_commands", "params": {"filter": "solid.box"}}, + {"command": "execute", "params": {"command": "solid.sphere", "params": {"radius": 2, "center": [100, 0, 0]}}}, + {"command": "describe_command", "params": {"id": "solid.box"}}, + ]}), + ); + assert_eq!(r["completed"], 5, "{r}"); + assert!(r["results"][4]["input"]["properties"].get("operation").is_some(), "{r}"); + let ids: Vec<&str> = r["results"][2].as_array().unwrap().iter().filter_map(|c| c["id"].as_str()).collect(); + assert!(!ids.is_empty() && ids.iter().all(|id| id.contains("solid.box")), "{ids:?}"); + assert!(volume(&mut s) > 4000.0); +} + +/// An unknown argument is refused with the tool's real arguments listed. +#[test] +fn unknown_arguments_list_the_real_ones() { + let mut s = server(); + for (tool_name, want) in [ + ("screenshot", &["view", "camera", "path", "width", "height", "source", "grid", "axes"][..]), + ("inspect_design", &["measure", "sketches"][..]), + ("body_topology", &["body"][..]), + ] { + let args = if tool_name == "body_topology" { json!({"body": "Body1", "nonsense": 3}) } else { json!({"nonsense": 3}) }; + let r = raw_tool(&mut s, tool_name, args); + let msg = r["error"]["message"].as_str().unwrap_or_default(); + assert_eq!(r["error"]["code"], -32602, "{r}"); + assert!(msg.contains("unknown argument `nonsense`") && want.iter().all(|a| msg.contains(a)), "{tool_name}: {msg}"); + } +} + /// `describe_command` serves a converted command's schemas, and a headless session set to strict /// refuses a misspelt key through `execute` without touching the design. #[test] diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 6f21fd1206..231e2b67e3 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -7,7 +7,7 @@ use serde_json::{Map, Value, json}; use solidcraft_engine::io::vfs; use solidcraft_engine::timing; -use crate::backend::Backend; +use crate::backend::{Backend, STILL_RUNNING}; /// Most commands in one `batch` call. const MAX_BATCH: usize = 10_000; @@ -106,10 +106,17 @@ pub fn tool_definitions() -> Vec { tool( "batch", "Run several commands", - "Run commands in order, stopping at the first error. Each step is {command, params}. On an error the steps \ - already run are undone (unless rollback: false), so the batch is all-or-nothing; the reply names the failing \ - step (index from 0), its error, and every result before it. The usual way to build a part: sketch, draw, \ - dimension, finish, extrude, fillet… in one call.", + "Run commands in order, stopping at the first error. Each step is {command, params}: an engine command id \ + (as list_commands lists them). A step may also name one of these tools with that tool's arguments as params, \ + and runs the engine command it stands for: set_parameter (parameters.change; `value` or `expression`), \ + list_commands (engine.commands, with `filter`), describe_command (engine.describe), execute ({command, params}), \ + measure (inspect.measure), \ + inspect_design (document.inspect), undo, redo, export, save, open, new_design, crash_reports; screenshot, \ + body_topology, batch and pending can't be steps. On an error the steps already run are undone (unless \ + rollback: false), so the batch is all-or-nothing; the reply names the failing step (index from 0), its error, \ + every result before it, whether the design really went back (rolled_back), the steps whose changes are in the \ + design (applied) and those never sent (not_run). The usual way to build a part: sketch, draw, dimension, \ + finish, extrude, fillet… in one call.", obj( json!({ "commands": { @@ -265,6 +272,16 @@ pub fn tool_definitions() -> Vec { obj(json!({}), &[]), Effect::ReadOnly, ), + tool( + "pending", + "Result of a running request", + "Bridged to the app: the result of a request that was still running when its tool call returned (an error \ + starting \"still running:\"). Waits for it up to 60 s: then its own result (or error), or done: false while \ + it still runs (call again). pending: null when nothing is running. A request still running was not \ + cancelled: don't send it again; other calls wait for it before they are sent.", + obj(json!({}), &[]), + Effect::ReadOnly, + ), tool("undo", "Undo", "Undo the last change to the design.", obj(json!({}), &[]), Effect::Edits), tool("redo", "Redo", "Redo the last undone change.", obj(json!({}), &[]), Effect::Edits), tool( @@ -350,7 +367,10 @@ pub(crate) fn check_arguments(name: &str, args: &Value) -> Result<(), String> { let props = schema["properties"].as_object(); for (k, v) in a { let Some(p) = props.and_then(|p| p.get(k)) else { - return Err(format!("{name}: unknown argument `{k}`")); + let valid: Vec<&str> = props.map(|p| p.keys().map(String::as_str).collect()).unwrap_or_default(); + let valid = if valid.is_empty() { "none".to_string() } else { valid.join(", ") }; + let k: String = k.chars().take(64).collect(); + return Err(format!("{name}: unknown argument `{k}`; its arguments are: {valid}")); }; if v.is_null() { continue; @@ -475,12 +495,7 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Map) -> Result { let all = b.call("engine.commands", json!({}))?; - let Some(f) = str_arg(a, "filter").map(str::to_lowercase).filter(|f| !f.is_empty()) else { return Ok(all) }; - let hit = |c: &Value| { - // Not "params": many docs name other commands (e.g. "usable in sketch.delete"), #269. - ["id", "label", "panel", "tab"].iter().any(|k| c.get(*k).and_then(Value::as_str).is_some_and(|s| s.to_lowercase().contains(&f))) - }; - Ok(Value::Array(all.as_array().map(|v| v.iter().filter(|c| hit(c)).cloned().collect()).unwrap_or_default())) + Ok(filter_commands(all, str_arg(a, "filter"))) } "describe_command" => { let id = str_arg(a, "id").ok_or("`id` is required")?; @@ -511,6 +526,7 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Map) -> Result pending(b), "measure" => { if let Some(items) = a.get("items") { return exec(b, "inspect.measure", json!({ "items": items })); @@ -548,6 +564,114 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Map) -> Result) -> Value { + let Some(f) = filter.map(str::to_lowercase).filter(|f| !f.is_empty()) else { return all }; + let hit = + |c: &Value| ["id", "label", "panel", "tab"].iter().any(|k| c.get(*k).and_then(Value::as_str).is_some_and(|s| s.to_lowercase().contains(&f))); + Value::Array(all.as_array().map(|v| v.iter().filter(|c| hit(c)).cloned().collect()).unwrap_or_default()) +} + +/// `pending`: the end of a request still running when its call returned (bridged only; a +/// headless session runs every request to its end). +fn pending(b: &mut dyn Backend) -> Result { + let p = b.call("control.pending", json!({}))?; + let what = p.get("pending").cloned().unwrap_or(Value::Null); + if what.is_null() { + return Ok(json!({"pending": null, "note": "nothing is running"})); + } + let name = what.as_str().unwrap_or("the request").to_string(); + if p.get("lost").and_then(Value::as_bool) == Some(true) { + return Err(format!( + "the connection to the app was lost while {name} was running, and its result with it: inspect the design (inspect_design) \ + to see whether it applied before sending it again" + )); + } + if p.get("done").and_then(Value::as_bool) == Some(true) { + let reply = p.get("reply").cloned().unwrap_or(Value::Null); + return match reply.get("ok").and_then(Value::as_bool) { + Some(true) => Ok(json!({"pending": what, "done": true, "result": reply.get("result").cloned().unwrap_or(Value::Null)})), + _ => Err(format!("{name} failed: {}", reply.get("error").and_then(Value::as_str).unwrap_or("no reply"))), + }; + } + Ok(json!({"pending": what, "done": false, "running_s": p.get("running_s"), "note": "still running: call pending again to keep waiting"})) +} + +/// A `batch` step as the engine command it runs: an engine command as it is, or one of these +/// tools' names (an agent writes the tool it knows) with that tool's arguments as its params. +/// `Ok((command, params, filter))`: `filter` narrows a `list_commands` step's catalog; an empty +/// `command` means the step is an engine command already. +fn batch_step(step: &Value) -> Result<(String, Value, Option), String> { + let command = step.get("command").or_else(|| step.get("id")).and_then(Value::as_str).ok_or("missing `command`")?; + let empty = Map::new(); + let params = step.get("params").cloned().filter(|p| !p.is_null()).unwrap_or(json!({})); + let a = params.as_object().unwrap_or(&empty); + let mapped = |c: &str, p: Value| Ok((c.to_string(), p, None)); + match command { + "list_commands" => Ok(("engine.commands".into(), json!({}), str_arg(a, "filter").map(str::to_string))), + "set_parameter" => { + let mut p = pick(a, &["name", "unit", "comment"]); + p["expression"] = parameter_value(a)?; + mapped("parameters.change", p) + } + "describe_command" => mapped("engine.describe", json!({"command": str_arg(a, "id").ok_or("a `describe_command` step needs params {id}")?})), + "execute" => { + let inner = str_arg(a, "command").ok_or("an `execute` step needs params {command, params}")?; + if !inner.contains('.') && TOOL_STEPS.contains(&inner) { + return Err(format!("an `execute` step runs an engine command, not the `{inner}` tool")); + } + mapped(inner, a.get("params").cloned().filter(Value::is_object).unwrap_or(json!({}))) + } + "measure" => match a.get("items") { + Some(items) => mapped("inspect.measure", json!({"items": items})), + None => mapped("inspect.measure", str_arg(a, "body").map_or(json!({}), |body| json!({"bodies": [body]}))), + }, + "inspect_design" => mapped("document.inspect", json!({"measure": a.get("measure").and_then(Value::as_bool).unwrap_or(true)})), + "undo" => mapped("edit.undo", json!({})), + "redo" => mapped("edit.redo", json!({})), + "export" => mapped("file.export", pick(a, &["path", "format", "bodies", "mesh_prefix", "effort", "velocity"])), + "save" => mapped("file.save", pick(a, &["path", "format"])), + "open" => mapped("doc.open", pick(a, &["path", "up", "names"])), + "new_design" => mapped("file.new", pick(a, &["name"])), + "crash_reports" => match a.get("id") { + Some(_) => mapped("diagnostics.crash_report", pick(a, &["id"])), + None => mapped("diagnostics.crash_reports", pick(a, &["limit"])), + }, + "body_topology" => Err("`body_topology` is a tool, not a command: in a batch use model.edges {body} and model.faces {body}".into()), + "screenshot" => Err("`screenshot` is a tool, not a command: call it after the batch".into()), + "batch" => Err("batches don't nest: put the steps in this batch".into()), + "pending" => Err("`pending` is a tool, not a command: call it on its own".into()), + "running" => Err("`running` is a tool, not a command: call it on its own".into()), + "skill_reference" => Err("`skill_reference` is a tool, not a command: call it on its own".into()), + // An engine command: the step goes as it is. + _ => Ok((String::new(), Value::Null, None)), + } +} + +/// The tool names a `batch` step may not be (or is mapped from): `execute` must not wrap them. +const TOOL_STEPS: &[&str] = &[ + "batch", + "body_topology", + "crash_reports", + "describe_command", + "execute", + "export", + "inspect_design", + "list_commands", + "measure", + "new_design", + "open", + "pending", + "redo", + "running", + "save", + "screenshot", + "set_parameter", + "skill_reference", + "undo", +]; + /// `set_parameter`'s new value: `value` or `expression` (the parameters.change name), one of /// them, or both when they agree (`50` and `"50"` do). fn parameter_value(a: &Map) -> Result { @@ -585,8 +709,9 @@ impl Undo { } } - /// Go back; (whether the design is back as it was, the number of steps undone). A failed - /// rollback says so rather than reporting the flag the caller asked for (#3242). + /// Go back: whether the design really is back where the batch started, and the number of + /// steps undone. A failed rollback says so rather than reporting the flag the caller asked + /// for (#3242). fn rollback(self, b: &mut dyn Backend, steps: usize) -> (bool, u64) { match self { Undo::Checkpoint(id) => match exec(b, "edit.restore_checkpoint", json!({"checkpoint": id})) { @@ -594,7 +719,7 @@ impl Undo { Err(_) => (false, 0), }, Undo::Depth(before) => { - let after = undo_depth(b).unwrap_or(before); + let Some(after) = undo_depth(b) else { return (false, 0) }; let mut undone = 0u64; for _ in before..after { if exec(b, "edit.undo", json!({})).is_err() { @@ -602,7 +727,7 @@ impl Undo { } undone += 1; } - (undone == steps as u64, undone) + (undone == after.saturating_sub(before), undone) } // Nothing remembered the design; only a first step that failed left nothing behind. Undo::None => (steps == 0, 0), @@ -630,6 +755,44 @@ fn batch(b: &mut dyn Backend, a: &Map) -> Value { let verbose = a.get("verbose").and_then(Value::as_bool).unwrap_or(true); let profile = a.get("profile").and_then(Value::as_bool).unwrap_or(false); let timed = profile || a.get("timings").and_then(Value::as_bool).unwrap_or(false); + // Steps that name a tool run the engine command it stands for. One that can't be a step + // fails the batch before anything is sent. + let mut mapped: Vec = Vec::with_capacity(steps.len()); + let mut filters: Vec<(usize, String)> = Vec::new(); + for (i, step) in steps.iter().enumerate() { + match batch_step(step) { + Ok((command, _, _)) if command.is_empty() => mapped.push(step.clone()), + Ok((command, params, filter)) => { + if let Some(f) = filter { + filters.push((i, f)); + } + mapped.push(json!({"command": command, "params": params})); + } + // A step without a command fails where it stands, as before. + Err(e) if e == "missing `command`" => mapped.push(step.clone()), + Err(e) => { + let command = step.get("command").or_else(|| step.get("id")).and_then(Value::as_str); + let reply = json!({ + "ok": false, + "completed": 0, + "failed": {"index": i, "command": command, "error": e}, + "rolled_back": false, + "undone": 0, + "applied": [], + "not_run": (0..steps.len()).collect::>(), + "results": [], + "note": "nothing was run", + }); + return text_result(&reply, true); + } + } + } + let steps = &mapped; + // A `list_commands` step's catalog, filtered as the tool filters it. + let narrow = |i: usize, v: Value| match filters.iter().find(|(j, _)| *j == i) { + Some((_, f)) => filter_commands(v, Some(f)), + None => v, + }; // An app that runs batches itself answers the whole batch in one request, so one frame // (#743). One too long for a control line goes step by step: the app would refuse it unrun. // The margin covers the envelope and the `allow_paths` the backend adds. @@ -649,12 +812,15 @@ fn batch(b: &mut dyn Backend, a: &Map) -> Value { failed.insert("error".into(), Value::String(error)); } } - return batch_reply(&r, rollback, verbose); + return batch_reply(&r, steps.len(), rollback, verbose, &narrow); } // Older apps and the headless session, or the app wants the steps one by one. Err(e) if e.starts_with("unknown method") || e.starts_with("engine.batch unavailable") => {} - // Never sent: no app to send it to. - Err(e) if e.starts_with(crate::backend::NOT_RUNNING) => return text_result(&Value::String(e), true), + // Never sent: no app to send it to. Or sent and still running (it was not cancelled): + // the `pending` tool gets its result. + Err(e) if e.starts_with(crate::backend::NOT_RUNNING) || e.starts_with(STILL_RUNNING) => { + return text_result(&Value::String(e), true); + } // The app may have run it: sending the steps again would apply them twice. Err(e) => { let msg = format!("the app did not answer engine.batch ({e}); it may have run: inspect the design before sending it again"); @@ -678,15 +844,33 @@ fn batch(b: &mut dyn Backend, a: &Map) -> Value { rows.push(took.to_json(i, command.unwrap_or_default())); } match r { - Ok(v) => results.push(if verbose { v } else { compact(v) }), + Ok(v) => { + let v = narrow(i, v); + results.push(if verbose { v } else { compact(v) }) + } Err(e) => { - let (rolled_back, undone) = start.rollback(b, i); + // A step still running in the app was not cancelled: the rollback first waits for + // it (the backend does), so a restored checkpoint takes it back too. + let running = e.starts_with(STILL_RUNNING); + let (went_back, undone) = start.rollback(b, i); + // Only a rollback asked for is reported as one. + let rolled_back = rollback && went_back; + let mut failed = json!({"index": i, "command": command, "error": e}); + // Applied: the steps before it, unless rolled back; and the step itself while it + // still runs, as it will apply when it finishes. + let mut applied: Vec = if rolled_back { Vec::new() } else { (0..i).collect() }; + if running && !rolled_back { + failed["still_running"] = json!(true); + applied.push(i); + } let reply = json!({ "ok": false, "completed": i, - "failed": {"index": i, "command": command, "error": e}, + "failed": failed, "rolled_back": rolled_back, "undone": undone, + "applied": applied, + "not_run": (i + 1..steps.len()).collect::>(), "results": results, }); return text_result(&with_timings(reply, timed, rows, clock.ms()), true); @@ -708,24 +892,35 @@ fn with_timings(mut reply: Value, timed: bool, rows: Vec, total_ms: f64) } /// The `batch` reply from the app's `engine.batch` answer, shaped as the per-step path shapes it. -fn batch_reply(r: &Value, rollback: bool, verbose: bool) -> Value { - let results: Vec = - r["results"].as_array().cloned().unwrap_or_default().into_iter().map(|v| if verbose { v } else { compact(v) }).collect(); +fn batch_reply(r: &Value, len: usize, rollback: bool, verbose: bool, narrow: &dyn Fn(usize, Value) -> Value) -> Value { + let results: Vec = r["results"] + .as_array() + .cloned() + .unwrap_or_default() + .into_iter() + .enumerate() + .map(|(i, v)| narrow(i, v)) + .map(|v| if verbose { v } else { compact(v) }) + .collect(); let (mut reply, error) = match r.get("failed").filter(|f| !f.is_null()) { None => (json!({"ok": true, "completed": results.len(), "results": results}), false), Some(failed) => { - // `rolled_back` tells whether the design went back, not whether a rollback was - // asked for (#3242): the app answers with its own `rolled_back`; an app that - // predates that field and undid none of the steps it had completed did not go back. - let undone = r["undone"].as_u64().unwrap_or(0); - let went_back = r.get("rolled_back").and_then(Value::as_bool).unwrap_or(undone > 0 || r["completed"].as_u64() == Some(0)); + let completed = r["completed"].as_u64().map_or(results.len(), |c| c as usize).min(len); + // `rolled_back` tells whether the design went back, not whether a rollback was asked + // for (#3242): an app that says so is believed; an older one went back when it undid + // every completed step (none completed: nothing to take back). + let went_back = r.get("rolled_back").and_then(Value::as_bool).unwrap_or_else(|| r["undone"].as_u64() == Some(completed as u64)); + let rolled_back = rollback && went_back; + let applied: Vec = if rolled_back { Vec::new() } else { (0..completed).collect() }; ( json!({ "ok": false, "completed": r["completed"], "failed": failed, - "rolled_back": rollback && went_back, + "rolled_back": rolled_back, "undone": r["undone"], + "applied": applied, + "not_run": (completed + 1..len).collect::>(), "results": results, }), true, diff --git a/crates/sketch/src/fonts.rs b/crates/sketch/src/fonts.rs index 769959a1dd..95fd45edf6 100644 --- a/crates/sketch/src/fonts.rs +++ b/crates/sketch/src/fonts.rs @@ -393,6 +393,11 @@ pub fn font_dirs() -> Vec { } dirs.push(PathBuf::from("/usr/local/share/fonts")); dirs.push(PathBuf::from("/usr/share/fonts")); + #[cfg(target_os = "linux")] + { + dirs.push(PathBuf::from("/run/host/fonts")); + dirs.push(PathBuf::from("/run/host/user-fonts")); + } } dirs } @@ -460,6 +465,15 @@ mod tests { use super::*; use ttf_parser::name_id; + #[cfg(target_os = "linux")] + #[test] + fn font_dirs_include_flatpak_host_fonts() { + let dirs = font_dirs(); + for dir in ["/run/host/fonts", "/run/host/user-fonts"] { + assert!(dirs.contains(&PathBuf::from(dir)), "missing {dir}"); + } + } + /// A face of family "Arial" whose full name is `full`. fn face(full: &str, bold: bool, italic: bool) -> FaceNames { FaceNames { index: 0, bold, italic, names: vec![(name_id::FAMILY, normalize("Arial")), (name_id::FULL_NAME, normalize(full))] } diff --git a/crates/sketch/src/loci.rs b/crates/sketch/src/loci.rs index cc61a08e65..62a8edfeed 100644 --- a/crates/sketch/src/loci.rs +++ b/crates/sketch/src/loci.rs @@ -829,7 +829,7 @@ pub(crate) fn read(sk: &Sketch, before: &dyn Fn(usize) -> Option, on_curve let rounds: Vec> = (0..m).map(|k| round_of(sk, k)).collect(); let line = |l: usize| lines.get(l).copied().flatten(); let round = |k: usize| rounds.get(k).copied().flatten(); - let cons: Vec<&ConstraintKind> = sk.constraints.iter().filter(|c| !c.driven).map(|c| &c.kind).collect(); + let cons: Vec<&ConstraintKind> = sk.constraints.iter().filter(|c| !c.driven && !sk.follows_its_source(&c.kind)).map(|c| &c.kind).collect(); let dim = |v: f64| (v.is_finite() && (0.0..=MAX_COORD).contains(&v)).then_some(v); // Radii and lengths the dimensions set, also through equal curves. diff --git a/crates/sketch/src/offset.rs b/crates/sketch/src/offset.rs index 1d38ac75d2..0e2fabc23a 100644 --- a/crates/sketch/src/offset.rs +++ b/crates/sketch/src/offset.rs @@ -30,6 +30,28 @@ impl Sketch { .or_else(|| self.freeform_offsets.iter().find(|o| o.output == output).map(|o| o.distance)) } + /// A Parallel between an offset line and the source it follows (#4464). The association + /// keeps them parallel already, and the output's points are held during a solve, so solving + /// it would only pin the source to the output's current direction. It drives again once + /// Break releases the output. + pub(crate) fn follows_its_source(&self, kind: &ConstraintKind) -> bool { + let ConstraintKind::Parallel { a, b } = *kind else { + return false; + }; + let (Some(a), Some(b)) = (self.curves.get(a).map(|c| c.id.as_str()), self.curves.get(b).map(|c| c.id.as_str())) else { + return false; + }; + self.offsets.iter().any(|o| { + o.sources.iter().enumerate().any(|(i, (source, _))| { + let output = match &o.topology { + Some(t) => t.roles.get(i).map(|r| r.output.as_str()), + None => o.outputs.get(i).map(String::as_str), + }; + output.is_some_and(|output| (source == a && output == b) || (source == b && output == a)) + }) + }) + } + /// Endpoints of a normal-distance annotation, evaluated from its source curve. pub fn offset_dimension_points(&self, output: &str) -> Option<(Vec2, Vec2)> { let (source, forward, distance) = self diff --git a/crates/sketch/src/solver.rs b/crates/sketch/src/solver.rs index d9f77d5c3b..de296c1b3d 100644 --- a/crates/sketch/src/solver.rs +++ b/crates/sketch/src/solver.rs @@ -1504,7 +1504,7 @@ fn build_blocks<'a>(sk: &'a Sketch, lay: &Layout, on_curve: &[(usize, usize)]) - } } for (i, c) in sk.constraints.iter().enumerate() { - if c.driven || matches!(c.kind, ConstraintKind::OffsetDistance { .. }) { + if c.driven || matches!(c.kind, ConstraintKind::OffsetDistance { .. }) || sk.follows_its_source(&c.kind) { continue; } raw.push(match touch_point(sk, &c.kind, on_curve) { diff --git a/crates/space-mouse/src/buttons.rs b/crates/space-mouse/src/buttons.rs new file mode 100644 index 0000000000..04818d91c1 --- /dev/null +++ b/crates/space-mouse/src/buttons.rs @@ -0,0 +1,153 @@ +//! What each button on a 3D mouse does by default, for the devices we have measured. The jobs +//! come from 3Dconnexion's public SpaceMouse Pro manual (Feature Guide); the button numbers come +//! only from our own measurements. Nothing here is guessed: a device or button that isn't in +//! [`MEASURED`] has no default. + +use crate::MAX_BUTTONS; + +/// A job a button can do by default. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Action { + /// Opens the device's settings. + Menu, + /// Brings the model back to the centre of the screen. + Fit, + ViewTop, + ViewRight, + ViewFront, + /// Rolls the view. + Roll, + Esc, + Alt, + Shift, + Ctrl, + /// Locks rotation with one press. + RotationLock, +} + +/// One measured button: device (USB vendor and product id), the button number the device +/// reports (counting from 1, like [`crate::Layout::buttons`]), and the job it does by default. +/// `None` means the key is measured but has no fixed default (the numbered function keys, whose +/// commands the vendor's driver assigns per application). +type Row = (u16, u16, usize, Option); + +/// The only place button numbers live. To add a key, add a row from a measurement; don't +/// guess a number from memory or from anyone else's code. +const MEASURED: &[Row] = &[ + // SpaceMouse Pro (046d:c62b): our own measurement on a SpaceMouse Pro, 2026-10-16. + // Each row is one printed key. + (0x046d, 0xc62b, 1, Some(Action::Menu)), + (0x046d, 0xc62b, 2, Some(Action::Fit)), + (0x046d, 0xc62b, 3, Some(Action::ViewTop)), + (0x046d, 0xc62b, 5, Some(Action::ViewRight)), + (0x046d, 0xc62b, 6, Some(Action::ViewFront)), + (0x046d, 0xc62b, 9, Some(Action::Roll)), + (0x046d, 0xc62b, 13, None), // printed "1" + (0x046d, 0xc62b, 14, None), // printed "2" + (0x046d, 0xc62b, 15, None), // printed "3" + (0x046d, 0xc62b, 16, None), // printed "4" + (0x046d, 0xc62b, 23, Some(Action::Esc)), + (0x046d, 0xc62b, 24, Some(Action::Alt)), + (0x046d, 0xc62b, 25, Some(Action::Shift)), + (0x046d, 0xc62b, 26, Some(Action::Ctrl)), + (0x046d, 0xc62b, 27, Some(Action::RotationLock)), +]; + +/// The default job of each button on this device, indexed like [`crate::Layout::buttons`] (button +/// number minus 1). `None` for a device we haven't measured, including homemade ones. For a +/// measured device, a button's entry is `None` unless we've measured it and it has a default. +pub fn defaults(vendor: u16, product: u16) -> Option<[Option; MAX_BUTTONS]> { + from_rows(MEASURED, vendor, product) +} + +fn from_rows(rows: &[Row], vendor: u16, product: u16) -> Option<[Option; MAX_BUTTONS]> { + let mut rows = rows.iter().filter(|row| row.0 == vendor && row.1 == product).peekable(); + rows.peek()?; + let mut out = [None; MAX_BUTTONS]; + for &(_, _, number, action) in rows { + // A row with a number outside 1..=MAX_BUTTONS is ignored rather than trusted. + if let Some(slot) = number.checked_sub(1).and_then(|i| out.get_mut(i)) { + *slot = action; + } + } + // A device whose rows give no action has no defaults at all. + out.iter().any(Option::is_some).then_some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pro_rows_resolve() { + let table = defaults(0x046d, 0xc62b).expect("measured device"); + let at = |n: usize| table.get(n - 1).copied().flatten(); + assert_eq!(at(1), Some(Action::Menu)); + assert_eq!(at(2), Some(Action::Fit)); + assert_eq!(at(3), Some(Action::ViewTop)); + assert_eq!(at(5), Some(Action::ViewRight)); + assert_eq!(at(6), Some(Action::ViewFront)); + assert_eq!(at(9), Some(Action::Roll)); + assert_eq!(at(23), Some(Action::Esc)); + assert_eq!(at(24), Some(Action::Alt)); + assert_eq!(at(25), Some(Action::Shift)); + assert_eq!(at(26), Some(Action::Ctrl)); + assert_eq!(at(27), Some(Action::RotationLock)); + // The numbered keys 1 to 4, undeclared numbers and the unused tail have no action. + for n in [4, 7, 8, 10, 13, 14, 15, 16, 28, 32] { + assert_eq!(at(n), None); + } + } + + #[test] + fn rows_resolve_to_their_action() { + let rows: &[Row] = &[ + // Synthetic numbers, not real mappings. + (1, 2, 5, Some(Action::Fit)), + // Synthetic numbers, not real mappings. + (1, 2, 1, Some(Action::Menu)), + // Synthetic numbers, not real mappings: zero and past MAX_BUTTONS are dropped. + (1, 2, 0, Some(Action::Esc)), + // Synthetic numbers, not real mappings. + (1, 2, MAX_BUTTONS + 1, Some(Action::Alt)), + // Synthetic numbers, not real mappings: a device with only no-action rows. + (1, 4, 7, None), + ]; + let table = from_rows(rows, 1, 2).expect("known device"); + assert_eq!(table.get(4), Some(&Some(Action::Fit))); + assert_eq!(table.first(), Some(&Some(Action::Menu))); + assert_eq!(table.iter().flatten().count(), 2); + assert!(from_rows(rows, 1, 3).is_none()); + assert!(from_rows(rows, 1, 4).is_none()); + } + + #[test] + fn measured_table_has_no_duplicates() { + for (i, a) in MEASURED.iter().enumerate() { + for b in MEASURED.iter().skip(i + 1) { + assert!((a.0, a.1, a.2) != (b.0, b.1, b.2), "duplicate row for button {}", a.2); + } + } + } + + #[test] + fn unknown_devices_have_no_defaults() { + assert!(defaults(0x046d, 0xc62a).is_none()); + assert!(defaults(0x256f, 0xc63a).is_none()); + assert!(defaults(0, 0).is_none()); + assert!(defaults(u16::MAX, u16::MAX).is_none()); + } + + #[test] + fn measured_table_stays_in_range() { + for &(_, _, number, _) in MEASURED { + assert!((1..=MAX_BUTTONS).contains(&number)); + } + // Whatever the ids, lookups never panic. + for v in [0, 1, 0x046d, u16::MAX] { + for p in [0, 0xc62b, u16::MAX] { + let _ = defaults(v, p); + } + } + } +} diff --git a/crates/space-mouse/src/calibration.rs b/crates/space-mouse/src/calibration.rs index bf8242520b..256295f80b 100644 --- a/crates/space-mouse/src/calibration.rs +++ b/crates/space-mouse/src/calibration.rs @@ -1,5 +1,5 @@ -//! Calibration: which of a device's raw axes is which, and how far each one goes. The user holds -//! the cap still, then moves it along each axis both ways; [`Recording::solve`] turns what the +//! Calibration: which of a device's raw axes is which, and how far each one goes. The user says +//! what kind of device it is ([`Kind`]), holds it still, then moves it along each axis both ways; [`Recording::solve`] turns what the //! device sent into a [`Calibration`] that maps any reading onto 3Dconnexion's axes (X right, //! Y towards the user, Z down, the rotations about them right-handed), ±[`FULL_SCALE`] at full //! deflection. Everything after that, the driver mapping and the user's per-axis choices, then @@ -15,9 +15,47 @@ pub struct Step { pub positive: bool, } +/// What kind of device is being calibrated. It decides which motions are asked for and how a step +/// is recorded; the user picks it, and [`Wizard::mismatch`] says when the device doesn't behave +/// like the kind picked. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum Kind { + /// A cap on springs that comes back to the middle when let go: a reading says how far it is + /// held out, and stays while it is held. + #[default] + ReturnToCenter, + /// A ball, often with a scroll ring, that stays where it is left: a reading says how fast it + /// moves, and goes back to rest as soon as it stops. + Trackball, +} + +impl Kind { + /// Whether the steps ask about canonical axis `axis`. A trackball has nothing to tilt: rolling + /// it is asked as sliding (rolled right is slid right, rolled toward you is pulled toward you), + /// its scroll ring as Z and spinning it as the twist. + pub fn asks(self, axis: usize) -> bool { + match self { + Kind::ReturnToCenter => axis < 6, + Kind::Trackball => matches!(axis, 0 | 1 | 2 | 5), + } + } +} + +/// [`AxisCalibration::source`] for a canonical axis the device doesn't have: it always reads 0. +/// A fixed width, so a calibration saved by a 64-bit build reads the same on wasm32 and back. +pub const NO_SOURCE: usize = u16::MAX as usize; + +/// Reads a source; anything from [`NO_SOURCE`] up, as `usize::MAX` saved by earlier builds is, means none. +#[cfg(feature = "serde")] +fn source_that_reads<'de, D: serde::Deserializer<'de>>(saved: D) -> Result { + let saved = ::deserialize(saved)?; + Ok(usize::try_from(saved).map_or(NO_SOURCE, |source| source.min(NO_SOURCE))) +} + /// The motions, in the order they are asked for. The positive direction of each is what both /// 3Dconnexion devices we measured report as positive: slide right, pull towards you, press down, -/// tilt back, tilt left, twist clockwise seen from above. +/// tilt the top towards you (the near edge down), tilt left, twist clockwise seen from above. pub const STEPS: [Step; 12] = { let mut steps = [Step { axis: 0, positive: true }; 12]; let mut i = 0; @@ -32,7 +70,8 @@ pub const STEPS: [Step; 12] = { #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct AxisCalibration { - /// The raw axis it is read from. + /// The raw axis it is read from, or [`NO_SOURCE`]. + #[cfg_attr(feature = "serde", serde(deserialize_with = "source_that_reads"))] pub source: usize, /// The raw axis runs the other way. pub reversed: bool, @@ -55,15 +94,63 @@ pub struct Calibration { /// calibrated off. Calibrations saved before it existed read as 0. #[cfg_attr(feature = "serde", serde(default))] pub dead_zone_share: f32, + /// What kind of device it calibrates. Calibrations saved before it existed are of caps. + #[cfg_attr(feature = "serde", serde(default))] + pub kind: Kind, pub axes: [AxisCalibration; 6], } +/// How much of an axis's travel past its dead zone the response takes to come up to full rate, +/// so the view eases into motion at the dead zone's edge rather than jumping to speed. Kept +/// small: a steady reading close to the dead zone is dropped by [`Settle`] instead. +pub const FEATHER_SHARE: f64 = 0.05; + +/// How far past the dead zone, as a share of full scale, a reading that has stopped changing +/// reads as no motion ([`Settle`]). +pub const SETTLE_SHARE: f64 = 0.2; +/// How long a reading has to stay put before [`Settle`] drops it. +pub const SETTLE_AFTER_SECONDS: f64 = 0.25; +/// How far a reading may wander, in calibrated units, and still count as not changing. +pub const SETTLE_WANDER: i16 = 3; + +/// Drops readings that have stopped changing close to the dead zone: a hand leaning on the cap +/// pushes it a little and holds it there, which shouldn't creep the view, while a push meant to +/// move it keeps changing or goes further. Past [`SETTLE_SHARE`] a steady reading is a held push +/// and stays. Takes the calibrated readings ([`Calibration::apply`]) of a [`Kind::ReturnToCenter`] +/// device once a frame: a trackball's slow roll is a steady reading that is meant, and a device +/// without a calibration is read as it reports. +#[derive(Clone, Copy, Debug, Default)] +pub struct Settle { + last: [i16; 6], + /// How long each axis has stayed within [`SETTLE_WANDER`] of `last`. + steady: [f64; 6], +} + +impl Settle { + /// This frame's reading, `seconds` after the previous one, with the settled axes at 0. + pub fn filter(&mut self, reading: [i16; 6], seconds: f64) -> [i16; 6] { + let seconds = if seconds.is_finite() { seconds.clamp(0.0, 1.0) } else { 0.0 }; + let band = (SETTLE_SHARE * f64::from(FULL_SCALE)).round() as i32; + std::array::from_fn(|axis| { + let value = reading[axis]; + if (i32::from(value) - i32::from(self.last[axis])).abs() <= i32::from(SETTLE_WANDER) { + self.steady[axis] += seconds; + } else { + (self.last[axis], self.steady[axis]) = (value, 0.0); + } + if i32::from(value).abs() <= band && self.steady[axis] >= SETTLE_AFTER_SECONDS { 0 } else { value } + }) + } +} + /// The widest dead zone a calibration takes, as a share of full deflection. pub const MAX_DEAD_ZONE_SHARE: f32 = 0.5; impl Calibration { /// A reading in the device's raw axes, on the canonical ones. Past full deflection the value - /// keeps growing, up to the i16 range; a source the device doesn't have reads 0. + /// keeps growing, up to the i16 range; a source the device doesn't have reads 0. Just past the + /// dead zone a cap's response feathers in ([`FEATHER_SHARE`]) instead of starting at full rate; + /// a trackball's reads its speed straight, so a slow roll stays slow and in proportion. pub fn apply(&self, raw: [i16; 6]) -> [i16; 6] { let share = if self.dead_zone_share.is_finite() { self.dead_zone_share.clamp(0.0, MAX_DEAD_ZONE_SHARE) } else { 0.0 }; let shared = (f64::from(share) * f64::from(FULL_SCALE)).round() as i32; @@ -84,7 +171,14 @@ impl Calibration { if scaled <= i64::from(shared) { return 0; } - let scaled = ((scaled - i64::from(shared)) * full_scale / (full_scale - i64::from(shared))).min(i64::from(i16::MAX)); + let scaled = (scaled - i64::from(shared)) * full_scale / (full_scale - i64::from(shared)); + // Feathered: over the first `knee` past the dead zone the response eases in (a + // parabola from no slope to full slope), then carries on in a straight line, rescaled + // so full deflection is still full scale. Even, so both pieces meet on a whole unit. + let knee = if self.kind == Kind::Trackball { 0 } else { 2 * (FEATHER_SHARE * f64::from(FULL_SCALE) / 2.0).round() as i64 }; + let eased = if scaled < knee { scaled * scaled / (2 * knee) } else { scaled - knee / 2 }; + // Never 0 past the dead zone, so its edge stays where the motion starts. + let scaled = (eased * full_scale / (full_scale - knee / 2)).clamp(1, i64::from(i16::MAX)); let signed = if (offset < 0) != axis.reversed { -scaled } else { scaled }; i16::try_from(signed).unwrap_or(0) }) @@ -124,16 +218,19 @@ impl Calibration { rest } - /// Whether it can be used as it is: each canonical axis reads its own raw axis, and goes - /// further both ways at full deflection than it wanders at rest. Every calibration - /// [`Recording::solve`] gives is; one from a hand-edited settings file may not be, and could - /// turn any touch of the cap into full speed. Its dead zone has to be a share from 0 to - /// [`MAX_DEAD_ZONE_SHARE`]. + /// Whether it can be used as it is: each canonical axis the device has reads its own raw axis, + /// and goes further both ways at full deflection than it wanders at rest, and the device has + /// at least one. Every calibration [`Recording::solve`] gives is; one from a hand-edited + /// settings file may not be, and could turn any touch of the cap into full speed. Its dead + /// zone has to be a share from 0 to [`MAX_DEAD_ZONE_SHARE`]. pub fn is_usable(&self) -> bool { let mut used = [false; 6]; (0.0..=MAX_DEAD_ZONE_SHARE).contains(&self.dead_zone_share) + && self.axes.iter().any(|axis| axis.source != NO_SOURCE) && self.axes.iter().all(|axis| { - axis.full.iter().all(|&full| full > axis.dead_zone) && used.get_mut(axis.source).is_some_and(|used| !std::mem::replace(used, true)) + axis.source == NO_SOURCE + || axis.full.iter().all(|&full| full > axis.dead_zone) + && used.get_mut(axis.source).is_some_and(|used| !std::mem::replace(used, true)) }) } } @@ -195,14 +292,20 @@ impl Samples { } } -/// What the device sent during calibration: readings at rest, during each of [`STEPS`], and -/// where the cap came back to after each one. +/// What the device sent during calibration: readings at rest, with a hand resting on the cap, +/// during each of [`STEPS`], and where the cap came back to after each one. #[derive(Clone, Debug, Default)] pub struct Recording { rest: Samples, + /// Readings with a hand resting on the cap, not pushing it: what the dead zone has to hide. + touch: Vec<[i16; 6]>, steps: [Samples; 12], /// The readings the cap settled on after step `n` was let go: all of a steady run. settled: [Samples; 12], + /// The canonical axes the device doesn't have: not asked for, and calibrated to [`NO_SOURCE`]. + absent: [bool; 6], + /// What kind of device it is, which the calibration carries. + pub kind: Kind, } impl Recording { @@ -211,6 +314,14 @@ impl Recording { self.rest.add(raw); } + /// One reading with a hand resting on the cap. + pub fn touch(&mut self, raw: [i16; 6]) { + // A recording is a few seconds of frames; the cap keeps a wrong caller from growing it. + if self.touch.len() < MAX_TOUCH_READINGS { + self.touch.push(raw); + } + } + /// One reading during step `index` of [`STEPS`]; any other index is ignored. pub fn step(&mut self, index: usize, raw: [i16; 6]) { if let Some(samples) = self.steps.get_mut(index) { @@ -225,6 +336,30 @@ impl Recording { } } + /// How far raw axis `source` went from `rest` with a hand on the cap: the [`TOUCH_PERCENTILE`]th + /// of its distances, so the jolt of a few frames doesn't count; 0 without readings. + fn touch_wander(&self, source: usize, rest: f64) -> f64 { + let mut away: Vec = self.touch.iter().filter_map(|raw| raw.get(source)).map(|&value| (f64::from(value) - rest).abs()).collect(); + away.sort_by(f64::total_cmp); + let index = (away.len() as f64 * TOUCH_PERCENTILE).ceil() as usize; + index.checked_sub(1).and_then(|index| away.get(index)).copied().unwrap_or(0.0) + } + + /// Notes that the device doesn't have canonical axis `axis` (a trackball can't tilt, and not + /// every one has a scroll ring), and forgets anything recorded for it; any other axis is ignored. + pub fn absent(&mut self, axis: usize) { + if let Some(absent) = self.absent.get_mut(axis) { + *absent = true; + self.clear_step(2 * axis); + self.clear_step(2 * axis + 1); + } + } + + /// Whether the device has canonical axis `axis`, as far as the recording knows. + pub fn has_axis(&self, axis: usize) -> bool { + self.absent.get(axis).is_some_and(|absent| !absent) + } + /// Whether step `index` has a reading yet. pub fn has_step(&self, index: usize) -> bool { self.steps.get(index).is_some_and(|samples| samples.count > 0) @@ -252,11 +387,19 @@ impl Recording { /// spacenavd keeping a relative-axis device's last small report, #2778) rests, here, at the /// middle of everything it read at rest and after each release, and its dead zone covers all of /// it, so where it stops doesn't creep. + /// + /// A canonical axis the device doesn't have ([`Recording::absent`]) needs no steps, takes no + /// raw axis from one it has, and reads nothing. pub fn solve(&self) -> Result { let rest = self.rest.mean().ok_or(Problem::NoRest)?; + if self.absent.iter().all(|&absent| absent) { + return Err(Problem::NotRecorded(STEPS[0])); + } let mut means = [[0.0; 6]; 12]; for ((mean, samples), step) in means.iter_mut().zip(&self.steps).zip(STEPS) { - *mean = samples.mean().ok_or(Problem::NotRecorded(step))?; + if self.has_axis(step.axis) { + *mean = samples.mean().ok_or(Problem::NotRecorded(step))?; + } } // travel[c][r]: how far raw axis r moved from canonical axis c's negative step to its positive one. let travel: [[f64; 6]; 6] = std::array::from_fn(|c| std::array::from_fn(|r| means[2 * c][r] - means[2 * c + 1][r])); @@ -265,6 +408,10 @@ impl Recording { let sources = best_assignment(&shares); let mut axes = [AxisCalibration { source: 0, reversed: false, rest: 0, dead_zone: 0, full: [0; 2] }; 6]; for (c, (axis, source)) in axes.iter_mut().zip(sources).enumerate() { + if !self.has_axis(c) { + *axis = AxisCalibration { source: NO_SOURCE, reversed: false, rest: 0, dead_zone: 0, full: [1; 2] }; + continue; + } let (rest_value, wander) = self.rest.rest_and_wander(source).unwrap_or((rest[source].round(), 0.0)); let (rest_value, wander) = self.settled_rest(source, rest_value, wander); let (positive, negative) = (&self.steps[2 * c], &self.steps[2 * c + 1]); @@ -275,6 +422,10 @@ impl Recording { if full.iter().any(|&full| full < 2.0 * wander || full < 1.0) { return Err(Problem::NoResponse(c)); } + // A hand resting on the cap reads as no motion too, up to `MAX_TOUCH_SHARE` of the + // shorter direction, so a hand that pushed doesn't hide the axis. + let touched = self.touch_wander(source, rest_value); + let wander = wander.max(touched.min(MAX_TOUCH_SHARE * full[0].min(full[1]))); *axis = AxisCalibration { source, reversed, @@ -283,11 +434,17 @@ impl Recording { full: full.map(|full| full.min(f64::from(u16::MAX)) as u16), }; } - let most = axes.iter().flat_map(|axis| axis.full).max().unwrap_or(0); - if let Some(c) = axes.iter().position(|axis| axis.full.iter().any(|&full| f64::from(full) < MIN_SHARE * f64::from(most))) { + // A trackball reads exactly 0 at rest, so it has no resting hand to turn away, and its + // scroll ring clicks one unit at a time where the ball reports tens. + let present = |axis: &&AxisCalibration| axis.source != NO_SOURCE; + let most = axes.iter().filter(present).flat_map(|axis| axis.full).max().unwrap_or(0); + if self.kind == Kind::ReturnToCenter + && let Some(c) = + axes.iter().position(|axis| axis.source != NO_SOURCE && axis.full.iter().any(|&full| f64::from(full) < MIN_SHARE * f64::from(most))) + { return Err(Problem::NoResponse(c)); } - Ok(Calibration { dead_zone_share: 0.0, axes }) + Ok(Calibration { dead_zone_share: 0.0, kind: self.kind, axes }) } } @@ -334,13 +491,23 @@ fn best_assignment(shares: &[[f64; 6]; 6]) -> [usize; 6] { } /// The least a direction may go, as a share of how far the furthest axis went, before it counts -/// as not having responded. It turns away a step that recorded a resting hand rather than a push, +/// as not having responded (on a [`Kind::ReturnToCenter`] device). It turns away a step that recorded a resting hand rather than a push, /// which would otherwise scale a few units of noise up to full speed, and still lets a homemade /// device go ten times further one way than the other. pub const MIN_SHARE: f64 = 1.0 / 12.0; /// Seconds of readings with the cap at rest before the first step. pub const REST_SECONDS: f64 = 1.5; +/// Seconds of readings with a hand resting on the cap, after the rest. +pub const TOUCH_SECONDS: f64 = 2.5; +/// Seconds at the start of the touch stage that aren't recorded: a hand landing on the cap jolts it. +pub const TOUCH_LANDING_SECONDS: f64 = 0.5; +/// How much of the touch readings the dead zone has to hide, as a share of them; the rest is a jolt. +pub const TOUCH_PERCENTILE: f64 = 0.9; +/// The most touch readings a recording keeps. +const MAX_TOUCH_READINGS: usize = 10_000; +/// The most of an axis's travel a resting hand may take as dead zone. +pub const MAX_TOUCH_SHARE: f64 = 0.25; /// Seconds the cap has to be held out in a step's direction before letting go finishes it. pub const HOLD_SECONDS: f64 = 0.4; /// Seconds the reading has to stay steady, back close to rest, to count as let go even outside the @@ -353,6 +520,11 @@ pub const HOLD_SECONDS: f64 = 0.4; /// [`STEADY_SHARE`] of the furthest that axis has gone in any step: an axis that read flat at rest /// may still dither once it's off-centre. pub const SETTLE_SECONDS: f64 = 0.5; + +/// How long without a fresh report before a steady reading may be the place a trackball stopped: reports +/// that stop coming mean the value is a kept one. Shorter than [`SETTLE_SECONDS`], longer than the gap +/// between a device's reports. +const REPORT_GAP_SECONDS: f64 = 0.25; /// How far from rest, as a share of its peak this step, an axis that moved has to be to count as let go. pub const SETTLED_SHARE: f64 = 0.25; /// How far from rest, as a share of its peak, the axis that went furthest may still be when the @@ -361,11 +533,23 @@ pub const RETURNED_SHARE: f64 = 0.5; /// The least spread steady readings may have, as a share of the furthest the axis has gone. pub const STEADY_SHARE: f64 = 1.0 / 50.0; +/// How long a trackball's reading has to stay exactly the same, far out, to look like a held cap. +pub const HELD_SECONDS: f64 = 1.5; + +/// How many times in one step a cap that is let go too soon makes [`Wizard::mismatch`] suggest a +/// trackball: a ball's reading drops back to rest between strokes, so it can't be held out. +pub const RESTARTS_FOR_TRACKBALL: u32 = 3; + /// Where the guided calibration is. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Stage { + /// Which kind of device it is ([`Wizard::choose`]); nothing is recorded yet. + Choose, /// Hands off the cap. Rest, + /// A hand resting on the cap without pushing it, which sets the dead zone. Caps only: a + /// trackball doesn't move under a resting hand. + Touch, /// Step `n` of [`STEPS`]. Step(usize), /// Every step is recorded. @@ -381,9 +565,16 @@ pub enum Stage { /// quiet one, and the solver judges whether each direction went far enough. "Let go" means every /// raw axis is back within its rest wander, or the reading has gone steady close to rest /// ([`SETTLE_SECONDS`], [`SETTLED_SHARE`]). +/// +/// A trackball can't be held out: its reading is a speed, back at rest whenever the ball stops, +/// between strokes and between the clicks of a scroll ring. So for a [`Kind::Trackball`] a step +/// adds up the time the reading is away from rest, pauses and all, and is done once that reaches +/// [`HOLD_SECONDS`] and the ball stops. Only the motions a trackball has are asked for, and any +/// step can be skipped ([`Wizard::skip`]) for a motion the device doesn't have. #[derive(Clone, Debug)] pub struct Wizard { stage: Stage, + kind: Kind, recording: Recording, /// Seconds spent in the current stage: at rest, or held out. seconds: f64, @@ -398,12 +589,24 @@ pub struct Wizard { reach: [f64; 6], /// The readings the previous step settled on: while a reading stays within their spread, nobody has touched the cap. settled: Option, + /// How often this step was let go too soon. + restarts: u32, + /// What the device has done that the kind picked doesn't: a cap let go too soon again and + /// again ([`RESTARTS_FOR_TRACKBALL`]), or a trackball's reading held steady far out. + dropped: bool, + held: bool, + /// The last reading, and how long it has been exactly that. + last: Option<[i16; 6]>, + same: f64, + /// Seconds since a fresh report was last fed (see [`Self::feed_reading`]). + since_fresh: f64, } impl Default for Wizard { fn default() -> Self { Self { stage: Stage::Rest, + kind: Kind::default(), recording: Recording::default(), seconds: 0.0, dead_zone_share: 0.0, @@ -412,26 +615,111 @@ impl Default for Wizard { peaks: [0.0; 6], reach: [0.0; 6], settled: None, + restarts: 0, + dropped: false, + held: false, + last: None, + same: 0.0, + since_fresh: f64::INFINITY, } } } impl Wizard { + /// A calibration of a `kind` of device, starting with it at rest. + pub fn new(kind: Kind) -> Self { + let mut wizard = Self { kind, ..Self::default() }; + wizard.recording.kind = kind; + for axis in (0..6).filter(|&axis| !kind.asks(axis)) { + wizard.recording.absent(axis); + } + wizard + } + + /// A calibration that first asks which kind of device it is. + pub fn choosing() -> Self { + Self { stage: Stage::Choose, ..Self::default() } + } + + /// Answers [`Stage::Choose`] (or switches kind and starts over, as [`Wizard::mismatch`] + /// suggests); the dead zone set so far stays. + pub fn choose(&mut self, kind: Kind) { + let dead_zone_share = self.dead_zone_share; + *self = Self { dead_zone_share, ..Self::new(kind) }; + } + pub fn stage(&self) -> Stage { self.stage } + pub fn kind(&self) -> Kind { + self.kind + } + + /// Which of the steps asked for this device the current one is, from 1, and how many there are: + /// 0 before the first, all of them once done. + pub fn counter(&self) -> (usize, usize) { + let asked = |index: &usize| STEPS.get(*index).is_some_and(|step| self.recording.has_axis(step.axis)); + let total = (0..STEPS.len()).filter(asked).count(); + match self.stage { + Stage::Choose | Stage::Rest | Stage::Touch => (0, total), + Stage::Step(index) => ((0..=index).filter(asked).count(), total), + Stage::Done => (total, total), + } + } + + /// Whether the current step can be skipped: the device would still have a motion left. + pub fn can_skip(&self) -> bool { + matches!(self.stage, Stage::Step(_)) && (0..6).filter(|&axis| self.recording.has_axis(axis)).count() > 1 + } + + /// Skips the current step's motion, both ways: the device doesn't have it. + pub fn skip(&mut self) { + if let (true, Stage::Step(index)) = (self.can_skip(), self.stage) + && let Some(step) = STEPS.get(index) + { + self.recording.absent(step.axis); + let next = self.next_step(index + 1); + self.enter(next); + self.settled = None; + } + } + + /// The kind of device this one behaves like, when that isn't the kind picked. + pub fn mismatch(&self) -> Option { + match self.kind { + Kind::ReturnToCenter if self.dropped => Some(Kind::Trackball), + Kind::Trackball if self.held => Some(Kind::ReturnToCenter), + _ => None, + } + } + /// How far through the current stage, 0..=1: the time at rest, or held out. pub fn progress(&self) -> f64 { match self.stage { + Stage::Choose => 0.0, Stage::Rest => (self.seconds / REST_SECONDS).min(1.0), + Stage::Touch => (self.seconds / TOUCH_SECONDS).min(1.0), Stage::Step(_) => (self.seconds / HOLD_SECONDS).min(1.0), Stage::Done => 1.0, } } - /// One reading, `seconds` after the previous one. + /// One reading, `seconds` after the previous one, which may be a value kept since the last report + /// (spacenavd sends only on change). See [`Self::feed_reading`]. pub fn feed(&mut self, raw: [i16; 6], seconds: f64) { + self.feed_reading(raw, seconds, false); + } + + /// One reading, `seconds` after the previous one. `fresh` says a report or record arrived since the + /// previous reading, even if its value is the same (a ball at one speed sends it over and over, and a + /// daemon's scaling can make two records read alike); a last value kept with nothing arriving is not + /// fresh. A trackball whose reports keep arriving (the latest less than a short gap ago) is still + /// rolling, so a steady reading is never taken for where it came to rest, whatever fraction of its + /// peak it is. Once nothing has arrived for the gap, a plateau below half the peak for the settle + /// time is taken as where the ball stopped. + pub fn feed_reading(&mut self, raw: [i16; 6], seconds: f64, fresh: bool) { + self.since_fresh = if fresh { 0.0 } else { self.since_fresh + seconds.clamp(0.0, 0.1) }; // A stalled frame shouldn't finish a stage on its own. let seconds = if seconds.is_finite() { seconds.clamp(0.0, 0.1) } else { 0.0 }; // Steady while every axis's readings since the run began stay within its noise. @@ -444,19 +732,45 @@ impl Wizard { run.add(raw); } self.run = run; + if self.last == Some(raw) { + self.same += seconds; + } else { + (self.last, self.same) = (Some(raw), 0.0); + } match self.stage { + Stage::Choose => {} Stage::Rest => { self.recording.rest(raw); self.seconds += seconds; if self.seconds >= REST_SECONDS { - self.enter(Stage::Step(0)); + let next = match self.kind { + Kind::ReturnToCenter => Stage::Touch, + Kind::Trackball => self.next_step(0), + }; + self.enter(next); + } + } + Stage::Touch => { + if self.seconds >= TOUCH_LANDING_SECONDS { + self.recording.touch(raw); + } + self.seconds += seconds; + if self.seconds >= TOUCH_SECONDS { + let first = self.next_step(0); + self.enter(first); } } Stage::Step(index) => { // Still where the last step settled: the cap hasn't been touched yet. Deliberately // skips the peak and hold bookkeeping; a device that rests exactly there reads as // untouched until it moves, which is what it is. - if self.settled.is_some_and(|settled| self.near(settled, raw)) { + // A trackball's has to be exactly the same: a scroll ring clicks by less than a + // cap's noise. + let untouched = match self.kind { + Kind::ReturnToCenter => self.settled.is_some_and(|settled| self.near(settled, raw)), + Kind::Trackball => self.settled.is_some_and(|settled| settled.count > 0 && settled.min == raw && settled.max == raw), + }; + if untouched { return; } self.settled = None; @@ -467,6 +781,10 @@ impl Wizard { *reach = reach.max(offset); } let furthest = self.peaks.iter().copied().fold(0.0, f64::max); + if self.kind == Kind::Trackball { + self.roll(index, raw, away, offsets, furthest, seconds); + return; + } let peaks = self.peaks; // Close to rest is the cap settling, never held; it has let go once it's steady there. // Every axis that moved about as far as the furthest has to be back by @@ -490,13 +808,16 @@ impl Wizard { } else { self.recording.settled(index, raw); } - self.enter(if index + 1 < STEPS.len() { Stage::Step(index + 1) } else { Stage::Done }); + let next = self.next_step(index + 1); + self.enter(next); self.settled = Some(settled); } else if self.seconds > 0.0 { // Let go too soon: the hold starts over, and only a whole one is kept. self.recording.clear_step(index); self.enter(Stage::Step(index)); self.settled = Some(settled); + self.restarts += 1; + self.dropped |= self.restarts >= RESTARTS_FOR_TRACKBALL; } } else if away >= 1.0 && !settling { self.recording.step(index, raw); @@ -507,19 +828,33 @@ impl Wizard { } } - /// Goes back one step and records it again; from the first step, back to rest. + /// Goes back one step and records it again; from the first step, back to rest, and from rest + /// back to choosing the kind. A skipped motion stays skipped. pub fn back(&mut self) { - match self.stage { - Stage::Rest => {} - Stage::Step(0) => *self = Self::default(), - Stage::Step(index) => { - self.recording.clear_step(index); - self.recording.clear_step(index - 1); - self.enter(Stage::Step(index - 1)); + let current = match self.stage { + Stage::Choose => return, + Stage::Rest => { + let dead_zone_share = self.dead_zone_share; + *self = Self { dead_zone_share, ..Self::choosing() }; + return; } - Stage::Done => { - self.recording.clear_step(STEPS.len() - 1); - self.enter(Stage::Step(STEPS.len() - 1)); + Stage::Touch => 0, + Stage::Step(index) => index, + Stage::Done => STEPS.len(), + }; + let asked = |index: &usize| STEPS.get(*index).is_some_and(|step| self.recording.has_axis(step.axis)); + match (0..current).rev().find(asked) { + Some(previous) => { + self.recording.clear_step(current); + self.recording.clear_step(previous); + self.enter(Stage::Step(previous)); + } + None => { + let (kind, dead_zone_share, absent) = (self.kind, self.dead_zone_share, self.recording.absent); + *self = Self { dead_zone_share, ..Self::new(kind) }; + for axis in (0..6).filter(|&axis| absent[axis]) { + self.recording.absent(axis); + } } } } @@ -541,6 +876,9 @@ impl Wizard { } fn enter(&mut self, stage: Stage) { + if stage != self.stage { + self.restarts = 0; + } self.stage = stage; self.seconds = 0.0; self.peaks = [0.0; 6]; @@ -548,6 +886,39 @@ impl Wizard { (self.run, self.steady) = (Samples::default(), 0.0); } + /// The first step from `index` on that asks about a motion the device has, or done. + fn next_step(&self, index: usize) -> Stage { + (index..STEPS.len()).find(|&i| STEPS.get(i).is_some_and(|step| self.recording.has_axis(step.axis))).map_or(Stage::Done, Stage::Step) + } + + /// One reading of a trackball's step: the time away from rest adds up, pauses and all, and the + /// step is done once it reaches [`HOLD_SECONDS`] and the ball has stopped. Stopped means back + /// at rest, or exactly the same reading for [`SETTLE_SECONDS`] after slowing to under half the + /// step's peak: spacenavd keeps a relative device's last report (#2778), which isn't recorded. + /// Exactly, not within the noise a cap's steadiness allows: a scroll ring's clicks are smaller + /// than that. The same reading for [`HELD_SECONDS`] without slowing down is a cap being held, + /// which [`Wizard::mismatch`] reports; the step stays open (a skip or a switch of kind moves on). + fn roll(&mut self, index: usize, raw: [i16; 6], away: f64, offsets: [f64; 6], furthest: f64, seconds: f64) { + let stuck = self.same >= SETTLE_SECONDS; + let high = away >= 1.0 && offsets.iter().copied().fold(0.0, f64::max) >= 0.5 * furthest; + self.held |= high && self.same >= HELD_SECONDS; + // A still-high reading is never a place the ball came to rest: a quantized ball can send the + // same speed while it rolls. Held steady far out only makes `mismatch` suggest a cap. + let stopped = away <= 0.5 || (stuck && !high && self.since_fresh >= REPORT_GAP_SECONDS); + if away >= 1.0 && !stuck { + self.recording.step(index, raw); + self.seconds += seconds; + } else if self.seconds >= HOLD_SECONDS && stopped { + // Stopped exactly here, so this one reading is where it settled. + let mut settled = Samples::default(); + settled.add(raw); + self.recording.settled[index] = settled; + let next = self.next_step(index + 1); + self.enter(next); + self.settled = Some(settled); + } + } + /// How far each raw axis is from rest, in raw units. fn offsets(&self, raw: [i16; 6]) -> [f64; 6] { std::array::from_fn(|r| (f64::from(raw[r]) - self.recording.rest.rest_and_wander(r).map_or(0.0, |(rest, _)| rest)).abs()) @@ -596,8 +967,8 @@ mod tests { [-2, -340, 219, -26, -20, 0], // push away [-1, -3, 344, 0, -37, 0], // press down [41, -24, -341, -11, -10, 32], // lift up - [0, 193, 0, 333, -26, 0], // tilt back - [0, -339, 163, -183, 0, 0], // tilt forward + [0, 193, 0, 333, -26, 0], // tilt the top towards you + [0, -339, 163, -183, 0, 0], // tilt the top away [61, 0, 1, -12, 328, 0], // tilt left [9, 80, 1, 0, -330, -6], // tilt right [0, -1, 78, 0, 0, 341], // twist clockwise @@ -662,7 +1033,7 @@ mod tests { assert_eq!(axis.rest, 100); } // Each step now reads on its own axis, the right way, at full scale: each direction is - // scaled on its own, so the Pro's shorter tilt forward gets there too. + // scaled on its own, so the Pro's shorter tilt away gets there too. for (step, reading) in STEPS.iter().zip(PRO.map(wire)) { let value = calibration.apply(reading)[step.axis]; assert_eq!(value, if step.positive { FULL_SCALE } else { -FULL_SCALE }, "{step:?}"); @@ -672,7 +1043,7 @@ mod tests { #[test] fn readings_within_the_rest_wander_are_zero_and_full_deflection_is_full_scale() { let axis = AxisCalibration { source: 1, reversed: true, rest: 10, dead_zone: 5, full: [105; 2] }; - let calibration = Calibration { dead_zone_share: 0.0, axes: [axis; 6] }; + let calibration = Calibration { dead_zone_share: 0.0, kind: Kind::default(), axes: [axis; 6] }; assert_eq!(calibration.apply([0, 14, 0, 0, 0, 0]), [0; 6]); assert_eq!(calibration.apply([0, 115, 0, 0, 0, 0]), [-FULL_SCALE; 6]); assert_eq!(calibration.apply([0, -95, 0, 0, 0, 0]), [FULL_SCALE; 6]); @@ -683,22 +1054,23 @@ mod tests { #[test] fn a_device_that_goes_further_one_way_reaches_full_scale_both_ways() { let axis = AxisCalibration { source: 0, reversed: false, rest: 0, dead_zone: 0, full: [200, 500] }; - let calibration = Calibration { dead_zone_share: 0.0, axes: [axis; 6] }; + let calibration = Calibration { dead_zone_share: 0.0, kind: Kind::default(), axes: [axis; 6] }; assert_eq!(calibration.apply([500, 0, 0, 0, 0, 0]), [FULL_SCALE; 6]); assert_eq!(calibration.apply([-200, 0, 0, 0, 0, 0]), [-FULL_SCALE; 6]); - assert_eq!(calibration.apply([-100, 0, 0, 0, 0, 0]), [-FULL_SCALE / 2; 6]); + // Halfway reads a little under half: the first stretch past rest feathers in. + assert_eq!(calibration.apply([-100, 0, 0, 0, 0, 0]), [-170; 6]); } #[test] fn the_users_dead_zone_comes_off_the_bottom_and_full_deflection_stays_full_scale() { let axis = AxisCalibration { source: 0, reversed: false, rest: 0, dead_zone: 0, full: [350; 2] }; - let calibration = Calibration { dead_zone_share: 0.2, axes: [axis; 6] }; + let calibration = Calibration { dead_zone_share: 0.2, kind: Kind::default(), axes: [axis; 6] }; assert_eq!(calibration.apply([70, 0, 0, 0, 0, 0])[0], 0, "a fifth of the travel is inside it"); - assert_eq!(calibration.apply([210, 0, 0, 0, 0, 0])[0], 175, "60% of the travel is halfway past it"); + assert_eq!(calibration.apply([210, 0, 0, 0, 0, 0])[0], 170, "60% of the travel is halfway past it, feathered"); assert_eq!(calibration.apply([-350, 0, 0, 0, 0, 0])[0], -FULL_SCALE); // Out of range or not a number: held to the range, or none. assert_eq!(Calibration { dead_zone_share: 9.0, ..calibration }.apply([170, 0, 0, 0, 0, 0])[0], 0); - assert_eq!(Calibration { dead_zone_share: f32::NAN, ..calibration }.apply([70, 0, 0, 0, 0, 0])[0], 70); + assert_eq!(Calibration { dead_zone_share: f32::NAN, ..calibration }.apply([70, 0, 0, 0, 0, 0])[0], 62); let solved = record(PRO, STILL).solve().unwrap(); assert!(Calibration { dead_zone_share: 0.5, ..solved }.is_usable()); assert!(!Calibration { dead_zone_share: 0.6, ..solved }.is_usable()); @@ -717,7 +1089,7 @@ mod tests { full: [1, 1], }); for dead_zone_share in [0.0, MAX_DEAD_ZONE_SHARE] { - let calibration = Calibration { dead_zone_share, axes }; + let calibration = Calibration { dead_zone_share, kind: Kind::default(), axes }; assert!(calibration.is_usable()); let read = |raw: [i16; 6]| calibration.apply(raw); for (raw, sign) in [(i16::MAX, 1), (i16::MIN, -1)] { @@ -755,8 +1127,11 @@ mod tests { #[test] fn a_device_at_its_raw_rest_reads_no_motion() { let axis = |source, rest| AxisCalibration { source, reversed: source % 2 == 1, rest, dead_zone: 0, full: [350; 2] }; - let calibration = - Calibration { dead_zone_share: 0.0, axes: [axis(3, 100), axis(0, -40), axis(1, 7), axis(2, 0), axis(5, 1000), axis(4, -1)] }; + let calibration = Calibration { + dead_zone_share: 0.0, + kind: Kind::default(), + axes: [axis(3, 100), axis(0, -40), axis(1, 7), axis(2, 0), axis(5, 1000), axis(4, -1)], + }; assert_eq!(calibration.raw_rest(), [-40, 7, 0, 100, -1, 1000]); assert_eq!(calibration.apply(calibration.raw_rest()), [0; 6]); } @@ -764,7 +1139,7 @@ mod tests { #[test] fn a_source_the_device_doesnt_have_reads_zero() { let axis = AxisCalibration { source: 9, reversed: false, rest: 0, dead_zone: 0, full: [350; 2] }; - assert_eq!(Calibration { dead_zone_share: 0.0, axes: [axis; 6] }.apply([350; 6]), [0; 6]); + assert_eq!(Calibration { dead_zone_share: 0.0, kind: Kind::default(), axes: [axis; 6] }.apply([350; 6]), [0; 6]); } #[test] @@ -792,8 +1167,16 @@ mod tests { } } + /// As `hold`, each reading flagged fresh or not. + fn hold_fresh(wizard: &mut Wizard, reading: [i16; 6], seconds: f64, fresh: bool) { + for _ in 0..(seconds / 0.02).round() as usize { + wizard.feed_reading(reading, 0.02, fresh); + } + } + + /// The rest stage and, for a cap, the touch stage, the cap only jittering. fn rest_then(wizard: &mut Wizard) { - for _ in 0..40 { + for _ in 0..105 { hold(wizard, STILL[0], 0.02); hold(wizard, STILL[1], 0.02); } @@ -897,7 +1280,7 @@ mod tests { /// go back to `rest[0]`. fn walk(rest: [[i16; 6]; 2], steps: [[i16; 6]; 12]) -> Wizard { let mut wizard = Wizard::default(); - for _ in 0..40 { + for _ in 0..105 { hold(&mut wizard, rest[0], 0.02); hold(&mut wizard, rest[1], 0.02); } @@ -932,7 +1315,7 @@ mod tests { // Nothing arrives at rest, so the reading stays exactly where the last report left it: // first wherever the cap was last let go, then after each step a different place. let mut wizard = Wizard::default(); - hold(&mut wizard, STUCK[0], REST_SECONDS + 0.1); + hold(&mut wizard, STUCK[0], REST_SECONDS + TOUCH_SECONDS + 0.1); for (index, reading) in PRO.into_iter().enumerate() { assert_eq!(wizard.stage(), Stage::Step(index)); // While the cap hasn't been touched, the last step's stuck reading isn't a hold. @@ -957,7 +1340,7 @@ mod tests { // spacenavd sends only on change, so a cap held still at full deflection repeats one // reading for far longer than SETTLE_SECONDS; it is held, not let go. let mut wizard = Wizard::default(); - hold(&mut wizard, STUCK[0], REST_SECONDS + 0.1); + hold(&mut wizard, STUCK[0], REST_SECONDS + TOUCH_SECONDS + 0.1); hold(&mut wizard, PRO[0], 1.0); assert_eq!((wizard.stage(), wizard.progress()), (Stage::Step(0), 1.0)); hold(&mut wizard, STUCK[1], SETTLE_SECONDS + 0.1); @@ -1006,7 +1389,7 @@ mod tests { // 3 from it, more than half of 5. let wizard = || { let mut wizard = Wizard::default(); - hold(&mut wizard, [0; 6], REST_SECONDS + 0.1); + hold(&mut wizard, [0; 6], REST_SECONDS + TOUCH_SECONDS + 0.1); hold(&mut wizard, [250, 0, 0, 0, 0, 0], 0.5); cycle(&mut wizard, &[[10, 0, 0, 0, 0, 0], [15, 0, 0, 0, 0, 0]], SETTLE_SECONDS + 0.1); assert_eq!(wizard.stage(), Stage::Step(1)); @@ -1041,7 +1424,7 @@ mod tests { fn an_axis_flat_at_rest_that_dithers_once_off_centre_still_settles() { // Silent at rest, so no wander was measured, but ±2 around where each release stops. let mut wizard = Wizard::default(); - hold(&mut wizard, [0; 6], REST_SECONDS + 0.1); + hold(&mut wizard, [0; 6], REST_SECONDS + TOUCH_SECONDS + 0.1); let dither = |at: [i16; 6]| [at.map(|value| value - 2), at, at.map(|value| value + 2)]; for (index, reading) in PRO.into_iter().enumerate() { assert_eq!(wizard.stage(), Stage::Step(index)); @@ -1078,7 +1461,7 @@ mod tests { // Flat at rest, and the biggest push only reaches 100, so the reach share is 2 and only // the floor (4, i.e. ±2) lets this ±2 dither count as steady. let mut wizard = Wizard::default(); - hold(&mut wizard, [0; 6], REST_SECONDS + 0.1); + hold(&mut wizard, [0; 6], REST_SECONDS + TOUCH_SECONDS + 0.1); hold(&mut wizard, [100, 0, 0, 0, 0, 0], 0.5); cycle(&mut wizard, &[[8, 0, 0, 0, 0, 0], [10, 0, 0, 0, 0, 0], [12, 0, 0, 0, 0, 0]], SETTLE_SECONDS + 0.1); assert_eq!(wizard.stage(), Stage::Step(1)); @@ -1089,7 +1472,7 @@ mod tests { // Step 0 settles on a ±2 dither around 10. Sitting there is untouched; ±3 around it is wider // than the steadiness check calls one position, so it is movement and starts a hold. let mut wizard = Wizard::default(); - hold(&mut wizard, [0; 6], REST_SECONDS + 0.1); + hold(&mut wizard, [0; 6], REST_SECONDS + TOUCH_SECONDS + 0.1); hold(&mut wizard, [100, 0, 0, 0, 0, 0], 0.5); cycle(&mut wizard, &[[8, 0, 0, 0, 0, 0], [10, 0, 0, 0, 0, 0], [12, 0, 0, 0, 0, 0]], SETTLE_SECONDS + 0.1); assert_eq!(wizard.stage(), Stage::Step(1)); @@ -1104,7 +1487,7 @@ mod tests { // One reading back at rest, not a steady run, is all the settle stores; a full push after // it is still movement. let mut wizard = Wizard::default(); - hold(&mut wizard, [0; 6], REST_SECONDS + 0.1); + hold(&mut wizard, [0; 6], REST_SECONDS + TOUCH_SECONDS + 0.1); hold(&mut wizard, [100, 0, 0, 0, 0, 0], 0.5); wizard.feed([0; 6], 0.02); assert_eq!(wizard.stage(), Stage::Step(1)); @@ -1119,7 +1502,7 @@ mod tests { // reading 4 either side (6, 14) is untouched, repeated or not; one more unit is movement. let wizard = || { let mut wizard = Wizard::default(); - hold(&mut wizard, [0; 6], REST_SECONDS + 0.1); + hold(&mut wizard, [0; 6], REST_SECONDS + TOUCH_SECONDS + 0.1); hold(&mut wizard, [100, 0, 0, 0, 0, 0], 0.5); hold(&mut wizard, [10, 0, 0, 0, 0, 0], SETTLE_SECONDS + 0.1); assert_eq!(wizard.stage(), Stage::Step(1)); @@ -1145,7 +1528,7 @@ mod tests { // The step's own axis only goes to 100; another axis stays 30 out after the release, // more than a quarter of 100, which mustn't hold the step up. let mut wizard = Wizard::default(); - hold(&mut wizard, [0; 6], REST_SECONDS + 0.1); + hold(&mut wizard, [0; 6], REST_SECONDS + TOUCH_SECONDS + 0.1); hold(&mut wizard, [100, 0, 0, 0, 0, 0], 0.5); hold(&mut wizard, [5, 30, 0, 0, 0, 0], SETTLE_SECONDS + 0.1); assert_eq!(wizard.stage(), Stage::Step(1)); @@ -1155,14 +1538,14 @@ mod tests { fn a_step_stays_held_when_the_lesser_axis_is_held_while_the_larger_one_relaxes() { for held in [[100, 5, 0, 0, 0, 0], [100, 60, 0, 0, 0, 0], [100, 200, 0, 0, 0, 0]] { let mut wizard = Wizard::default(); - hold(&mut wizard, [0; 6], REST_SECONDS + 0.1); + hold(&mut wizard, [0; 6], REST_SECONDS + TOUCH_SECONDS + 0.1); hold(&mut wizard, [100, 200, 0, 0, 0, 0], 0.5); hold(&mut wizard, held, SETTLE_SECONDS + 0.1); assert_eq!(wizard.stage(), Stage::Step(0), "{held:?}"); } // A plain hold of a single axis. let mut wizard = Wizard::default(); - hold(&mut wizard, [0; 6], REST_SECONDS + 0.1); + hold(&mut wizard, [0; 6], REST_SECONDS + TOUCH_SECONDS + 0.1); hold(&mut wizard, [100, 0, 0, 0, 0, 0], 0.5); hold(&mut wizard, [100, 0, 0, 0, 0, 0], SETTLE_SECONDS + 0.1); assert_eq!(wizard.stage(), Stage::Step(0)); @@ -1173,13 +1556,13 @@ mod tests { // Nula's case: the push moves raw 1 twice as far as raw 0, which may be the one asked // for. Raw 0 comes back to 5 % and raw 1 stays at 30 %: let go, both steady. let mut wizard = Wizard::default(); - hold(&mut wizard, [0; 6], REST_SECONDS + 0.1); + hold(&mut wizard, [0; 6], REST_SECONDS + TOUCH_SECONDS + 0.1); hold(&mut wizard, [100, 200, 0, 0, 0, 0], 0.5); hold(&mut wizard, [5, 60, 0, 0, 0, 0], SETTLE_SECONDS + 0.1); assert_eq!(wizard.stage(), Stage::Step(1)); // Still holding the push, with only the lesser axis back, isn't let go. let mut holding = Wizard::default(); - hold(&mut holding, [0; 6], REST_SECONDS + 0.1); + hold(&mut holding, [0; 6], REST_SECONDS + TOUCH_SECONDS + 0.1); hold(&mut holding, [100, 200, 0, 0, 0, 0], 0.5); hold(&mut holding, [5, 200, 0, 0, 0, 0], 1.0); assert_eq!(holding.stage(), Stage::Step(0)); @@ -1190,7 +1573,7 @@ mod tests { // Mostly 10 with an occasional 14: spread 4, so steady, but the mean sits near 11 and the 14 // is the far edge. Sitting there stays untouched; 15 is wider than the steadiness check allows. let mut wizard = Wizard::default(); - hold(&mut wizard, [0; 6], REST_SECONDS + 0.1); + hold(&mut wizard, [0; 6], REST_SECONDS + TOUCH_SECONDS + 0.1); hold(&mut wizard, [100, 0, 0, 0, 0, 0], 0.5); let lopsided = [[10, 0, 0, 0, 0, 0], [10, 0, 0, 0, 0, 0], [10, 0, 0, 0, 0, 0], [14, 0, 0, 0, 0, 0]]; cycle(&mut wizard, &lopsided, SETTLE_SECONDS + 0.1); @@ -1206,7 +1589,7 @@ mod tests { // The documented limit (Grok's case): raw 0 moved less than half as far as raw 1, so it // never qualifies, and raw 1 at 90 is above a quarter of its 300 peak. let mut wizard = Wizard::default(); - hold(&mut wizard, [0; 6], REST_SECONDS + 0.1); + hold(&mut wizard, [0; 6], REST_SECONDS + TOUCH_SECONDS + 0.1); hold(&mut wizard, [100, 300, 0, 0, 0, 0], 0.5); hold(&mut wizard, [5, 90, 0, 0, 0, 0], SETTLE_SECONDS + 1.0); assert_eq!(wizard.stage(), Stage::Step(0)); @@ -1238,7 +1621,7 @@ mod tests { let x = |value: i16| [value, 0, 0, 0, 0, 0]; let (above, below) = ([x(16), x(12), x(8)], [x(-8), x(-12), x(-16)]); let mut wizard = Wizard::default(); - for _ in 0..40 { + for _ in 0..105 { hold(&mut wizard, wander[0], 0.02); hold(&mut wizard, wander[1], 0.02); } @@ -1256,7 +1639,7 @@ mod tests { #[test] fn two_steps_in_a_row_that_stop_at_the_same_place_both_finish() { let mut wizard = Wizard::default(); - hold(&mut wizard, STUCK[0], REST_SECONDS + 0.1); + hold(&mut wizard, STUCK[0], REST_SECONDS + TOUCH_SECONDS + 0.1); for reading in &PRO[..2] { hold(&mut wizard, *reading, 0.5); hold(&mut wizard, STUCK[1], SETTLE_SECONDS + 0.1); @@ -1354,4 +1737,288 @@ mod tests { recording.step(12, [i16::MAX; 6]); assert_eq!(recording.solve(), record(PRO, STILL).solve()); } + + /// A trackball as raw axes: rolled right on raw 0, rolled toward you on raw 1, its scroll ring + /// on raw 2 in single clicks, spun on raw 5; raw 3 and 4 never move. + const BALL: [(usize, [i16; 6]); 8] = [ + (0, [40, 3, 0, 0, 0, 0]), + (1, [-40, -2, 0, 0, 0, 0]), + (2, [2, 40, 0, 0, 0, 0]), + (3, [-3, -40, 0, 0, 0, 0]), + (4, [0, 0, 1, 0, 0, 0]), + (5, [0, 0, -1, 0, 0, 0]), + (10, [0, 0, 0, 0, 0, 30]), + (11, [0, 0, 0, 0, 0, -30]), + ]; + + /// Strokes of `reading` with pauses between, as a hand rolls a ball or clicks a scroll ring, + /// until the step moves on. + fn strokes(wizard: &mut Wizard, reading: [i16; 6], index: usize) { + for _ in 0..20 { + if wizard.stage() != Stage::Step(index) { + return; + } + hold(wizard, reading, 0.14); + hold(wizard, [0; 6], 0.1); + } + } + + #[test] + fn a_trackball_calibrates_from_strokes_with_pauses_and_has_no_tilt() { + let mut wizard = Wizard::choosing(); + hold(&mut wizard, [5; 6], 1.0); + assert_eq!(wizard.stage(), Stage::Choose, "nothing is recorded before the kind is picked"); + wizard.choose(Kind::Trackball); + hold(&mut wizard, [0; 6], REST_SECONDS + TOUCH_SECONDS + 0.1); + assert_eq!(wizard.counter(), (1, 8)); + for (n, (index, reading)) in BALL.into_iter().enumerate() { + assert_eq!(wizard.stage(), Stage::Step(index)); + assert_eq!(wizard.counter(), (n + 1, 8)); + strokes(&mut wizard, reading, index); + } + assert_eq!(wizard.stage(), Stage::Done); + assert_eq!(wizard.mismatch(), None); + let calibration = wizard.result().unwrap().unwrap(); + assert_eq!(calibration.kind, Kind::Trackball); + for (axis, source) in [(0, 0), (1, 1), (2, 2), (5, 5)] { + assert_eq!((calibration.axes[axis].source, calibration.axes[axis].reversed), (source, false), "axis {axis}"); + } + assert_eq!((calibration.axes[3].source, calibration.axes[4].source), (NO_SOURCE, NO_SOURCE)); + assert!(calibration.is_usable()); + // The raw axes it has no use for read nothing; the ones it has read at full scale. + assert_eq!(calibration.apply([0, 0, 0, 99, 99, 0]), [0; 6]); + assert_eq!(calibration.apply([40, 0, 0, 0, 0, 0])[0], FULL_SCALE); + } + + /// A ball that slows to a lower speed and keeps sending it is still rolling: its fresh reports are not + /// where it came to rest. (Without freshness, a daemon's kept value, the same plateau does end the step.) + #[test] + fn a_decelerating_trackball_that_keeps_sending_is_not_taken_for_rest() { + let run = |fresh: bool| { + let mut wizard = Wizard::new(Kind::Trackball); + hold_fresh(&mut wizard, [0; 6], REST_SECONDS + TOUCH_SECONDS + 0.1, fresh); + assert_eq!(wizard.stage(), Stage::Step(0)); + hold_fresh(&mut wizard, [40, 3, 0, 0, 0, 0], 0.6, fresh); + hold_fresh(&mut wizard, [10, 0, 0, 0, 0, 0], 0.7, fresh); + wizard + }; + // Reports still arriving at that speed: the ball is rolling, the step is not done. + let mut wizard = run(true); + assert_eq!(wizard.stage(), Stage::Step(0), "a fresh plateau is motion"); + // It then stops (a zero report) and the strokes go on: the first axis is not recorded as 10 at rest. + hold_fresh(&mut wizard, [0; 6], 0.2, true); + assert_eq!(wizard.stage(), Stage::Step(1)); + for (index, reading) in BALL.into_iter().skip(1) { + strokes(&mut wizard, reading, index); + } + let calibration = wizard.result().unwrap().expect("no NoResponse for the first axis"); + assert!(calibration.axes[0].rest == 0 && calibration.axes[0].dead_zone < 5, "{:?}", calibration.axes[0]); + // The same readings as a kept value (no freshness) keep the old reading of a plateau. + assert_eq!(run(false).stage(), Stage::Step(1), "a kept value that stops changing is where it stopped"); + } + + /// Reports that arrive only on some frames (a device slower than the display) still count as arriving, + /// and silence for longer than the gap does not. + #[test] + fn reports_on_some_frames_keep_a_plateau_rolling_and_silence_ends_it() { + let mut wizard = Wizard::new(Kind::Trackball); + hold_fresh(&mut wizard, [0; 6], REST_SECONDS + TOUCH_SECONDS + 0.1, true); + hold_fresh(&mut wizard, [40, 3, 0, 0, 0, 0], 0.6, true); + // A report every third frame (60 ms), the same speed. + for frame in 0..40 { + wizard.feed_reading([10, 0, 0, 0, 0, 0], 0.02, frame % 3 == 0); + } + assert_eq!(wizard.stage(), Stage::Step(0), "reports still arriving"); + // Nothing arrives any more: the last value is kept, and the plateau is where it stopped. + hold_fresh(&mut wizard, [10, 0, 0, 0, 0, 0], 0.8, false); + assert_eq!(wizard.stage(), Stage::Step(1)); + } + + #[test] + fn a_steady_trackball_speed_is_not_taken_for_rest() { + let mut wizard = Wizard::new(Kind::Trackball); + hold(&mut wizard, [0; 6], REST_SECONDS + TOUCH_SECONDS + 0.1); + assert_eq!(wizard.stage(), Stage::Step(0)); + // A quantized ball rolling at one speed for longer than a cap is ever held. + hold(&mut wizard, [40, 3, 0, 0, 0, 0], HELD_SECONDS + 0.1); + assert_eq!(wizard.stage(), Stage::Step(0), "the step is not done by a reading that stays high"); + // Letting it stop finishes the step, and the rest of the strokes go as usual. + hold(&mut wizard, [0; 6], 0.2); + assert_eq!(wizard.stage(), Stage::Step(1)); + for (index, reading) in BALL.into_iter().skip(1) { + strokes(&mut wizard, reading, index); + } + let calibration = wizard.result().unwrap().expect("no NoResponse for the first axis"); + assert!(calibration.axes[0].dead_zone < 20 && calibration.axes[0].rest == 0, "{:?}", calibration.axes[0]); + assert!(calibration.is_usable()); + } + + #[test] + fn a_motion_the_device_lacks_can_be_skipped_but_not_the_last_one() { + let mut wizard = Wizard::new(Kind::Trackball); + hold(&mut wizard, [0; 6], REST_SECONDS + TOUCH_SECONDS + 0.1); + for (index, reading) in BALL.into_iter().take(4) { + strokes(&mut wizard, reading, index); + } + // No scroll ring: skipping the press step skips the lift step too. + assert_eq!(wizard.stage(), Stage::Step(4)); + wizard.skip(); + assert_eq!((wizard.stage(), wizard.counter()), (Stage::Step(10), (5, 6))); + for (index, reading) in BALL.into_iter().skip(6) { + strokes(&mut wizard, reading, index); + } + let calibration = wizard.result().unwrap().unwrap(); + assert_eq!(calibration.axes[2].source, NO_SOURCE); + assert!(calibration.is_usable()); + // Going back over a skipped motion doesn't ask for it again. + wizard.back(); + assert_eq!(wizard.stage(), Stage::Step(11)); + wizard.back(); + wizard.back(); + assert_eq!(wizard.stage(), Stage::Step(3)); + // A device has to have something. + let mut wizard = Wizard::new(Kind::Trackball); + hold(&mut wizard, [0; 6], REST_SECONDS + TOUCH_SECONDS + 0.1); + for _ in 0..3 { + assert!(wizard.can_skip()); + wizard.skip(); + } + assert_eq!(wizard.stage(), Stage::Step(10)); + assert!(!wizard.can_skip()); + wizard.skip(); + assert_eq!(wizard.stage(), Stage::Step(10)); + let none = Calibration { dead_zone_share: 0.0, kind: Kind::Trackball, axes: [calibration.axes[3]; 6] }; + assert!(!none.is_usable()); + } + + #[test] + fn a_device_that_doesnt_behave_like_the_kind_picked_is_named() { + // A trackball calibrated as a cap: every stroke ends too soon. + let mut wizard = Wizard::default(); + hold(&mut wizard, [0; 6], REST_SECONDS + TOUCH_SECONDS + 0.1); + for _ in 0..RESTARTS_FOR_TRACKBALL { + assert_eq!(wizard.mismatch(), None); + hold(&mut wizard, [40, 0, 0, 0, 0, 0], 0.14); + hold(&mut wizard, [0; 6], 0.1); + } + assert_eq!(wizard.mismatch(), Some(Kind::Trackball)); + // A cap calibrated as a trackball: held out, steady. + let mut wizard = Wizard::new(Kind::Trackball); + rest_then(&mut wizard); + hold(&mut wizard, PRO[0], HELD_SECONDS + 0.2); + assert_eq!(wizard.mismatch(), Some(Kind::ReturnToCenter)); + // Switching starts over as the other kind, keeping the dead zone. + wizard.set_dead_zone_share(0.1); + wizard.choose(Kind::ReturnToCenter); + assert_eq!((wizard.stage(), wizard.kind(), wizard.dead_zone_share(), wizard.mismatch()), (Stage::Rest, Kind::ReturnToCenter, 0.1, None)); + wizard.back(); + assert_eq!(wizard.stage(), Stage::Choose); + } + + #[test] + fn a_resting_hand_sets_the_dead_zone_but_never_hides_the_axis() { + // A cap that reads nothing untouched, as a SpaceMouse Pro does once its reports stop. + let mut wizard = Wizard::default(); + hold(&mut wizard, [0; 6], REST_SECONDS + 0.02); + assert_eq!(wizard.stage(), Stage::Touch); + // The hand on the cap leans a little: 12 on X, 30 on Rz. + while wizard.stage() == Stage::Touch { + hold(&mut wizard, [12, -5, 3, 0, 4, -30], 0.02); + hold(&mut wizard, [-8, 5, 0, 2, -4, 20], 0.02); + } + assert_eq!(wizard.stage(), Stage::Step(0)); + hold(&mut wizard, [0; 6], 0.1); + for reading in PRO { + hold(&mut wizard, reading, 0.5); + hold(&mut wizard, [0; 6], 0.1); + } + let calibration = wizard.result().unwrap().unwrap(); + assert_eq!(calibration.axes[0].dead_zone, 12); + assert_eq!(calibration.axes[5].dead_zone, 30); + // What the hand did reads as nothing; past it, the cap moves. + assert_eq!(calibration.apply([12, -5, 3, 0, 4, -30]), [0; 6]); + assert!(calibration.apply([40, 0, 0, 0, 0, 0])[0] > 0); + // A hand that pushed instead takes at most a quarter of the axis. + let mut recording = record(PRO, STILL); + recording.touch([300, 0, 0, 0, 0, 0]); + let pushed = recording.solve().unwrap(); + assert!(f64::from(pushed.axes[0].dead_zone) <= MAX_TOUCH_SHARE * f64::from(pushed.axes[0].full[0].min(pushed.axes[0].full[1])) + 1.0); + assert!(pushed.is_usable()); + } + + #[test] + fn a_hand_landing_on_the_cap_does_not_set_the_dead_zone() { + let mut wizard = Wizard::default(); + hold(&mut wizard, [0; 6], REST_SECONDS + 0.02); + // The landing jolts X to 90 for a quarter second, then the hand leans a little: 12. + hold(&mut wizard, [90, 0, 0, 0, 0, 0], 0.3); + while wizard.stage() == Stage::Touch { + hold(&mut wizard, [12, 0, 0, 0, 0, 0], 0.02); + hold(&mut wizard, [-8, 0, 0, 0, 0, 0], 0.02); + } + hold(&mut wizard, [0; 6], 0.1); + for reading in PRO { + hold(&mut wizard, reading, 0.5); + hold(&mut wizard, [0; 6], 0.1); + } + let calibration = wizard.result().unwrap().unwrap(); + assert_eq!(calibration.axes[0].dead_zone, 12); + // Even landing late, a few frames past the first half second, one jolt isn't the dead zone. + let mut recording = record(PRO, STILL); + for _ in 0..3 { + recording.touch([90, 0, 0, 0, 0, 0]); + } + for _ in 0..97 { + recording.touch([10, 0, 0, 0, 0, 0]); + } + assert_eq!(recording.solve().unwrap().axes[0].dead_zone, 10); + } + + #[test] + fn a_slow_trackball_roll_maps_straight_while_a_cap_feathers_in() { + let axis = AxisCalibration { source: 0, reversed: false, rest: 0, dead_zone: 0, full: [350; 2] }; + let ball = Calibration { dead_zone_share: 0.0, kind: Kind::Trackball, axes: [axis; 6] }; + assert_eq!(ball.apply([9, 0, 0, 0, 0, 0])[0], 9); + assert_eq!(ball.apply([350, 0, 0, 0, 0, 0])[0], FULL_SCALE); + let cap = Calibration { kind: Kind::ReturnToCenter, ..ball }; + assert!(cap.apply([9, 0, 0, 0, 0, 0])[0] < 9); + } + + #[test] + fn the_response_feathers_in_past_the_dead_zone() { + let axis = AxisCalibration { source: 0, reversed: false, rest: 0, dead_zone: 20, full: [370; 2] }; + let calibration = Calibration { dead_zone_share: 0.0, kind: Kind::default(), axes: [axis; 6] }; + let read = |value: i16| calibration.apply([value, 0, 0, 0, 0, 0])[0]; + // The dead zone's edge is still where motion starts, and it starts small. + assert_eq!((read(20), read(21)), (0, 1)); + // Easing in: under the straight line it used to follow, which had 30 past the edge at 30. + assert!(read(50) < 30 && read(50) > 0); + // Never steps back or jumps, and still reaches full scale. + let response: Vec = (0..=370).map(read).collect(); + assert!(response.windows(2).all(|w| w[1] >= w[0] && w[1] - w[0] <= 2), "{response:?}"); + assert_eq!(read(370), FULL_SCALE); + assert_eq!(read(-370), -FULL_SCALE); + } + + #[test] + fn a_steady_lean_near_the_dead_zone_drops_but_a_held_push_stays() { + let mut settle = Settle::default(); + let frames = |settle: &mut Settle, reading: [i16; 6], seconds: f64| { + let mut out = [0; 6]; + for _ in 0..(seconds / 0.02).round() as usize { + out = settle.filter(reading, 0.02); + } + out + }; + // A hand leaning: 30 on X, jittering by 2, then held there. + assert_eq!(frames(&mut settle, [30, 0, 0, 0, 0, 0], 0.1)[0], 30, "fresh, it moves"); + frames(&mut settle, [32, 0, 0, 0, 0, 0], 0.1); + assert_eq!(frames(&mut settle, [31, 0, 0, 0, 0, 0], SETTLE_AFTER_SECONDS)[0], 0, "steady: dropped"); + // Moving again moves at once. + assert_eq!(settle.filter([45, 0, 0, 0, 0, 0], 0.02)[0], 45); + // A held push past the band stays, however steady. + assert_eq!(frames(&mut settle, [0, 0, 0, 0, 0, 200], 2.0)[5], 200); + // Each axis on its own, and nothing odd for odd times. + assert!(settle.filter([10, -10, 0, 0, 0, 0], f64::NAN).iter().all(|v| v.abs() <= 10)); + } } diff --git a/crates/space-mouse/src/lib.rs b/crates/space-mouse/src/lib.rs index 6a2af5f2e5..07cae441d7 100644 --- a/crates/space-mouse/src/lib.rs +++ b/crates/space-mouse/src/lib.rs @@ -10,10 +10,12 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +pub mod buttons; mod calibration; mod descriptor; pub use calibration::{ - AxisCalibration, Calibration, HOLD_SECONDS, MAX_DEAD_ZONE_SHARE, MIN_SHARE, Problem, REST_SECONDS, Recording, STEPS, Stage, Step, Wizard, + AxisCalibration, Calibration, FEATHER_SHARE, HOLD_SECONDS, Kind, MAX_DEAD_ZONE_SHARE, MIN_SHARE, NO_SOURCE, Problem, REST_SECONDS, + RESTARTS_FOR_TRACKBALL, Recording, SETTLE_AFTER_SECONDS, SETTLE_SHARE, SETTLE_WANDER, STEPS, Settle, Stage, Step, Wizard, }; pub use descriptor::{Descriptor, FULL_SCALE, Field, Layout, MAX_BUTTONS, Report}; diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index ef706b1041..c64daff3fa 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -31,6 +31,10 @@ zip = { workspace = true } # Rasterises the icon SVGs (assets/icons/ui/design/svg) at the size they are drawn. tiny-skia = { workspace = true } +[target.'cfg(unix)'.dependencies] +# Ends an agent's process group with the system call (MIT; already in the tree through zip). +nix = { workspace = true } + [target.'cfg(windows)'.dependencies] # Updates: the folder the Windows installer recorded, so an install outside Program Files is still # updated by its installer (MIT; already in the tree as a build dependency). diff --git a/crates/ui-egui/src/agent_chat/markdown_view.rs b/crates/ui-egui/src/agent_chat/markdown_view.rs index 8c6d6da585..4b8dfed147 100644 --- a/crates/ui-egui/src/agent_chat/markdown_view.rs +++ b/crates/ui-egui/src/agent_chat/markdown_view.rs @@ -94,7 +94,7 @@ pub fn show(ui: &mut egui::Ui, slot: usize, src: &str) { ui.add(egui::Label::new(RichText::new(markdown::plain(s)).size(size).semibold().color(t.text)).selectable(true).wrap()); } Block::Code { text, .. } => { - egui::Frame::NONE.fill(t.field).corner_radius(4.0).inner_margin(Margin::same(6)).show(ui, |ui| { + egui::Frame::NONE.fill(t.field).corner_radius(t.radius_sm).inner_margin(Margin::same(6)).show(ui, |ui| { ui.set_width(ui.available_width()); ui.add( egui::Label::new(RichText::new(text).monospace().size(size - (t.font_button - t.font_body)).color(t.text)) @@ -118,7 +118,7 @@ pub fn show(ui: &mut egui::Ui, slot: usize, src: &str) { Block::Quote(s) => { ui.horizontal_top(|ui| { let (bar, _) = ui.allocate_exact_size(vec2(3.0, 16.0), egui::Sense::hover()); - ui.painter().rect_filled(bar, 1.0, t.border); + ui.painter().rect_filled(bar, t.radius_sm / 4, t.border); ui.vertical(|ui| spans(ui, s, size, t.text_dim)); }); } diff --git a/crates/ui-egui/src/agent_chat/panel.rs b/crates/ui-egui/src/agent_chat/panel.rs index 2039db34d9..7d58ad5f89 100644 --- a/crates/ui-egui/src/agent_chat/panel.rs +++ b/crates/ui-egui/src/agent_chat/panel.rs @@ -265,11 +265,11 @@ fn picture_strip(ui: &mut egui::Ui, n: usize, pictures: &[Picture]) -> Option { - let resp = ui.add(egui::Image::new((texture.id(), fit(texture.size_vec2(), max))).corner_radius(4.0)); + let resp = ui.add(egui::Image::new((texture.id(), fit(texture.size_vec2(), max))).corner_radius(t.radius_sm)); crate::scenario::publish_rect("agent_chat:gallery_picture", resp.rect); } Shown::Loading => { @@ -361,7 +361,7 @@ fn drop_hint(ctx: &egui::Context, area: Rect) { let t = Tokens::get(); let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, Id::new("sc_agent_chat_drop_hint"))); let zone = area.shrink(6.0); - painter.rect(zone, 10.0, t.accent.gamma_multiply(0.16), Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); + painter.rect(zone, t.radius_xl, t.accent.gamma_multiply(0.16), Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); let font = egui::FontId::proportional(Tokens::get().font_heading); let text = painter.layout(tr("ui.agent_chat_drop_hint").to_string(), font, t.text, (zone.width() - 24.0).max(40.0)); let at = zone.center() - text.size() / 2.0; @@ -472,7 +472,7 @@ fn glyph_button(ui: &mut egui::Ui, kind: &str, tip: &str) -> egui::Response { let t = Tokens::get(); let (r, resp) = ui.allocate_exact_size(vec2(t.form_close_size, t.form_close_size), Sense::click()); if resp.hovered() { - ui.painter().rect_filled(r, 4.0, t.hover); + ui.painter().rect_filled(r, t.radius_sm, t.hover); } let c = r.center(); let s = Stroke::new(1.4, t.text); @@ -534,7 +534,7 @@ fn strip(app: &mut SolveApp, ui: &mut egui::Ui) { let resp = ui.interact(button, Id::new("sc_agent_chat_unfold"), Sense::click()); crate::scenario::publish_handle("agent_chat:unfold", button.center()); if resp.hovered() { - ui.painter().rect_filled(button, 4.0, t.hover); + ui.painter().rect_filled(button, t.radius_sm, t.hover); } ui_kit::disclosure(ui.painter(), button.center(), false, t.text); let state = app.agent_chat.state(); @@ -553,7 +553,7 @@ fn strip(app: &mut SolveApp, ui: &mut egui::Ui) { } ui.painter().line_segment( if app.preferences.agent_chat_side == "left" { [r.right_top(), r.right_bottom()] } else { [r.left_top(), r.left_bottom()] }, - Stroke::new(1.0, t.border), + t.primary_border(t.border), ); let tip = format!("{} · {}", tip(), state.label()); if resp.on_hover_text(tip).clicked() { @@ -1298,7 +1298,7 @@ fn conversation(app: &mut SolveApp, ui: &mut egui::Ui) { Container::new(ui.ctx(), Surface::Panel) .edge_frame(t.panel) .stroke(Stroke::new(1.0, t.accent)) - .corner_radius(6.0) + .corner_radius(t.control_radius) .inner_margin(egui::Margin::same(6)) .show(ui, |ui| { ui.set_width(ui.available_width()); @@ -1481,9 +1481,12 @@ pub(super) fn remove_tip(f: &attach::Attachment) -> &'static str { fn loading_chip(ui: &mut egui::Ui, name: &str, importing: bool, i: usize, w: f32) -> bool { let t = Tokens::get(); let mut clicked = false; - let r = egui::Frame::NONE.fill(t.toolbar).stroke(Stroke::new(1.0, t.border)).corner_radius(6.0).inner_margin(egui::Margin::symmetric(4, 3)).show( - ui, - |ui| { + let r = egui::Frame::NONE + .fill(t.toolbar) + .stroke(t.control_border_stroke(t.border)) + .corner_radius(t.control_radius) + .inner_margin(egui::Margin::symmetric(4, 3)) + .show(ui, |ui| { let inner = (w - 10.0).max(40.0); ui.set_width(inner); ui.horizontal(|ui| { @@ -1505,8 +1508,7 @@ fn loading_chip(ui: &mut egui::Ui, name: &str, importing: bool, i: usize, w: f32 crate::scenario::publish_handle(&format!("agent_chat:file_loading_remove:{i}"), c); clicked = x.on_hover_text(tr(if importing { "ui.agent_chat_remove_imported" } else { "ui.agent_chat_remove_file" })).clicked(); }); - }, - ); + }); crate::scenario::publish_handle(&format!("agent_chat:file_loading:{i}"), r.response.rect.center()); clicked } @@ -1516,9 +1518,12 @@ fn loading_chip(ui: &mut egui::Ui, name: &str, importing: bool, i: usize, w: f32 fn chip(ui: &mut egui::Ui, thumbs: &mut std::collections::HashMap, f: &attach::Attachment, i: usize, w: f32) -> bool { let t = Tokens::get(); let mut clicked = false; - let r = egui::Frame::NONE.fill(t.toolbar).stroke(Stroke::new(1.0, t.border)).corner_radius(6.0).inner_margin(egui::Margin::symmetric(4, 3)).show( - ui, - |ui| { + let r = egui::Frame::NONE + .fill(t.toolbar) + .stroke(t.control_border_stroke(t.border)) + .corner_radius(t.control_radius) + .inner_margin(egui::Margin::symmetric(4, 3)) + .show(ui, |ui| { let inner = (w - 10.0).max(40.0); ui.set_width(inner); ui.horizontal(|ui| { @@ -1561,8 +1566,7 @@ fn chip(ui: &mut egui::Ui, thumbs: &mut std::collections::HashMap Signal::SIGKILL, + _ => Signal::SIGTERM, + }; + // The group is signalled with the system call, not with a `kill` program: busybox' `kill` + // takes no `--` and signals nothing, and the shell's builtin parses the group differently + // from one shell to the next (#4587). + let _ = killpg(Pid::from_raw(pgid), signal); } /// Windows: end the agent and every process it started. diff --git a/crates/ui-egui/src/agent_chat/settings.rs b/crates/ui-egui/src/agent_chat/settings.rs index 27cafaa0b8..853228b540 100644 --- a/crates/ui-egui/src/agent_chat/settings.rs +++ b/crates/ui-egui/src/agent_chat/settings.rs @@ -140,6 +140,9 @@ pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) { let response = ui_kit::checkbox(ui, tr("ai.prefs.background_commands"), &mut app.preferences.agent_off_thread) .on_hover_text(tr("ui.agent_off_thread_note")); crate::scenario::publish_handle("prefs:agent_off_thread", response.rect.center()); + let response = ui_kit::checkbox(ui, tr("ai.prefs.allow_control"), &mut app.preferences.agent_control) + .on_hover_text(tr("ai.prefs.allow_control_note")); + crate::scenario::publish_handle("prefs:agent_control", response.rect.center()); } if cfg!(target_arch = "wasm32") { ui_kit::text(ui, tr("ui.agent_chat_desktop_only"), ui_kit::Text::Hint("desktop")); diff --git a/crates/ui-egui/src/browser.rs b/crates/ui-egui/src/browser.rs index 09d72b82e3..c38ff988ca 100644 --- a/crates/ui-egui/src/browser.rs +++ b/crates/ui-egui/src/browser.rs @@ -68,7 +68,9 @@ pub struct TreeState { pub collapsed: BTreeSet, /// Folders opened by the user (folders that start closed). pub expanded: BTreeSet, - /// Scroll to this body's row and open its folders (Find in Browser). + /// Scroll to this body's row (by the name it has in the world, which tells the placements + /// of a part apart) and open the components, placements and folders above it: Find in + /// Browser, and a body picked in the viewport (#3005). pub reveal: Option, /// A native feature to reveal in its placed component's Features folder. pub(super) reveal_feature: Option, @@ -360,9 +362,9 @@ pub(crate) fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp { let dragged_over = egui::DragAndDrop::has_any_payload(ui.ctx()) && ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| r.contains(p)); let mut out = RowResp { rect: Some(r), hovered: resp.hovered() || dragged_over, ..Default::default() }; if row.selected { - ui.painter().rect_filled(r, 3.0, crate::theme::TabAccent::current(ui.ctx()).soft); + ui.painter().rect_filled(r, t.radius_sm.saturating_sub(1), crate::theme::TabAccent::current(ui.ctx()).soft); } else if resp.hovered() || row.mark == Mark::Here { - ui.painter().rect_filled(r, 3.0, t.hover); + ui.painter().rect_filled(r, t.radius_sm.saturating_sub(1), t.hover); } // A lit item (selected, or under the pointer in the 3D view): a bar down the left of its row, // and a fainter, thinner one on each row leading to it, so the bars trace its branch. @@ -372,7 +374,7 @@ pub(crate) fn draw_row(ui: &mut egui::Ui, id: egui::Id, row: &Row) -> RowResp { Mark::Here => Some((3.0, t.accent)), }; if let Some((w, colour)) = bar { - ui.painter().rect_filled(Rect::from_min_size(r.left_top() + vec2(0.0, 2.0), vec2(w, r.height() - 4.0)), 1.0, colour); + ui.painter().rect_filled(Rect::from_min_size(r.left_top() + vec2(0.0, 2.0), vec2(w, r.height() - 4.0)), t.radius_sm / 4, colour); } let mut x = r.left() + 2.0 + row.depth as f32 * INDENT; let cy = r.center().y; @@ -529,6 +531,31 @@ fn sel_of_key(k: &str) -> Option { None } +/// The body a selection item is on: a body, or a face or edge of one (by its name in the +/// world). +pub(crate) fn picked_body(sel: &Sel) -> Option<&str> { + match sel { + Sel::Body { name } => Some(name), + Sel::Face { body, .. } | Sel::Edge { body, .. } => Some(body), + _ => None, + } +} + +/// A pick in the viewport: the Browser, when shown, opens down to the picked body's row and +/// scrolls to it (#3005). The body of the last item picked. +pub(crate) fn reveal_pick(app: &mut SolveApp) { + if app.ui.show_browser + && let Some(body) = app.session.selection.last().and_then(picked_body) + { + app.tree.reveal = Some(body.to_string()); + } +} + +/// Whether the body being revealed is in this placement or one below it: it opens. +fn reveals_in_occurrence(app: &SolveApp, path: &[u64]) -> bool { + app.tree.reveal.as_ref().is_some_and(|r| app.session.occurrence_bodies(path, true).is_ok_and(|bodies| bodies.contains(r))) +} + pub(crate) fn is_open(app: &SolveApp, key: &str, default: bool) -> bool { if default { !app.tree.collapsed.contains(key) } else { app.tree.expanded.contains(key) } } @@ -819,7 +846,7 @@ fn browser_contents(app: &mut SolveApp, ui: &mut egui::Ui, acts: &mut Actions, c { return; } - ui.painter().hline(ui.max_rect().x_range(), ui.cursor().top() + 1.0, egui::Stroke::new(1.0, t.toolbar.lerp_to_gamma(t.border, 0.5))); + t.secondary_hline(ui.painter(), ui.max_rect().x_range(), ui.cursor().top() + 1.0, t.toolbar.lerp_to_gamma(t.border, 0.5)); ui.add_space(t.space_3); if collapsible && !stacked { browser_rows_hugging(app, ui, acts); @@ -947,6 +974,9 @@ fn rows_scrolling(app: &mut SolveApp, ui: &mut egui::Ui, acts: &mut Actions, shr ui.spacing_mut().item_spacing.y = 0.0; component_rows(app, ui, 0, 0, acts); }); + // The row was reached and scrolled to, or isn't in the tree (a body a later feature removes): + // either way the reveal is done. + app.tree.reveal = None; } fn finish_browser(app: &mut SolveApp, ui: &mut egui::Ui, acts: Actions) { @@ -977,9 +1007,14 @@ fn component_name(app: &SolveApp, id: u64) -> String { fn component_rows(app: &mut SolveApp, ui: &mut egui::Ui, id: u64, depth: usize, acts: &mut Actions) { let t = Tokens::get(); let key = format!("c{id}"); + let bodies = crate::context_menu::component_bodies(app, id); + if let Some(r) = app.tree.reveal.clone() + && (bodies.contains(&r) || model_children(app, id).into_iter().any(|o| reveals_in_occurrence(app, &[o]))) + { + app.tree.collapsed.remove(&key); + } let open = is_open(app, &key, true); let active = app.session.active_component == id; - let bodies = crate::context_menu::component_bodies(app, id); let visible = bodies.is_empty() || bodies.iter().any(|b| !app.session.visibility.hidden_bodies.contains(b)); let grounded = app.session.doc.occurrence_of(id).is_some_and(|o| o.grounded); let faded = app.session.active_component != 0 && !app.session.doc.component_within(id, app.session.active_component) && !active; @@ -1082,6 +1117,9 @@ fn occurrence_key(path: &[u64]) -> String { fn occurrence_rows(app: &mut SolveApp, ui: &mut egui::Ui, path: Vec, depth: usize, acts: &mut Actions) { let Some(placement) = model_placement(app, &path) else { return }; let key = occurrence_key(&path); + if reveals_in_occurrence(app, &path) { + app.tree.collapsed.remove(&key); + } let open = is_open(app, &key, true); let active = app.session.active_occurrence_path.as_deref() == Some(path.as_slice()) || (app.session.active_occurrence_path.is_none() @@ -1180,6 +1218,8 @@ fn occurrence_rows(app: &mut SolveApp, ui: &mut egui::Ui, path: Vec, depth: } } +/// The Document Settings group. A second click on an entry closes the window it opened, the way +/// that window's own × does (#4150). fn doc_settings(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize) { let key = "docsettings"; let open = is_open(app, key, false); @@ -1192,8 +1232,12 @@ fn doc_settings(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize) { } let units = format!("Units: {}", app.session.doc.units); let r = draw_row(ui, ui.id().with("units"), &Row { depth: depth + 1, icon: "settings", label: &units, ..Default::default() }); - if let Some(p) = r.secondary.or(r.clicked.then(|| r.rect.map(|x| x.left_bottom())).flatten()) { + if let Some(p) = r.secondary { crate::context_menu::open_for(app, p, Target::Units); + } else if r.clicked + && let Some(rect) = r.rect + { + crate::context_menu::toggle_for(app, rect, rect.left_bottom(), Target::Units); } let prec = format!( "Precision: {} {}, {}°", @@ -1202,16 +1246,28 @@ fn doc_settings(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize) { format_args!("{:.*}", usize::from(app.preferences.angle_decimals), 0.0) ); if draw_row(ui, ui.id().with("precision"), &Row { depth: depth + 1, icon: "settings", label: &prec, ..Default::default() }).clicked { - app.prefs_window.open = true; + if app.prefs_window.open { + crate::prefs::close(app, true); + } else { + app.prefs_window.open = true; + } } let params = format!("Parameters ({})", app.session.doc.params.len()); if draw_row(ui, ui.id().with("params"), &Row { depth: depth + 1, icon: "params", label: ¶ms, ..Default::default() }).clicked { - app.start("parameters.change"); + if app.params.is_some() { + app.params = None; + } else { + app.start("parameters.change"); + } } if draw_row(ui, ui.id().with("design_properties"), &Row { depth: depth + 1, icon: "settings", label: "Design Properties", ..Default::default() }) .clicked { - app.start("document.metadata"); + if app.design_properties.is_some() { + app.design_properties = None; + } else { + app.start("document.metadata"); + } } } @@ -1587,8 +1643,8 @@ fn dialog_takes_body(app: &SolveApp) -> bool { app.dialog.as_ref().and_then(|d| d.active_input()).is_some_and(|i| i.accept & crate::selection::BODIES != 0) } -/// A browser click on a plane or axis: it goes to the open dialog's input if that takes it, -/// otherwise it becomes the selection. +/// Browser picks go only into compatible active dialog inputs. Outside a dialog, planes and +/// axes become the selection; body rows use the ordinary Ctrl/Shift-aware selection path. fn pick_from_browser(app: &mut SolveApp, x: Sel) { if let Sel::Body { .. } = &x && dialog_takes_body(app) @@ -1820,11 +1876,11 @@ fn folder_rows_at(app: &mut SolveApp, ui: &mut egui::Ui, comp: u64, path: Option let key = format!("{prefix}/{folder}"); if let Some(r) = app.tree.reveal.clone() && folder == "bodies" - && all.iter().any(|e| e.key == r) + && let Some(e) = all.iter().find(|e| e.row_key == format!("b:{r}")) { app.tree.collapsed.remove(&key); app.tree.collapsed.remove(&format!("c{comp}")); - for g in app.session.doc.browser_groups.iter().filter(|g| g.items.contains(&r)) { + for g in app.session.doc.browser_groups.iter().filter(|g| g.items.contains(&e.key)) { app.tree.collapsed.remove(&format!("g{}", g.id)); } } @@ -1972,7 +2028,7 @@ fn entry_row( toggle(app, &pattern_key, true); } if folder == "bodies" - && app.tree.reveal.as_deref() == Some(e.key.as_str()) + && app.tree.reveal.as_ref().is_some_and(|b| e.row_key.strip_prefix("b:") == Some(b.as_str())) && let Some(rect) = r.rect { ui.scroll_to_rect(rect, Some(egui::Align::Center)); @@ -1989,12 +2045,10 @@ fn entry_row( app.edit_sketch(id); } } else if r.clicked { - if folder == "construction" { + if folder == "construction" || (folder == "bodies" && app.dialog.is_some()) { if let Some(s) = e.sel.clone() { pick_from_browser(app, s); } - } else if let Some(s) = e.sel.clone().filter(|s| matches!(s, Sel::Body { .. }) && dialog_takes_body(app)) { - pick_from_browser(app, s); } else { click_select(app, ui, e.row_key.clone(), e.sel.clone()); } @@ -2057,7 +2111,9 @@ fn drop_target(ui: &egui::Ui, resp: &egui::Response, accept: impl Fn(&Drag) -> b let r = resp.rect; match mark { DropMark::Above => ui.painter().hline(r.x_range(), r.top(), Stroke::new(2.0, t.accent)), - DropMark::Box => ui.painter().rect_stroke(r.shrink(0.5), 3.0, Stroke::new(1.5, t.accent), egui::StrokeKind::Inside), + DropMark::Box => { + ui.painter().rect_stroke(r.shrink(0.5), t.radius_sm.saturating_sub(1), Stroke::new(1.5, t.accent), egui::StrokeKind::Inside) + } }; } // Taking a payload takes it whatever its type: leave other drags (appearance swatches) be. @@ -2221,7 +2277,7 @@ pub fn dims_overlay(app: &SolveApp, ctx: &egui::Context) { } let Some(tc) = to(lay.text) else { continue }; let r = Rect::from_center_size(tc, size); - painter.rect_filled(r, 2.0, tk.viewport_top.gamma_multiply(0.85)); + painter.rect_filled(r, tk.radius_sm / 2, tk.viewport_top.gamma_multiply(0.85)); painter.galley(r.min + vec2(3.0, 1.0), galley, col); } } @@ -2243,7 +2299,10 @@ pub const SAVE: &str = "__save"; /// that capture, revert or keep moving. pub fn needs_capture(app: &SolveApp, what: &str) -> bool { !app.session.pending_moves.is_empty() - && !matches!(what, "component.capture_position" | "component.revert_position" | "occurrence.move" | "edit.undo" | "edit.redo") + && !matches!( + what, + "component.capture_position" | "component.revert_position" | "occurrence.move" | "occurrence.drag" | "edit.undo" | "edit.redo" + ) && app.tree.capture_prompt.is_none() } diff --git a/crates/ui-egui/src/browser/features.rs b/crates/ui-egui/src/browser/features.rs index 8177fd11fa..0e66b85fcd 100644 --- a/crates/ui-egui/src/browser/features.rs +++ b/crates/ui-egui/src/browser/features.rs @@ -109,7 +109,7 @@ mod tests { h.app.tree.expanded.insert("c0/features".into()); DRAWN.with(|d| d.borrow_mut().clear()); let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; - let _ = h.ctx.run_ui(raw, |ui| rows(&mut h.app, ui, 0, &[], 0, &mut Actions::default())); + h.ctx.run_ui(raw, |ui| rows(&mut h.app, ui, 0, &[], 0, &mut Actions::default())).textures_delta.clear(); drawn_rows().into_iter().map(|(label, ..)| label).collect() } diff --git a/crates/ui-egui/src/browser_card_tests.rs b/crates/ui-egui/src/browser_card_tests.rs index 7a208131fc..8dac69e0ed 100644 --- a/crates/ui-egui/src/browser_card_tests.rs +++ b/crates/ui-egui/src/browser_card_tests.rs @@ -2,6 +2,59 @@ use super::*; use crate::scenario::{Harness, handle_at, rect_of}; use crate::shell::{LayoutMode, SHELL_GAP}; +#[test] +fn browser_body_clicks_fill_and_toggle_save_mesh_inputs() { + for mode in [LayoutMode::Floating, LayoutMode::Docked] { + let mut h = Harness::new(); + h.app.ui.layout = mode; + for (name, x) in [("One", 0), ("Two", 20)] { + h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"corner":[x,0,0],"operation":"new_body","body_name":name})).unwrap(); + } + h.app.start("file.save_mesh"); + h.settle(); + for (clicked, expected) in [("One", vec!["One"]), ("Two", vec!["One", "Two"]), ("One", vec!["Two"])] { + h.step(&json!({"click":{"handle":format!("row:{clicked}")}})).unwrap(); + let d = h.app.dialog.as_ref().unwrap(); + let crate::dialogs::Kind::SaveMesh(k) = &d.kind else { panic!("Save as Mesh is open") }; + let commands = crate::dialogs_save_mesh::commands(k, &d.inputs).unwrap(); + assert_eq!(commands[0].1["bodies"], json!(expected), "{mode:?}: clicked {clicked}"); + assert!(crate::dialogs_save_mesh::ready(&d.inputs)); + assert!(h.app.session.selection.is_empty(), "dialog picks do not change the global selection"); + } + h.step(&json!({"click":{"handle":"row:Two"}})).unwrap(); + assert!(!crate::dialogs_save_mesh::ready(&h.app.dialog.as_ref().unwrap().inputs)); + h.step(&json!({"key":"Escape"})).unwrap(); + h.step(&json!({"click":{"handle":"row:One"}})).unwrap(); + assert_eq!(h.app.session.selection, vec![Sel::Body { name: "One".into() }]); + } +} + +#[test] +fn browser_body_clicks_respect_the_active_dialog_input() { + let mut h = Harness::new(); + for (name, x) in [("One", 0), ("Two", 20)] { + h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"corner":[x,0,0],"operation":"new_body","body_name":name})).unwrap(); + } + h.app.start("solid.combine"); + h.app.dialog.as_mut().unwrap().preview_enabled = false; + h.settle(); + h.step(&json!({"click":{"handle":"row:One"}})).unwrap(); + assert_eq!(h.app.dialog.as_ref().unwrap().active, 1, "a target pick advances to Tool Bodies"); + h.step(&json!({"click":{"handle":"row:Two"}})).unwrap(); + let d = h.app.dialog.as_ref().unwrap(); + assert_eq!(d.inputs[0].items, vec![Sel::Body { name: "One".into() }]); + assert_eq!(d.inputs[1].items, vec![Sel::Body { name: "Two".into() }]); + h.call("ui.dialogInput", json!({"index":0})); + h.step(&json!({"click":{"handle":"row:Two"}})).unwrap(); + assert_eq!(h.app.dialog.as_ref().unwrap().inputs[0].items, vec![Sel::Body { name: "Two".into() }], "a single input replaces its pick"); + h.step(&json!({"key":"Escape"})).unwrap(); + h.app.start("solid.fillet"); + h.settle(); + h.step(&json!({"click":{"handle":"row:One"}})).unwrap(); + assert!(h.app.dialog.as_ref().unwrap().inputs.iter().all(|i| i.items.is_empty()), "a body is not an edge"); + assert!(h.app.session.selection.is_empty(), "an incompatible pick does not leak into the global selection"); +} + #[test] fn browser_card_shrinks_and_scrolls_without_covering_history() { let mut h = Harness::new(); @@ -99,6 +152,74 @@ fn card_and_column_keep_body_eyes_menus_and_group_drops() { } } +/// A second click on a Document Settings entry closes the window it opened, the way that +/// window's own × does, and a third opens it again (#4150): the Units menu, Preferences (a change +/// made there is put back), Parameters (a value typed but not applied is dropped) and Design +/// Properties (nothing typed there is saved). +#[test] +fn a_second_click_on_a_document_settings_entry_closes_its_window() { + for mode in [LayoutMode::Floating, LayoutMode::Docked] { + let mut h = Harness::new(); + h.app.ui.layout = mode; + h.app.run("parameters.add", json!({"name":"width","expression":"10 mm"})).unwrap(); + h.settle(); + h.step(&json!({"click":{"chevron":"Document Settings"}})).unwrap(); + h.settle(); + // The Units, Precision and Parameters labels carry their values. + let row = |start: &str| { + let (_, rect, _, _) = drawn_rows().into_iter().find(|(label, ..)| label.starts_with(start)).unwrap(); + json!({"click":[rect.center().x, rect.center().y]}) + }; + + h.step(&row("Units")).unwrap(); + assert!(h.app.viewport.context_menu.is_some() && h.app.menu.target == Target::Units, "{mode:?}: the first click opens the Units menu"); + h.step(&row("Units")).unwrap(); + h.frames(2); + assert!(h.app.viewport.context_menu.is_none(), "{mode:?}: the second click closes it instead of reopening it"); + h.step(&row("Units")).unwrap(); + assert!(h.app.viewport.context_menu.is_some(), "{mode:?}: and a third opens it again"); + h.step(&json!({"key":"Escape"})).unwrap(); + + h.step(&row("Precision")).unwrap(); + h.frames(2); + assert!(h.app.prefs_window.open, "{mode:?}: the first click opens Preferences"); + let decimals = h.app.preferences.angle_decimals; + h.app.preferences.angle_decimals = decimals + 2; + h.step(&row("Precision")).unwrap(); + h.frames(2); + assert!(!h.app.prefs_window.open, "{mode:?}: the second click closes it"); + assert_eq!(h.app.preferences.angle_decimals, decimals, "{mode:?}: and puts back what changed there, as × does"); + h.step(&row("Precision")).unwrap(); + h.frames(2); + assert!(h.app.prefs_window.open, "{mode:?}: a third opens it again"); + h.step(&json!({"key":"Escape"})).unwrap(); + + h.step(&row("Parameters")).unwrap(); + h.frames(2); + assert!(h.app.params.is_some(), "{mode:?}: the first click opens Parameters"); + h.step(&json!({"click":{"handle":"param:width"}})).unwrap(); + h.step(&json!({"key":"A","cmd":true})).unwrap(); + h.step(&json!({"text":"25 mm"})).unwrap(); + h.step(&row("Parameters")).unwrap(); + h.frames(2); + assert!(h.app.params.is_none(), "{mode:?}: the second click closes it"); + assert_eq!(h.app.session.doc.param("width").unwrap().expr, "10 mm", "{mode:?}: and drops a value typed but not applied"); + h.step(&row("Parameters")).unwrap(); + assert!(h.app.params.is_some(), "{mode:?}: a third opens it again"); + h.app.params = None; + + h.step(&row("Design Properties")).unwrap(); + assert!(h.app.design_properties.is_some(), "{mode:?}: the first click opens Design Properties"); + h.app.design_properties.as_mut().unwrap().fields[0] = "Typed".into(); + h.frames(2); + h.step(&row("Design Properties")).unwrap(); + assert!(h.app.design_properties.is_none(), "{mode:?}: the second click closes it"); + assert_eq!(h.app.session.doc.metadata.project(), None, "{mode:?}: and saves nothing typed there"); + h.step(&row("Design Properties")).unwrap(); + assert!(h.app.design_properties.is_some(), "{mode:?}: a third opens it again"); + } +} + #[test] fn browser_header_folds_the_floating_card_to_its_header_and_back() { let mut h = Harness::new(); diff --git a/crates/ui-egui/src/browser_model_tests.rs b/crates/ui-egui/src/browser_model_tests.rs index c2c3f41a86..b0b0c9e967 100644 --- a/crates/ui-egui/src/browser_model_tests.rs +++ b/crates/ui-egui/src/browser_model_tests.rs @@ -81,3 +81,28 @@ fn body_index_and_placements_follow_geometry_undo_and_document_identity() { assert!(super::model_children(&h.app, 0).is_empty()); assert!(super::entries(&h.app, component, "bodies").is_empty()); } + +/// #3005: a face picked in the viewport, on the second placement of a part whose rows are +/// closed: that placement opens down to its body's row (which the browser lights for the pick, +/// #3009); the first placement stays closed. +#[test] +fn a_face_picked_in_the_viewport_opens_the_browser_to_its_placements_body() { + let (mut h, _, occurrence) = fixture(); + let copy = h.app.run("occurrence.copy", json!({"occurrence":occurrence,"translate":[50,0,0]})).unwrap()["occurrence"].as_u64().unwrap(); + let first = super::model_placement(&mut h.app, &[occurrence]).unwrap().bodies[0].world.clone(); + let second = super::model_placement(&mut h.app, &[copy]).unwrap().bodies[0].world.clone(); + h.app.ui.show_browser = true; + let (one, two) = (super::occurrence_key(&[occurrence]), super::occurrence_key(&[copy])); + h.app.tree.collapsed.extend([one.clone(), two.clone(), format!("{two}/bodies")]); + h.frames(2); + assert!(!h.app.tree.order.contains(&format!("b:{second}")), "the body's row is closed away"); + let face = Sel::Face { body: second.clone(), index: 0, point: solidcraft_engine::geom::Vec3::new(55.0, 5.0, 10.0) }; + crate::viewport::select(&mut h.app, Some(face), false, false); + assert_eq!(h.app.tree.reveal.as_deref(), Some(second.as_str())); + h.frames(2); + assert!(h.app.tree.reveal.is_none(), "revealed"); + assert!(!h.app.tree.collapsed.contains(&two) && !h.app.tree.collapsed.contains(&format!("{two}/bodies"))); + assert!(h.app.tree.collapsed.contains(&one), "the other placement stays as it was"); + assert!(h.app.tree.order.contains(&format!("b:{second}")), "the row is drawn"); + assert!(!h.app.tree.order.contains(&format!("b:{first}")), "the other placement's row stays closed away"); +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 72ca873637..8bb0481d66 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -345,6 +345,8 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj draw(app, ui, painter, proj, &mut d, base, bs, dir); } app.dialog = Some(d); + // A Single hole's handles, over the depth arrow's stem: a press on a hole moves it. + crate::hole_place::show(app, ui, painter, proj); } /// A round step for dragged values at this zoom (1, 2 or 5 times a power of ten). @@ -373,7 +375,7 @@ fn along(proj: &Proj, pos: Pos2, base: Vec3, dir: Vec3) -> Option { Some((b * e - d) / den) } -fn format_len(v: f64) -> String { +pub(crate) fn format_len(v: f64) -> String { let s = format!("{v:.3}"); let s = s.trim_end_matches('0').trim_end_matches('.'); format!("{} mm", if s == "-0" { "0" } else { s }) @@ -443,7 +445,7 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, | Kind::OffsetFaces { .. } | Kind::SketchModify(crate::sketch_modify_ui::Mod::Move { .. }) ); - let focus = std::mem::take(&mut d.focus); + let mut focus = std::mem::take(&mut d.focus); let half_height = app.cam.half_height(); let axis = revolve_axis(app, d); if matches!(d.kind, Kind::Revolve { .. }) { @@ -529,6 +531,11 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, opts.all = false; } dir = None; + // A Single hole's distances from its reference edges (#37); a newly picked edge's box + // takes the keyboard. + if crate::hole_refs::canvas(app, ui, painter, proj, d) { + focus = false; + } } // A fillet with a radius spline is shaped by its handles along the edge; the arrow would sit // on one of them. @@ -584,28 +591,42 @@ fn value_box(ui: &mut egui::Ui, label: &str, value: &mut String, at: Pos2, selec /// [`value_box`]; with `keep_tab` Tab stays in the box for the caller (the Move box steps /// through its values with it). fn value_box_with(ui: &mut egui::Ui, label: &str, value: &mut String, at: Pos2, select_all: bool, keep_tab: bool) { + let ids = (egui::Id::new("sc_canvas_area"), egui::Id::new("sc_canvas_value")); + value_box_in(ui, ids, label, value, (at, egui::Align2::LEFT_TOP), select_all, keep_tab); +} + +/// [`value_box_with`] under its own ids (its area's and its field's), for a value beside the main +/// one (a hole's reference distances), with the point of the box that sits at `at`. Returns where +/// the field is. +pub(crate) fn value_box_in( + ui: &mut egui::Ui, + (area, id): (egui::Id, egui::Id), + label: &str, + value: &mut String, + (at, pivot): (Pos2, egui::Align2), + select_all: bool, + keep_tab: bool, +) -> Option { let t = Tokens::get(); - let id = egui::Id::new("sc_canvas_value"); - Container::new(ui.ctx(), Surface::Canvas).area(egui::Id::new("sc_canvas_area")).fixed_pos(at).order(egui::Order::Foreground).show( - ui.ctx(), - |ui| { - Container::popup_frame(ui.style()).inner_margin(egui::Margin::symmetric(6, 3)).show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(egui::RichText::new(crate::i18n::tr_label(label)).color(t.text_dim).size(crate::theme::Tokens::get().font_caption)); - let r = crate::ui_kit::canvas_value_field(ui, id, value, keep_tab); - crate::params_dialog::complete(ui, &r, value); - if select_all { - r.request_focus(); - let n = value.chars().count(); - let mut st = egui::text_edit::TextEditState::load(ui.ctx(), id).unwrap_or_default(); - st.cursor - .set_char_range(Some(egui::text_selection::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(n)))); - st.store(ui.ctx(), id); - } - }); + let mut field = None; + Container::new(ui.ctx(), Surface::Canvas).area(area).fixed_pos(at).pivot(pivot).order(egui::Order::Foreground).show(ui.ctx(), |ui| { + Container::popup_frame(ui.style()).inner_margin(egui::Margin::symmetric(6, 3)).show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(egui::RichText::new(crate::i18n::tr_label(label)).color(t.text_dim).size(crate::theme::Tokens::get().font_caption)); + let r = crate::ui_kit::canvas_value_field(ui, id, value, keep_tab); + crate::params_dialog::complete(ui, &r, value); + if select_all { + r.request_focus(); + let n = value.chars().count(); + let mut st = egui::text_edit::TextEditState::load(ui.ctx(), id).unwrap_or_default(); + st.cursor.set_char_range(Some(egui::text_selection::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(n)))); + st.store(ui.ctx(), id); + } + field = Some(r.rect); }); - }, - ); + }); + }); + field } /// How far (logical points) from an arrow's stem a press still takes the arrow. @@ -917,7 +938,7 @@ fn measure_line(app: &SolveApp, painter: &egui::Painter, proj: &Proj, r: &serde_ if app.preferences.measure_axis_distances { measure_components(app, painter, proj, a, b, rect); } - painter.rect(rect, 3.0, t.overlay, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + painter.rect(rect, t.radius_sm.saturating_sub(1), t.overlay, t.control_border_stroke(t.border), egui::StrokeKind::Inside); painter.galley(rect.min + vec2(5.0, 3.0), galley, t.text); } @@ -977,7 +998,7 @@ fn measure_components(app: &SolveApp, painter: &egui::Painter, proj: &Proj, a: V } } painter.line_segment([at, rect.center()], Stroke::new(0.8, color)); - painter.rect(rect, 3.0, t.overlay, Stroke::new(1.0, color), egui::StrokeKind::Inside); + painter.rect(rect, t.radius_sm.saturating_sub(1), t.overlay, Stroke::new(1.0, color), egui::StrokeKind::Inside); painter.galley(rect.min + vec2(5.0, 3.0), galley, t.text); let handle = format!("measure:label:{axis}"); crate::scenario::publish_target(&handle, rect); diff --git a/crates/ui-egui/src/colour_vision_tests.rs b/crates/ui-egui/src/colour_vision_tests.rs index 2132323f0c..783452373a 100644 --- a/crates/ui-egui/src/colour_vision_tests.rs +++ b/crates/ui-egui/src/colour_vision_tests.rs @@ -379,6 +379,15 @@ const SHADED: [&str; 2] = ["analysis.draft", "analysis.access"]; /// Tokens no colour vision mode needs to keep apart, and why. A new token fails /// `every_token_is_measured_or_carries_no_meaning` until it is in a group above or here. const NO_MEANING: &[(&str, &str)] = &[ + ("borders.primary", "structural chrome: hierarchy uses width, pattern and placement, not hue"), + ("borders.secondary", "structural chrome: hierarchy uses width, pattern and placement, not hue"), + ("borders.secondary_dotted", "structural chrome: hierarchy uses width, pattern and placement, not hue"), + ("borders.tertiary", "structural chrome: hierarchy uses width, pattern and placement, not hue"), + ("borders.container", "structural chrome: hierarchy uses width, pattern and placement, not hue"), + ("borders.control", "structural chrome: hierarchy uses width, pattern and placement, not hue"), + ("borders.primary_inset", "structural chrome: hierarchy uses width, pattern and placement, not hue"), + ("borders.bevel", "structural chrome: hierarchy uses width, pattern and placement, not hue"), + ("icon_palette", "optional SVG palette; actual roles and theme contrast are guarded in icon and theme tests"), ("file_dim", "File view secondary text, WCAG guarded in theme tests"), ("file_recovery_well", "decorative recovery icon ground"), ("file_action_height", "layout dimension"), diff --git a/crates/ui-egui/src/context_menu.rs b/crates/ui-egui/src/context_menu.rs index 77c75f324a..c4efbd375b 100644 --- a/crates/ui-egui/src/context_menu.rs +++ b/crates/ui-egui/src/context_menu.rs @@ -166,7 +166,7 @@ pub(crate) fn cmd(app: &SolveApp, id: &str, label: &str) -> Item { let shortcut = match id { "edit.undo" => "Ctrl+Z".to_string(), "edit.redo" => "Ctrl+Y".to_string(), - _ => crate::keymap::effective(app, id).unwrap_or_default(), + _ => crate::keymap::display_for(app, id).unwrap_or_default(), }; Item { id: id.into(), @@ -346,6 +346,13 @@ pub fn items(app: &SolveApp, target: &Target) -> Vec { Target::Sketch { id } => sketch_items(app, *id), Target::Feature { id } => vec![ act("ui.editFeature", "Edit Feature", "edit").with(json!({"feature":id})).on(app.session.doc.feature(*id).is_some()), + act("ui.rename", "Rename", "rename").with(json!({"feature":id})).on(app.session.doc.feature(*id).is_some()), + act("ui.delete", "Delete", "delete").key("Del").with(json!({"items": [{"type": "feature", "id": id}]})).on(app + .session + .doc + .feature(*id) + .is_some()), + Item::sep(), act("ui.findTimeline", "Find in Timeline", "rollback").with(json!({"feature":id})).on(app.session.doc.feature(*id).is_some()), ], Target::Plane { id } => plane_items(app, *id), @@ -482,6 +489,7 @@ fn viewport_items(app: &SolveApp) -> Vec { v.push(cmd(app, "sketch.offset", "Offset")); v.push(Item::sep()); v.push(act("ui.finishSketch", "Finish Sketch", "finish")); + v.push(act("ui.cancelSketch", "Cancel Sketch", "sketch_cancel")); v.push(Item::sep()); v.push(cmd(app, "edit.undo", "Undo").on(!app.session.undo.is_empty())); v.push(cmd(app, "edit.redo", "Redo").on(!app.session.redo.is_empty())); @@ -1014,6 +1022,7 @@ pub fn run_item(app: &mut SolveApp, item: &Item, at: Pos2) { "ui.delete" if item.params.is_some() => crate::delete::request(app, p), "ui.delete" => delete(app), "ui.finishSketch" => app.finish_sketch(), + "ui.cancelSketch" => app.cancel_sketch(), "ui.relinkSource" => { if let Some(component) = id_of(&p, "component") { crate::linked_source::start(app, component); @@ -1470,15 +1479,7 @@ pub(crate) fn commit_rename(app: &mut SolveApp, r: &Rename) { return; } match &r.what { - RenameWhat::Body(old) => { - if app.run("body.rename", json!({ "body": old, "name": name })).is_ok() { - for list in [&mut app.session.visibility.hidden_bodies, &mut app.ui.locked_bodies] { - for b in list.iter_mut().filter(|b| *b == old) { - *b = name.clone(); - } - } - } - } + RenameWhat::Body(old) => drop(app.run_heavy("body.rename", json!({ "body": old, "name": name }))), RenameWhat::Feature(f) => drop(app.run("timeline.rename", json!({ "feature": f, "name": name }))), RenameWhat::Component(c) => drop(app.run("component.rename", json!({ "component": c, "name": name }))), RenameWhat::Group(g) => crate::browser::group_action(app, "ui.renameGroup", &json!({ "group": g, "name": name })), @@ -1494,7 +1495,8 @@ pub(crate) fn commit_rename(app: &mut SolveApp, r: &Rename) { // --------------------------------------------------------------------------------------------- /// Open the viewport menu at a screen position. A right-click on something not selected -/// selects it first, so the menu is about what is under the cursor. +/// selects it first, so the menu is about what is under the cursor; a face or edge already in a +/// multi-selection keeps the whole selection (#3733). pub fn open(app: &mut SolveApp, at: Pos2) { app.menu.over_geometry = app.viewport.hover.is_some(); // A right-click leaves a plane unselected, but the menu offers a sketch on it. @@ -1503,10 +1505,11 @@ pub fn open(app: &mut SolveApp, at: Pos2) { _ => None, }); crate::select_other::close(app); + let selected = |app: &SolveApp, sel: &Sel| app.session.selection.iter().any(|x| crate::selection::same_item(x, sel)); if app.session.active_sketch.is_none() && let Some(h) = app.viewport.hover.clone() && let Some((_, sel)) = crate::viewport::candidate(app, &[h]) - && !app.session.selection.contains(&sel) + && !selected(app, &sel) && !matches!(sel, Sel::Plane { .. } | Sel::Axis { .. }) { let _ = app.run("select.set", json!({ "items": [sel] })); @@ -1514,7 +1517,7 @@ pub fn open(app: &mut SolveApp, at: Pos2) { && let Some(h) = app.viewport.hover.clone() && let Some((_, sel)) = crate::viewport::candidate(app, &[h]) && (matches!(sel, Sel::SketchCurve { .. } | Sel::SketchPoint { .. }) || crate::sketch_text::entity_for_selection(app, &sel).is_some()) - && !app.session.selection.contains(&sel) + && !selected(app, &sel) { let _ = app.run("select.set", json!({ "items": [sel] })); } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index fcc8076490..210bc02808 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -675,9 +675,26 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) -> } "ui.key" => { let Some(key) = s("key").and_then(key_from) else { return err("unknown key") }; - let modifiers = egui::Modifiers { shift: b("shift"), command: b("cmd"), ctrl: b("cmd") || b("ctrl"), ..Default::default() }; - app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }); - app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }); + // Preserve the control channel's legacy primary-modifier alias. An explicit + // ctrl flag opts into physical Control/Command state (ctrl:false for Cmd alone). + let explicit_ctrl = p.get("ctrl").is_some(); + let ctrl = if explicit_ctrl { b("ctrl") } else { b("cmd") }; + let modifiers = egui::Modifiers { shift: b("shift"), alt: b("alt"), ctrl, command: b("cmd") || ctrl, mac_cmd: b("cmd") && explicit_ctrl }; + let pressed = match p.get("pressed") { + None => None, + Some(Value::Bool(pressed)) => Some(*pressed), + Some(_) => return err("pressed must be a boolean"), + }; + app.synthetic.push(egui::Event::ModifiersChanged(modifiers)); + if pressed != Some(false) { + app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }); + } + if pressed != Some(true) { + app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }); + } + if pressed.is_none() { + app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + } ok(Value::Null) } "ui.text" => { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index f177a6b1f6..afda0175f7 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -353,6 +353,8 @@ crate::dialog_descriptor::dialog_descriptors! { }, /// Plastic features (`dialogs_plastic`). Plastic(crate::dialogs_plastic::Pl), + /// Frame members (`dialogs_frame`). + Frame(crate::dialogs_frame::Member), /// Appearance (`dialogs_appearance`). Appearance(crate::dialogs_appearance::Ap), /// Contact sets, motion studies, exploded views, configurations (`dialogs_motion`). @@ -375,6 +377,13 @@ crate::dialog_descriptor::dialog_descriptors! { /// Holes go on any face (flat or curved; the engine drills against the face's normal there). const HOLE_FACES: Accept = PLANAR_FACES | FACES | selection::POINTS; +/// The Single hole placement's inputs: the face, then up to two reference edges (#37). +fn single_hole_inputs() -> Vec { + let mut v = vec![SelInput::new("Face", HOLE_FACES, true)]; + v.extend(crate::hole_refs::inputs()); + v +} + /// The rest of the Hole dialog: placement, extents, tap type and drill point. #[derive(Clone, Debug, PartialEq)] pub struct HoleOpts { @@ -390,11 +399,22 @@ pub struct HoleOpts { pub thread: String, /// Custom nominal diameter and pitch being edited, separate from the saved designation. pub custom_thread: Option, + /// The Single hole's reference edge distances (`hole_refs`, #37). + pub refs: Box, } impl Default for HoleOpts { fn default() -> Self { - HoleOpts { multiple: false, all: false, angled: true, tip_angle: "118 deg".into(), tapped: false, thread: "M5".into(), custom_thread: None } + HoleOpts { + multiple: false, + all: false, + angled: true, + tip_angle: "118 deg".into(), + tapped: false, + thread: "M5".into(), + custom_thread: None, + refs: Default::default(), + } } } @@ -570,13 +590,14 @@ fn fits(a: Accept, s: &Sel) -> bool { Sel::Body { .. } => a & BODIES != 0, Sel::Plane { .. } => a & PLANES != 0, Sel::Axis { .. } => a & AXES != 0, - Sel::SketchCurve { .. } => a & (AXES | CURVES) != 0, + Sel::SketchCurve { .. } => a & (AXES | CURVES | selection::SKETCH_LINES) != 0, + Sel::ExternalSketchCurve { .. } => a & selection::SKETCH_LINES != 0, Sel::Vertex { .. } => a & selection::VERTICES != 0, Sel::Feature { .. } => a & selection::FEATURES != 0, // Sketch points go into a sketch command's own inputs (Entities, a base point), so a // box selection and a pre-selection reach them the way a click already does. Sel::SketchPoint { .. } => a & (selection::POINTS | selection::SKETCH_POINTS) != 0, - Sel::SketchConstraint { .. } | Sel::Occurrence { .. } | Sel::ExternalSketchCurve { .. } | Sel::ExternalSketchPoint { .. } => false, + Sel::SketchConstraint { .. } | Sel::Occurrence { .. } | Sel::ExternalSketchPoint { .. } => false, } } @@ -621,7 +642,7 @@ impl Dialog { direction: 0, operation: usize::from(has_bodies), auto_op: true, - smart: Default::default(), + smart: crate::smart_extrude::Smart { auto_to_object: app.preferences.extrude_auto_to_object, ..Default::default() }, distance2: "10 mm".into(), taper: "0 deg".into(), taper_present: false, @@ -735,7 +756,7 @@ impl Dialog { ] }), "solid.move" => Dialog::new(crate::dialogs_move::kind(0), crate::dialogs_move::inputs(0)), - "solid.hole" => Dialog::new(hole_defaults(), vec![SelInput::new("Face", HOLE_FACES, true)]), + "solid.hole" => Dialog::new(hole_defaults(), single_hole_inputs()), "solid.box" => Dialog::new( Kind::Primitive { cmd: "solid.box", @@ -793,6 +814,7 @@ impl Dialog { .or_else(|| crate::dialogs_save_mesh::start(app, id)) .or_else(|| crate::dialogs_view_dxf::start(app, id)) .or_else(|| crate::dialogs_plastic::start(app, id)) + .or_else(|| crate::dialogs_frame::start(app, id)) .or_else(|| crate::dialogs_appearance::start(app, id)) .or_else(|| crate::dialogs_motion::start(app, id)) .or_else(|| crate::dialogs_parts::start(app, id)) @@ -934,6 +956,7 @@ impl Dialog { Kind::Surface(_) => return true, Kind::SketchModify(k) => return k.previews(), Kind::Plastic(k) => return k.previews(), + Kind::Frame(_) => return true, Kind::Part(_) | Kind::PartSize(_) => return true, // Web previews run synchronously; a modelled thread can take seconds. Kind::Thread { .. } => return !cfg!(target_arch = "wasm32") && self.extra.get("modeled").and_then(Value::as_bool).unwrap_or(false), @@ -1130,6 +1153,9 @@ impl Dialog { if matches!(self.kind, Kind::SketchModify(_)) { return crate::sketch_modify_ui::candidate(self, s, h); } + if let Some(target) = self.extrude_target(s, h) { + return Some(target); + } let inp = self.active_input()?; // Features are also picked on the canvas: a face stands for the feature that made it. if inp.accept == selection::FEATURES { @@ -1148,8 +1174,67 @@ impl Dialog { inp.accepts(h, |body, face| snap.and_then(|f| f.planar(body, face)).unwrap_or_else(|| planar_face(s, body, face).is_some())) } + /// Extrude's automatic To Object: with profiles picked, a face or vertex off their plane, or + /// a plane parallel to it, is the object to extrude to rather than another profile. While the + /// To Object input is active, a parallel plane is taken too (as a place on it). + fn extrude_target(&self, s: &Session, h: &Hit) -> Option { + if !matches!(self.kind, Kind::Extrude { thin: false, smart: crate::smart_extrude::Smart { auto_to_object: true, .. }, .. }) { + return None; + } + let to_active = self.active_input().is_some_and(|i| i.label == TO_OBJECT); + if self.active != 0 && !to_active { + return None; + } + let reference = profile_plane(s, self.inputs.first()?.items.first()?)?; + let sel = match h { + // A place on the plane: its origin (a browser pick carries no point). + Hit::Plane { name, .. } => { + let plane = plane_by_name(s, name)?; + if plane.normal().dot(reference.normal()).abs() < 1.0 - 1e-9 { + return None; + } + Sel::Vertex { body: String::new(), point: plane.origin } + } + _ if to_active => return None, + Hit::Face { body, index, point } => Sel::Face { body: body.clone(), index: *index, point: *point }, + Hit::Vertex { body, point } => Sel::Vertex { body: body.clone(), point: *point }, + _ => return None, + }; + object_target(s, &reference, &sel).then_some(sel) + } + + /// Take a pick as the extrude's To Object (see [`Dialog::extrude_target`]): the extent turns to + /// To Object with it. False when the pick is not one. + fn take_extrude_target(&mut self, s: &Session, sel: &Sel) -> bool { + if self.active != 0 + || !matches!(self.kind, Kind::Extrude { thin: false, smart: crate::smart_extrude::Smart { auto_to_object: true, .. }, .. }) + { + return false; + } + let Some(reference) = self.inputs.first().and_then(|i| i.items.first()).and_then(|first| profile_plane(s, first)) else { return false }; + if !object_target(s, &reference, sel) { + return false; + } + set_to_object(&mut self.inputs, &mut self.active, true); + let Some(at) = self.inputs.iter().position(|i| i.label == TO_OBJECT) else { return false }; + if let Some(input) = self.inputs.get_mut(at) { + input.items = vec![sel.clone()]; + } + self.active = at; + if let Kind::Extrude { all, .. } = &mut self.kind { + *all = false; + } + self.focus = true; + self.error = None; + self.sync_thread(s); + true + } + /// A pick in the viewport (already accepted by the active input). pub fn pick(&mut self, s: &Session, sel: Sel) { + if self.take_extrude_target(s, &sel) { + return; + } if let Some(key) = feature_pattern_reference_key(self, self.active) { self.extra.remove(key); } @@ -1708,6 +1793,7 @@ fn title(k: &Kind) -> &'static str { Kind::SaveMesh(_) => "SAVE AS MESH", Kind::ViewDxf { .. } => "Export Current View as DXF", Kind::Plastic(k) => k.title(), + Kind::Frame(_) => "FRAME MEMBER", Kind::Appearance(_) => "APPEARANCE", Kind::Motion(k) => k.title(), Kind::Part(_) => "INSERT PART", @@ -1729,6 +1815,8 @@ fn hint(inp: &SelInput) -> &'static str { "click a plane or planar face" } else if a & AXES != 0 { "click an axis or sketch line" + } else if a & selection::SKETCH_LINES != 0 { + "click sketch lines" } else if a & CURVES != 0 { "click sketch curves" } else if a & (selection::POINTS | selection::SKETCH_POINTS) != 0 { @@ -1885,7 +1973,7 @@ fn placement_row(d: &mut Dialog, ui: &mut egui::Ui) { choice_row(ui, "Placement", "hole_place", &mut m, &["Single", "Multiple"]); if (m == 1) != opts.multiple { opts.multiple = m == 1; - d.inputs = vec![if opts.multiple { SelInput::new("Points", selection::POINTS, true) } else { SelInput::new("Face", HOLE_FACES, true) }]; + d.inputs = if opts.multiple { vec![SelInput::new("Points", selection::POINTS, true)] } else { single_hole_inputs() }; d.active = 0; d.extra.remove("direction"); } @@ -1934,7 +2022,7 @@ fn stored_revolve_axis(kind: &Kind) -> Option<&Value> { } } -fn input_rows_range(d: &mut Dialog, ui: &mut egui::Ui, s: &Session, range: std::ops::Range) { +pub(crate) fn input_rows_range(d: &mut Dialog, ui: &mut egui::Ui, s: &Session, range: std::ops::Range) { // Edit Text shows its Path input among its own rows. if matches!(d.kind, Kind::Text(_)) { return; @@ -2211,6 +2299,38 @@ pub(crate) fn shown_distance(r: &Value, show: Option) -> Option<(Shown, f show.and_then(get).or_else(|| get(default)).or_else(|| get(Shown::Min)) } +/// A new To Object target of a new extrude sets the distance to how far it is, so the length +/// shows and stays when the extent goes back to Distance. +fn sync_reach(app: &SolveApp, d: &mut Dialog) { + let Kind::Extrude { smart, start, to_offset, .. } = &d.kind else { return }; + if !smart.auto_to_object { + return; + } + let target = d.inputs.iter().find(|i| i.label == TO_OBJECT).and_then(|i| i.items.first()).cloned(); + if smart.target == target { + return; + } + // A start offset, a To offset or a From Object move the ends: the distance stays as it is. + let plain = d.editing.is_none() && start.trim().is_empty() && to_offset.trim().is_empty() && !object_on(&d.inputs, FROM_OBJECT); + // Picks and the profiles' plane are both in world space. + let reach = match &target { + Some(Sel::Face { point, .. } | Sel::Vertex { point, .. }) if plain => { + d.inputs.first().and_then(|i| i.items.first()).and_then(|first| profile_plane(&app.session, first)).map(|plane| plane.height(*point)) + } + _ => None, + }; + let Kind::Extrude { smart, distance, direction, .. } = &mut d.kind else { return }; + smart.target = target; + if let Some(reach) = reach.filter(|r| r.is_finite() && r.abs() > 1e-9) { + let text = format!("{:.6}", reach.abs()); + *distance = format!("{} mm", text.trim_end_matches('0').trim_end_matches('.')); + // The side the target is on, for when the extent goes back to Distance. + if *direction < 2 { + *direction = usize::from(reach < 0.0); + } + } +} + /// Smart Extrude (#1257, see [`crate::smart_extrude`]): a new pick sets the direction from the /// side it was picked from, and the automatic operation follows the geometry, latched per side. fn auto_operation(app: &SolveApp, d: &mut Dialog) { @@ -2289,6 +2409,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { { return; } + sync_reach(app, &mut d); auto_operation(app, &mut d); d.sync_thread(&app.session); update_measure(app, &mut d); @@ -2663,6 +2784,33 @@ fn set_object(inputs: &mut Vec, active: &mut usize, label: &'static st } } +/// The plane an extrude's picked profiles lie in: the first pick's sketch plane, or its planar face. +fn profile_plane(s: &Session, first: &Sel) -> Option { + let st = s.world_state(); + match first { + Sel::Profile { sketch, .. } => st.sketch(*sketch).map(|ss| ss.plane), + Sel::Face { body, index, .. } => st.body(body)?.body.planar_face(*index), + _ => None, + } +} + +/// Is a pick an object to extrude to from profiles in `reference` (not one more profile)? A face +/// is unless it is planar and in that plane; a vertex or a place is unless it lies in it. +fn object_target(s: &Session, reference: &solidcraft_engine::geom::Plane, sel: &Sel) -> bool { + const TOLERANCE: f64 = 1e-6; + match sel { + Sel::Face { body, index, .. } => { + let st = s.world_state(); + let Some(b) = st.body(body) else { return false }; + b.body + .planar_face(*index) + .is_none_or(|face| reference.normal().dot(face.normal()).abs() < 1.0 - 1e-9 || reference.height(face.origin).abs() > TOLERANCE) + } + Sel::Vertex { point, .. } => reference.height(*point).abs() > TOLERANCE, + _ => false, + } +} + /// The picked To Object place. fn to_object_point(inputs: &[SelInput]) -> Option { object_point(inputs, TO_OBJECT) @@ -2850,7 +2998,7 @@ pub fn start_sketch(app: &mut SolveApp, sel: &Sel) -> Result<(), String> { /// behind the plane's normal, the in-plane axis to turn the plane about (`"x"` or `"y"`), chosen /// so the sketch's y axis stays as upright on screen as it can (x then runs to the right). fn flip_toward_viewer(app: &SolveApp, name: &str) -> Option<&'static str> { - let plane = plane_by_name(app, name)?; + let plane = plane_by_name(&app.session, name)?; let n = plane.normal(); // Where the eye is: in perspective its position, in orthographic only its direction counts. let side = if app.cam.fov > 0.0 { (app.cam.eye() - plane.origin).dot(n) } else { app.cam.basis().2.dot(n) }; @@ -2876,15 +3024,21 @@ fn shown_origin_plane(app: &SolveApp, name: &str) -> Option { } /// The world plane of an origin plane or a construction plane by name, where it is shown. -fn plane_by_name(app: &SolveApp, name: &str) -> Option { +fn plane_by_name(s: &Session, name: &str) -> Option { if let Some(p) = solidcraft_engine::geom::Plane::named(name) { return Some(p); } - let st = app.session.world_state(); - st.construct.iter().find(|c| c.name == name).and_then(|c| match &c.geom { - solidcraft_engine::doc::construct::ConstructGeom::Plane(pl) => Some(*pl), - _ => None, - }) + let st = s.world_state(); + st.construct + .iter() + .find(|c| c.name == name) + .and_then(|c| match &c.geom { + solidcraft_engine::doc::construct::ConstructGeom::Plane(pl) => Some(*pl), + _ => None, + }) + // With occurrences the world state leaves construction geometry out; the viewport's + // planes are evaluated in place. + .or_else(|| solidcraft_engine::view::construction_planes(s).into_iter().find(|(_, n, _)| n == name).map(|(_, _, pl)| pl)) } /// Animate the camera to look straight at the active sketch plane. @@ -2943,6 +3097,11 @@ fn sels(d: &Dialog, i: usize) -> &[Sel] { selection::items(&d.inputs, i) } +/// Section Analysis with a section on and no plane picked: OK turns the section off. +fn section_turns_off(s: &Session, inputs: &[selection::SelInput]) -> bool { + s.section.is_some() && selection::items(inputs, 0).is_empty() +} + pub(crate) fn run_dialog(app: &mut SolveApp, d: &Dialog) -> Result<(), String> { if let Kind::ViewDxf { dashed, tolerance } = &d.kind { return crate::dialogs_view_dxf::run(app, *dashed, *tolerance); @@ -3595,6 +3754,9 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result, S ("material.assign", json!({"bodies": sels(d, 0).body_names(), "material": name})) } Kind::Section { offset, flip, hatch } => { + if section_turns_off(s, &d.inputs) { + return Ok(vec![("inspect.section".into(), json!({"clear": true}))]); + } need(0, "a plane or planar face")?; let plane = match sels(d, 0).first() { Some(Sel::Plane { name }) if matches!(name.as_str(), "XY" | "XZ" | "YZ") => json!(name), @@ -3666,6 +3828,16 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result, S None => json!({ "position": kept.unwrap_or(p) }), })); } + // Reference edges (#37) place the one hole by its distances from them, from where + // it was picked. + let references = crate::hole_refs::params(d); + if !references.is_empty() { + let at = sels(d, 0).iter().find_map(face_point); + match (places.as_mut_slice(), at) { + ([place], Some(at)) => *place = json!({"position": at, "references": references}), + _ => return Err("reference edges place one hole: pick one place on the face".into()), + } + } } let mut out = Vec::new(); for place in places { @@ -3760,6 +3932,7 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result, S Kind::SaveMesh(k) => return crate::dialogs_save_mesh::commands(k, &d.inputs), Kind::ViewDxf { .. } => return Ok(Vec::new()), Kind::Plastic(k) => return crate::dialogs_plastic::commands(app, k, &d.inputs, &d.extra), + Kind::Frame(k) => return crate::dialogs_frame::commands(app, k, &d.inputs, &d.extra), Kind::Appearance(k) => return Ok(crate::dialogs_appearance::commands(k, &d.inputs)), Kind::Motion(k) => return crate::dialogs_motion::commands(app, k, &d.inputs), Kind::Part(k) => return crate::dialogs_parts::commands(app, k, &d.inputs), @@ -4024,6 +4197,41 @@ pub(crate) fn face_sel(s: &Session, p0: Vec3) -> Option { (d <= tolerance).then(|| Sel::Face { body: body.name.clone(), index, point }) } +/// Resolve a stored name on the current input body before placing its pick in the viewport. +/// Keep the resolved index: a face point can lie on a boundary shared with another face. +fn named_refill(s: &Session, body: Option<&str>, name: &str, point: Vec3, edge: bool) -> Option { + use solidcraft_engine::doc::naming; + + if name.is_empty() { + return None; + } + let st = s.model.state(); + let (source, index, point) = if edge { + let (b, hit) = st + .bodies + .iter() + .filter(|b| body.is_none_or(|name| b.name == name)) + .filter_map(|b| naming::locate_edge(b, name, point).ok().map(|hit| (b, hit))) + .min_by(|(_, a), (_, b)| a.point.dist(point).total_cmp(&b.point.dist(point)))?; + (b, hit.edge, hit.point) + } else if let Some((b, (index, point, _))) = body.and_then(|n| st.body(n)).and_then(|b| naming::face_on_body(b, name, point).map(|hit| (b, hit))) + { + (b, index, point) + } else { + let hit = naming::locate_face(&st, name, point)?; + (st.bodies.get(hit.body)?, hit.face, hit.point) + }; + let (body, point) = s + .doc + .body_frames_with_source(&st) + .into_iter() + .find(|(_, definition, _, _, _)| definition == source.definition_name()) + .map(|(world, _, _, _, frame)| (world, solidcraft_engine::doc::apply_point(&frame, point))) + .unwrap_or_else(|| (source.name.clone(), point)); + s.world_state().body(&body)?; + Some(if edge { Sel::Edge { body, index, point } } else { Sel::Face { body, index, point } }) +} + fn emboss_face_sel(s: &Session, component: u64, face: &solidcraft_engine::doc::EmbossFace, persistent: &str) -> Option { let source = s.model.state(); let body = source.body(&face.body)?; @@ -4195,7 +4403,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option) -> Option { + FeatureKind::Fillet { edges, radius, body, .. } | FeatureKind::Chamfer { edges, distance: radius, body, .. } => { + let edge_pick = + |i: usize, p: Vec3| f.edge_names.get(i).and_then(|name| named_refill(s, body.as_deref(), name, p, true)).or_else(|| edge_sel(s, p)); let chamfer = matches!(f.kind, FeatureKind::Chamfer { .. }); let mut d = start(if chamfer { "solid.chamfer" } else { "solid.fillet" })?; d.kind = fillet_kind(radius, chamfer); @@ -4339,7 +4549,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option {} } // The start was the first edge's last point: ends swapped. - let first_end = edges.first().and_then(|p| edge_sel(s, p.0)).and_then(|x| match x { + let first_end = edges.first().and_then(|p| edge_pick(0, p.0)).and_then(|x| match x { Sel::Edge { body, index, .. } => { s.world_state().body(&body).and_then(|b| b.mesh().edges.get(index).and_then(|e| e.last().copied())) } @@ -4351,7 +4561,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option) -> Option) -> Option) -> Option { + FeatureKind::Hole { position, at, direction, diameter, depth, hole, points, thread, to, references } => { let mut d = start("solid.hole")?; let mut k = hole_defaults(); if let Kind::Hole { diameter: dia, depth: dep, kind, cb_diameter, cb_depth, cs_diameter, cs_angle, opts } = &mut k { @@ -4449,6 +4671,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option = at.iter().map(|e| e.as_str()).collect(); d.extra.insert("position_exprs".into(), json!({"at": exprs, "point": pt3(position.0)})); } + crate::hole_refs::restore(s, &mut d, references, &f.edge_names); } } if to.is_some() { @@ -4884,6 +5107,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option) -> Option { let (kind, inputs, extra) = crate::dialogs_sheet::for_feature(app, other) .or_else(|| crate::dialogs_plastic::for_feature(app, other)) + .or_else(|| crate::dialogs_frame::for_feature(app, other)) .or_else(|| crate::dialogs_surface::for_feature(app, other, &feature_frame(s, f.component)))?; let mut d = Dialog::new(kind, inputs); d.extra = extra; diff --git a/crates/ui-egui/src/dialogs_all_tests.rs b/crates/ui-egui/src/dialogs_all_tests.rs index 4a50f8b7d9..272dc3e68f 100644 --- a/crates/ui-egui/src/dialogs_all_tests.rs +++ b/crates/ui-egui/src/dialogs_all_tests.rs @@ -55,6 +55,8 @@ fn representative(h: &Harness, command: &str) -> Dialog { } } else if input.accept & PLANES != 0 { vec![Sel::Plane { name: if index == 1 { "XZ" } else { "XY" }.into() }] + } else if input.accept & crate::selection::SKETCH_LINES != 0 { + vec![Sel::ExternalSketchCurve { sketch: s.world_state().sketches.last().unwrap().feature, id: path.clone() }] } else if input.accept & CURVES != 0 || command == "sheet.fold" && index == 0 { vec![Sel::SketchCurve { id: path.clone() }] } else if input.accept & AXES != 0 { @@ -148,6 +150,7 @@ fn check_form(command: &str, labels: &[&str]) { "joint.gear" => &["Reverse", "Internal gear"], "joint.rack_pinion" | "joint.screw" => &["Reverse"], "joint.cam" => &["Other side"], + "frame.member" => &["All Lines", "Mirror"], _ => &[], }; for label in checks { @@ -192,10 +195,10 @@ fn control_role(command: &str, label: &str) -> Option { "Operation" if command == "solid.mirror" => return None, "Operation" | "Type" | "Orientation" | "Move Type" | "Object Type" | "Rule" | "Rim Edge" | "Catch Direction" | "Shape" | "Holes" | "Size" | "Bend Line Position" | "From Snap" | "To Snap" | "Joint" | "Joint 1" | "Joint 2" | "Gear 1" | "Gear 2" | "Pinion" | "Rack" | "Screw" - | "Nut" | "Cam" | "Follower" => Role::ComboBox, + | "Nut" | "Cam" | "Follower" | "Anchor" => Role::ComboBox, "Radius" if command == "joint.rack_pinion" => Role::Label, "Pitch" if command == "solid.thread" => Role::ComboBox, - "Automatic size" | "Reverse" | "Other side" => Role::CheckBox, + "Automatic size" | "Reverse" | "Other side" | "All Lines" | "Mirror" => Role::CheckBox, "Animate" => Role::Button, "Ratio" if command.starts_with("joint.") && command != "joint.motion_link" => return None, _ => Role::TextInput, @@ -235,6 +238,7 @@ form_case!(remove, "solid.remove", []); form_case!(path_pattern, "solid.pattern.path", ["Type", "Object Type", "Quantity", "Spacing"]); form_case!(pipe, "solid.pipe", ["Diameter", "Wall"]); form_case!(stock, "solid.bounding_solid", ["Margin"]); +form_case!(frame_member, "frame.member", ["All Lines", "Anchor", "Angle", "Mirror", "Name"]); form_case!(thread, "solid.thread", ["Profile", "Size", "Pitch", "Designation"]); form_case!(primitive_box, "solid.box", ["length", "width", "height", "Operation"]); form_case!(cylinder, "solid.cylinder", ["diameter", "height", "Operation"]); @@ -337,3 +341,116 @@ fn remaining_dialogs_keep_kit_chrome_and_cancel_restores_preview() { assert_eq!(h.app.session.pending_moves, moves, "{command}: cancel restores display-only moves"); } } + +fn resized_box_editor(command: &str, params: Value) -> (Harness, u64) { + let mut h = Harness::new(); + for (id, params) in [ + ("sketch.create", json!({"plane":"XY"})), + ("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[40,20]})), + ("sketch.dimension", json!({"entities":["l1"],"value":40})), + ("sketch.dimension", json!({"entities":["l2"],"value":20})), + ("sketch.finish", json!({})), + ("solid.extrude", json!({"distance":20})), + (command, params), + ("parameters.change", json!({"name":"d2","expression":"30"})), + ] { + h.app.run(id, params).unwrap(); + } + let id = h.app.session.doc.features.last().unwrap().id; + assert!(h.app.session.model.results.iter().all(|r| r.error.is_none())); + h.app.edit_feature(id); + (h, id) +} + +#[test] +fn blend_editor_refills_named_edges_after_sketch_resize() { + for command in ["solid.fillet", "solid.chamfer"] { + let (mut h, id) = resized_box_editor(command, json!({"edges":[[40,10,20],[20,20,20],[20,0,20],[0,10,20]],"radius":4,"distance":4})); + let d = h.app.dialog.as_ref().unwrap(); + let picks = &d.inputs[0].items; + assert_eq!(picks.len(), 4); + assert_eq!(&d.picked_edges, picks); + let s = &h.app.session; + let body = s.model.state().bodies.first().unwrap().clone(); + let names = &s.doc.feature(id).unwrap().edge_names; + for (pick, name) in picks.iter().zip(names) { + let Sel::Edge { point, index, .. } = pick else { panic!("{pick:?}") }; + let hit = solidcraft_engine::doc::naming::locate_edge(&body, name, *point).unwrap(); + assert_eq!(*index, hit.edge); + assert!(hit.on_point, "{command}: {point:?} must lie on the resized edge"); + } + let commands = apply_commands(&h.app, d).unwrap(); + crate::dialogs::roll_marker(&mut h.app, None).unwrap(); + for (command, params) in commands { + h.app.run(&command, params).unwrap(); + } + assert_eq!(h.app.session.doc.features.last().unwrap().id, id); + assert!(h.app.session.model.results.iter().all(|r| r.error.is_none())); + } +} + +#[test] +fn face_editor_refills_named_faces_after_sketch_resize() { + for (command, params) in + [("solid.shell", json!({"faces":[[20,20,10]],"thickness":2})), ("solid.draft", json!({"faces":[[20,20,10]],"neutral":"XY","angle":3}))] + { + let (mut h, id) = resized_box_editor(command, params); + let d = h.app.dialog.as_ref().unwrap(); + let picks = &d.inputs[0].items; + assert_eq!(picks.len(), 1, "{command}: the moved face must refill"); + let Sel::Face { point, .. } = picks.first().unwrap() else { panic!("{picks:?}") }; + assert!((point.y - 30.0).abs() < 1e-6, "{command}: {point:?}"); + let commands = apply_commands(&h.app, d).unwrap(); + crate::dialogs::roll_marker(&mut h.app, None).unwrap(); + for (command, params) in commands { + h.app.run(&command, params).unwrap(); + } + assert_eq!(h.app.session.doc.features.last().unwrap().id, id); + assert!(h.app.session.model.results.iter().all(|r| r.error.is_none())); + } +} + +#[test] +fn solid_pattern_quantity_arrows_step_the_count_the_command_receives() { + let mut h = fixture(); + h.app.run("parameters.add", json!({"name":"copies","expression":"3","unit":""})).unwrap(); + for (command, key, label) in [ + ("solid.pattern.circular", "count", "Quantity"), + ("solid.pattern.rectangular", "count1", "Quantity"), + ("solid.pattern.path", "count", "Quantity"), + ] { + let mut d = representative(&h, command); + if command == "solid.pattern.rectangular" { + d.inputs[1].items = vec![Sel::Axis { name: "X".into() }]; + } + h.app.dialog = Some(d); + h.settle(); + let sent = |h: &Harness| { + let cmds = crate::dialogs::preview_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap_or_else(|e| panic!("{command}: {e}")); + cmds.iter().find(|(id, _)| id == command).map(|(_, p)| p[key].clone()).unwrap_or_else(|| panic!("{command}: {cmds:?}")) + }; + let before = sent(&h).as_str().unwrap().parse::().unwrap(); + h.step(&json!({"click":{"handle":format!("dialog:{label} up")}})).unwrap(); + assert_eq!(sent(&h), json!((before + 1).to_string()), "{command}"); + let set = |h: &mut Harness, text: &str| { + if let Some(Kind::PatternRect { count, .. } | Kind::PatternCirc { count, .. } | Kind::PathPattern { count, .. }) = + h.app.dialog.as_mut().map(|d| &mut d.kind) + { + *count = text.into(); + } + h.frames(2); + }; + // From 1 to the document's 1000 instances. + set(&mut h, "1"); + h.step(&json!({"click":{"handle":format!("dialog:{label} down")}})).unwrap(); + assert_eq!(sent(&h), json!("1"), "{command}"); + set(&mut h, "1000"); + h.step(&json!({"click":{"handle":format!("dialog:{label} up")}})).unwrap(); + assert_eq!(sent(&h), json!("1000"), "{command}"); + set(&mut h, "copies"); + h.step(&json!({"click":{"handle":format!("dialog:{label} up")}})).unwrap(); + assert_eq!(sent(&h), json!("copies + 1"), "{command}"); + h.step(&json!({"click":{"handle":"dialog:cancel"}})).unwrap(); + h.settle(); + } +} diff --git a/crates/ui-egui/src/dialogs_assembly.rs b/crates/ui-egui/src/dialogs_assembly.rs index 4f06c1c25f..cb1c25c251 100644 --- a/crates/ui-egui/src/dialogs_assembly.rs +++ b/crates/ui-egui/src/dialogs_assembly.rs @@ -386,12 +386,17 @@ pub struct SnapPt { pub param: Value, } -/// The occurrence placing a body (0: the root design), when the body is its primary instance. +/// The occurrence placing a world body (0: the root design), the instance its name says. pub(crate) fn occurrence_of_body(s: &Session, body: &str) -> Option { + // The world name tells the instance: a further occurrence of a component is not its first. let st = s.world_state(); let b = st.body(body)?; - let comp = s.doc.model_body_component(b); - if comp == 0 { Some(0) } else { s.doc.occurrence_of(comp).map(|o| o.id) } + if s.doc.model_body_component(b) == 0 { + return Some(0); + } + let model = s.model.state(); + let (_, _, _, path, _) = s.doc.body_frames_with_source(&model).into_iter().find(|(world, ..)| world == body)?; + path.last().copied() } /// Centre and unit normal of the circle through a polyline's points, if it lies on one. @@ -1159,7 +1164,7 @@ fn glyph(painter: &egui::Painter, proj: &Proj, s: &SnapPt, col: Color32, half_he } /// What a snap is called (the hover label). -fn snap_name(k: SnapKind) -> &'static str { +pub(crate) fn snap_name(k: SnapKind) -> &'static str { match k { SnapKind::FaceCenter => "Face center", SnapKind::CircleCenter => "Circle center", @@ -1220,7 +1225,7 @@ pub fn timeline_joints(app: &mut SolveApp, ui: &mut egui::Ui, p: &egui::Painter, } else { t.timeline_item }; - p.rect(br, 3.0, bg, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + p.rect(br, t.radius_sm.saturating_sub(1), bg, t.control_border_stroke(t.border), egui::StrokeKind::Inside); crate::icons::paint_icon(p, br.shrink(4.0), "joint", f32::from((if suppressed { t.border } else { t.icon }).a()) / 255.0); if suppressed { p.line_segment([br.left_bottom() + vec2(4.0, -4.0), br.right_top() + vec2(-4.0, 4.0)], Stroke::new(1.5, t.text_dim)); @@ -1267,9 +1272,9 @@ pub fn timeline_joints_vertical(app: &mut SolveApp, ui: &mut egui::Ui) { let (rect, response) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), egui::Sense::click()); crate::scenario::publish_handle(&format!("timeline-joint:{name}"), rect.center()); if app.tree.picked_joints.contains(&id) { - ui.painter().rect_filled(rect, 3.0, t.accent_soft); + ui.painter().rect_filled(rect, t.radius_sm.saturating_sub(1), t.accent_soft); } else if response.hovered() { - ui.painter().rect_filled(rect, 3.0, t.hover); + ui.painter().rect_filled(rect, t.radius_sm.saturating_sub(1), t.hover); } let icon = egui::Rect::from_min_size(egui::pos2(rect.left() + 4.0, rect.top() + 4.0), vec2(22.0, 22.0)); crate::icons::paint_icon(ui.painter(), icon, "joint", f32::from((if suppressed { t.border } else { t.icon }).a()) / 255.0); diff --git a/crates/ui-egui/src/dialogs_assembly_tests.rs b/crates/ui-egui/src/dialogs_assembly_tests.rs index e78310bd08..dd6c9102fe 100644 --- a/crates/ui-egui/src/dialogs_assembly_tests.rs +++ b/crates/ui-egui/src/dialogs_assembly_tests.rs @@ -215,6 +215,19 @@ fn component_picks_become_occurrences() { assert_eq!(c[0].0, "component.create"); } +/// A body of a further instance of a component belongs to that instance, not the first (#3857). +#[test] +fn body_of_a_further_instance_is_its_own_occurrence() { + let mut app = two_boxes(); + let copy = app.session.execute("occurrence.copy", &json!({"component": "B", "translate": [0, 40, 0]})).unwrap()["occurrence"].as_u64().unwrap(); + let first = occ(&app, "B"); + assert_ne!(copy, first); + let frames = app.session.doc.body_frames(&app.session.model.state()); + let of = |o: u64| frames.iter().find(|f| f.2.last() == Some(&o)).map(|f| f.0.clone()).unwrap(); + assert_eq!(occurrence_of_body(&app.session, &of(first)), Some(first)); + assert_eq!(occurrence_of_body(&app.session, &of(copy)), Some(copy)); +} + /// Round edges snap to their centre; straight ones are not circles. #[test] fn circles_from_edge_polylines() { diff --git a/crates/ui-egui/src/dialogs_dense.rs b/crates/ui-egui/src/dialogs_dense.rs index 50467ffd40..39b5c901c8 100644 --- a/crates/ui-egui/src/dialogs_dense.rs +++ b/crates/ui-egui/src/dialogs_dense.rs @@ -75,6 +75,7 @@ pub(super) fn command(kind: &Kind) -> Option<&'static str> { crate::dialogs_sheet::Sm::FlatPattern { .. } => Some("sheet.flat_pattern"), _ => k.simple_metadata().map(|(command, _)| command), }, + Kind::Frame(_) => Some("frame.member"), Kind::Plastic(k) => match k { crate::dialogs_plastic::Pl::SnapFit { .. } => Some("plastic.snap_fit"), crate::dialogs_plastic::Pl::Assign { .. } => Some("plastic.assign_rule"), @@ -105,6 +106,16 @@ const FILLET_TYPES: [(&str, &str); 5] = [ ("Spline", "spline"), ]; +/// The most instances a solid pattern makes in one direction (the document's own limit). +const MAX_PATTERN_INSTANCES: i64 = 1000; + +/// A solid pattern's quantity row, with step arrows from 1 to `MAX_PATTERN_INSTANCES`. The +/// count goes to the command as typed, so it is evaluated as the document evaluates it. +fn quantity_row(ui: &mut egui::Ui, doc: &solidcraft_engine::doc::Document, label: &str, value: &mut String) -> bool { + let eval = |text: &str| doc.eval(text.trim(), solidcraft_engine::doc::expr::Kind::Unitless).ok(); + ui_kit::count_row(ui, label, value, &ui_kit::Count { min: 1, max: MAX_PATTERN_INSTANCES, eval: &eval }) +} + fn fillet_type(extra: &serde_json::Map) -> usize { let value = extra.get("type").and_then(Value::as_str).unwrap_or("constant"); if value == "g2" { 3 } else { FILLET_TYPES.iter().position(|(_, key)| *key == value).unwrap_or(0) } @@ -172,7 +183,12 @@ pub(super) fn rows(app: &mut SolveApp, ctx: &egui::Context, d: &mut Dialog, ui: ui_kit::text(ui, tr(key), ui_kit::Text::Hint("manage")); } placement_row(d, ui); - input_rows(d, ui, &app.session); + if matches!(&d.kind, Kind::Hole { opts, .. } if !opts.multiple) { + // Each reference edge with its distance (#37). + enter |= crate::hole_refs::input_rows(d, ui, &app.session); + } else { + input_rows(d, ui, &app.session); + } match &mut d.kind { Kind::Material { index } => { let names = app.session.doc.material_names(); @@ -197,7 +213,7 @@ pub(super) fn rows(app: &mut SolveApp, ctx: &egui::Context, d: &mut Dialog, ui: } } } - Kind::SketchModify(k) => enter |= crate::sketch_modify_ui::rows(ui, k), + Kind::SketchModify(k) => enter |= crate::sketch_modify_ui::rows(ui, k, &app.session.doc), Kind::Text(k) => enter |= crate::sketch_text::rows(ui, k, &mut d.inputs, &mut d.active), Kind::TextMove(k) => enter |= crate::sketch_text_move::rows(ui, k), Kind::EditParam { name, expr } => { @@ -468,23 +484,23 @@ pub(super) fn rows(app: &mut SolveApp, ctx: &egui::Context, d: &mut Dialog, ui: } } Kind::PatternRect { count, spacing, count2, spacing2 } => { - for (l, v) in [("Quantity", count), ("Spacing", spacing), ("Quantity 2", count2), ("Spacing 2", spacing2)] { - enter |= expr_row(ui, l, v); - } + enter |= quantity_row(ui, &app.session.doc, "Quantity", count); + enter |= expr_row(ui, "Spacing", spacing); + enter |= quantity_row(ui, &app.session.doc, "Quantity 2", count2); + enter |= expr_row(ui, "Spacing 2", spacing2); if d.inputs.first().is_some_and(|i| i.accept == selection::FEATURES) { feature_pattern_alignment_row(ui, &mut d.extra, 0); feature_pattern_alignment_row(ui, &mut d.extra, 1); } } Kind::PatternCirc { count, angle } => { - for (l, v) in [("Quantity", count), ("Total angle", angle)] { - enter |= expr_row(ui, l, v); - } + enter |= quantity_row(ui, &app.session.doc, "Quantity", count); + enter |= expr_row(ui, "Total angle", angle); if d.inputs.first().is_some_and(|i| i.accept == selection::FEATURES) { feature_pattern_alignment_row(ui, &mut d.extra, 0); } } - Kind::Hole { diameter, depth, kind, cb_diameter, cb_depth, cs_diameter, cs_angle, opts } => { + Kind::Hole { kind, opts, .. } => { form_row(ui, "Extents", |ui| { let cur = if to_object_on(&d.inputs) { 1 } else { usize::from(opts.all) * 2 }; let mut ext = cur; @@ -513,30 +529,18 @@ pub(super) fn rows(app: &mut SolveApp, ctx: &egui::Context, d: &mut Dialog, ui: if opts.tapped { enter |= crate::dialogs_thread::hole_rows(ui, &app.session.doc, &mut opts.thread, &mut opts.custom_thread); } - if *kind == 0 { + // Through all, the tip lies past the part. + if *kind == 0 && !opts.all { form_row(ui, "Drill Point", |ui| { let mut tip = usize::from(opts.angled); combo(ui, "hole_tip", &["Flat", "Angle"], &mut tip); opts.angled = tip == 1; }); } - if !opts.all { - enter |= expr_row(ui, "Depth", depth); - } - if *kind == 0 && opts.angled { - enter |= expr_row(ui, "Tip Angle", &mut opts.tip_angle); - } - if !opts.tapped { - enter |= expr_row(ui, "Diameter", diameter); - } - if *kind == 1 { - enter |= expr_row(ui, "Counterbore Ø", cb_diameter); - enter |= expr_row(ui, "Counterbore depth", cb_depth); - } - if *kind == 2 { - enter |= expr_row(ui, "Countersink Ø", cs_diameter); - enter |= expr_row(ui, "Countersink angle", cs_angle); - } + // The section through the hole, each size beside its dimension (#2171). + let to_object = to_object_on(&d.inputs); + let doc = &app.session.doc; + enter |= crate::dialogs_hole::rows(ui, &mut d.kind, to_object, &|e, k| doc.eval(e, k).ok()); } _ => enter |= additional_rows(app, d, ui), } @@ -700,9 +704,8 @@ fn additional_rows(app: &mut SolveApp, d: &mut Dialog, ui: &mut egui::Ui) -> boo extra_options(ui, "boundary_options", &mut d.extra, &[("Remove Tools", "remove_tools")]); } Kind::PathPattern { count, spacing } => { - for (l, v) in [("Quantity", count), ("Spacing", spacing)] { - enter |= expr_row(ui, l, v); - } + enter |= quantity_row(ui, &app.session.doc, "Quantity", count); + enter |= expr_row(ui, "Spacing", spacing); } Kind::Pipe { diameter, wall } => { enter |= expr_row(ui, "Diameter", diameter); @@ -784,11 +787,15 @@ fn additional_rows(app: &mut SolveApp, d: &mut Dialog, ui: &mut egui::Ui) -> boo Kind::Surface(k) => enter |= crate::dialogs_surface::rows(ui, k), Kind::Sheet(k) => enter |= crate::dialogs_sheet::rows(app, ui, k, &mut d.inputs), Kind::Plastic(k) => enter |= crate::dialogs_plastic::rows(app, ui, k, &d.inputs), + Kind::Frame(k) => enter |= crate::dialogs_frame::rows(app, ui, k, &d.inputs), Kind::Assembly(k) => enter |= crate::dialogs_assembly::rows(app, ui, k, &mut d.inputs, &mut d.active), Kind::Measure { result, show, .. } => measure_rows(app, ui, result.as_ref(), show), Kind::Section { offset, flip, hatch } => { enter |= expr_row(ui, "Distance", offset); ui_kit::options(ui, "feature_options", &mut [("Flip", flip), ("Hatch", hatch)]); + if section_turns_off(&app.session, &d.inputs) { + ui_kit::text(ui, tr("section.off_hint"), ui_kit::Text::Hint("section_off")); + } } Kind::Rename { name, .. } => { form_row(ui, "Name", |ui| { diff --git a/crates/ui-egui/src/dialogs_frame.rs b/crates/ui-egui/src/dialogs_frame.rs new file mode 100644 index 0000000000..9ec3283f53 --- /dev/null +++ b/crates/ui-egui/src/dialogs_frame.rs @@ -0,0 +1,215 @@ +//! Frame Member (`frame.member`): structural members along the lines of a frame sketch. The lines +//! are picked in the view; the section is a built-in profile with its dimensions, or the profiles +//! picked in another sketch. Anchor, angle and mirror place the section on each line. + +use serde_json::{Map, Value, json}; +use solidcraft_engine::Sel; +use solidcraft_engine::doc::expr::Kind as ValueKind; +use solidcraft_engine::doc::frame::{self, MemberProfile}; +use solidcraft_engine::doc::{FeatureKind, ProfileSel}; + +use crate::SolveApp; +use crate::dialogs::{Kind, check, curves_sketch, form_row, profile_indices, put, shown_expr}; +use crate::selection::{PROFILES, SKETCH_LINES, SelInput, SelectionExt as _}; +use crate::ui_kit::{choice_row, expr_row, hint_row, text_row}; + +/// The anchors as `frame.member` names them, and their labels. +const ANCHORS: [(&str, &str); 11] = [ + ("origin", "Origin"), + ("center", "Center"), + ("centroid", "Centroid"), + ("top_left", "Top Left"), + ("top", "Top Middle"), + ("top_right", "Top Right"), + ("left", "Left Middle"), + ("right", "Right Middle"), + ("bottom_left", "Bottom Left"), + ("bottom", "Bottom Middle"), + ("bottom_right", "Bottom Right"), +]; + +/// The Frame Member dialog's values (the picks are its selection inputs). +#[derive(Clone, Debug)] +pub struct Member { + /// Every line of the frame sketch, now and as lines are added, not only the lines picked. + pub all: bool, + /// The built-in profile (an index into `frame::profile_kinds`), used when no section is picked. + pub kind: usize, + /// Each built-in profile's dimensions, its required ones then its optional ones; kept per + /// profile so switching back finds them again. An empty optional one takes its default. + pub dims: Vec>, + /// A profile sketch whose whole drawn section is the member's (nested loops alternating), as + /// a feature made that way keeps it while no section is picked. + pub whole: Option, + pub anchor: usize, + pub angle: String, + pub mirror: bool, + pub name: String, +} + +impl Member { + fn new() -> Member { + let dims = frame::profile_kinds() + .iter() + .map(|k| { + let example = serde_json::to_value(&k.example).unwrap_or(Value::Null); + let given = k.required.iter().map(|d| example.get(*d).and_then(Value::as_f64).map(|v| format!("{v} mm")).unwrap_or_default()); + given.chain(k.optional.iter().map(|_| String::new())).collect() + }) + .collect(); + // A rectangular tube is the most common member. + let kind = frame::profile_kinds().iter().position(|k| k.kind == "rect_tube").unwrap_or(0); + Member { all: false, kind, dims, whole: None, anchor: 0, angle: String::new(), mirror: false, name: String::new() } + } +} + +/// The dialog for Frame Member. +pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { + (id == "frame.member") + .then(|| (Kind::Frame(Member::new()), vec![SelInput::new("Lines", SKETCH_LINES, true), SelInput::new("Section (optional)", PROFILES, true)])) +} + +fn tr(text: &str) -> &str { + crate::i18n::tr_label(text) +} + +/// The dialog's own rows (after the selection inputs); true when Enter was pressed in a value. +pub fn rows(app: &SolveApp, ui: &mut egui::Ui, m: &mut Member, inputs: &[SelInput]) -> bool { + let mut enter = false; + form_row(ui, "All Lines", |ui| check(ui, &mut m.all)); + let picked = !crate::selection::items(inputs, 1).is_empty(); + if let Some(sketch) = m.whole.filter(|_| !picked) { + let name = app.session.doc.feature(sketch).map(|f| f.name.clone()).unwrap_or_default(); + let mut on = true; + form_row(ui, "Section", |ui| crate::ui_kit::checkbox(ui, &name, &mut on)); + if !on { + m.whole = None; + } + } else if !picked { + let kinds = frame::profile_kinds(); + let labels: Vec<&str> = kinds.iter().map(|k| k.label).collect(); + choice_row(ui, "Profile", "frame_profile", &mut m.kind, &labels); + if let (Some(k), Some(values)) = (kinds.get(m.kind), m.dims.get_mut(m.kind)) { + for (i, (name, v)) in k.required.iter().chain(k.optional).zip(values.iter_mut()).enumerate() { + let label = frame::dimension_label(name); + enter |= if i < k.required.len() { expr_row(ui, label, v) } else { hint_row(ui, label, v, tr("default")) }; + } + } + } + let anchors: Vec<&str> = ANCHORS.iter().map(|a| a.1).collect(); + choice_row(ui, "Anchor", "frame_anchor", &mut m.anchor, &anchors); + enter |= hint_row(ui, "Angle", &mut m.angle, "0 deg"); + form_row(ui, "Mirror", |ui| check(ui, &mut m.mirror)); + enter |= text_row(ui, "Name", &mut m.name, tr("from the profile")).0; + enter +} + +/// The command the dialog's OK runs (`extra`: parameters kept from the feature being edited). +pub fn commands(app: &SolveApp, m: &Member, inputs: &[SelInput], extra: &Map) -> Result, String> { + let section = crate::selection::items(inputs, 1).profiles(); + let section_sketch = section.first().map(|x| x.0).or(m.whole); + if section.iter().any(|x| Some(x.0) != section_sketch) { + return Err("the section's profiles must be in one sketch".into()); + } + // A line picked in the view comes with its sketch (line ids are a sketch's own); a line + // selected before the dialog opened is looked for in the newest sketch with it, not the + // section's. + let picks = crate::selection::items(inputs, 0); + let mut lines = Vec::new(); + let mut known = None; + for pick in picks { + match pick { + Sel::ExternalSketchCurve { sketch, id } => { + if known.is_some_and(|k| k != *sketch) { + return Err("the lines must be in one sketch".into()); + } + known = Some(*sketch); + lines.push(id.clone()); + } + Sel::SketchCurve { id } => lines.push(id.clone()), + _ => {} + } + } + if lines.is_empty() { + return Err("select the frame sketch's lines first".into()); + } + let sketch = known.or_else(|| curves_sketch(app, &lines, section_sketch)).ok_or("the lines must be in one sketch, not the section's")?; + let mut p = json!({"sketch": sketch}); + if !m.all { + p["lines"] = json!(lines); + } + if let Some(ps) = section_sketch { + p["profile_sketch"] = json!(ps); + if !section.is_empty() { + p["profiles"] = json!(section.iter().map(|x| x.1).collect::>()); + } + } else { + let kinds = frame::profile_kinds(); + let (k, values) = kinds.get(m.kind).zip(m.dims.get(m.kind)).ok_or("choose a profile")?; + let mut profile = json!({"kind": k.kind}); + for (name, v) in k.required.iter().chain(k.optional).zip(values) { + if !v.trim().is_empty() { + profile[*name] = json!(v); + } + } + p["profile"] = profile; + } + if m.anchor != 0 + && let Some((anchor, _)) = ANCHORS.get(m.anchor) + { + p["anchor"] = json!(anchor); + } + put(&mut p, &[("angle", &m.angle), ("body_name", m.name.trim())]); + if m.mirror { + p["mirror"] = json!(true); + } + if let Value::Object(o) = &mut p { + for (key, v) in extra { + o.entry(key.clone()).or_insert_with(|| v.clone()); + } + } + Ok(vec![("frame.member".to_string(), p)]) +} + +/// The dialog editing a frame member feature, filled from it. +pub fn for_feature(app: &SolveApp, kind: &FeatureKind) -> Option<(Kind, Vec, Map)> { + let FeatureKind::FrameMember { path_sketch, lines, profile, anchor, angle, mirror, body_name, .. } = kind else { return None }; + let (_, mut inputs) = start(app, "frame.member")?; + let st = app.session.model.state(); + let mut m = Member::new(); + m.all = lines.is_empty(); + let ids = if m.all { frame::member_line_ids(&st, kind).unwrap_or_default() } else { lines.clone() }; + if let Some(i) = inputs.get_mut(0) { + i.items = ids.into_iter().map(|id| Sel::ExternalSketchCurve { sketch: path_sketch.0, id }).collect(); + } + match profile { + MemberProfile::Library { profile } => { + let kinds = frame::profile_kinds(); + if let Some((index, k)) = kinds.iter().enumerate().find(|(_, k)| k.kind == profile.kind) + && let Some(values) = m.dims.get_mut(index) + { + m.kind = index; + for (name, v) in k.required.iter().chain(k.optional).zip(values.iter_mut()) { + *v = profile.dims.get(*name).map(|e| shown_expr(e, ValueKind::Length)).unwrap_or_default(); + } + } + } + MemberProfile::Sketch { sketch, profiles: ProfileSel::All } => m.whole = Some(sketch.0), + MemberProfile::Sketch { sketch, profiles } => { + let picks = st.sketch(sketch.0).map(|ss| profile_indices(ss, profiles)).unwrap_or_default(); + if let Some(i) = inputs.get_mut(1) { + i.items = picks.into_iter().map(|index| Sel::Profile { sketch: sketch.0, index }).collect(); + } + } + } + let anchor = serde_json::to_value(anchor).ok(); + m.anchor = ANCHORS.iter().position(|(a, _)| anchor.as_ref().and_then(Value::as_str) == Some(*a)).unwrap_or(0); + m.angle = angle.as_deref().map(|a| shown_expr(a, ValueKind::Angle)).unwrap_or_default(); + m.mirror = *mirror; + m.name = body_name.clone().unwrap_or_default(); + Some((Kind::Frame(m), inputs, Map::new())) +} + +#[cfg(test)] +#[path = "dialogs_frame_tests.rs"] +mod tests; diff --git a/crates/ui-egui/src/dialogs_frame_tests.rs b/crates/ui-egui/src/dialogs_frame_tests.rs new file mode 100644 index 0000000000..cfa5f2260b --- /dev/null +++ b/crates/ui-egui/src/dialogs_frame_tests.rs @@ -0,0 +1,197 @@ +use crate::test_support::run_all; +use serde_json::json; +use solidcraft_engine::Session; + +use super::*; +use crate::Services; +use crate::dialogs::{Dialog, apply_commands, for_feature as edit_dialog}; + +fn app_with(cmds: &[(&str, Value)]) -> SolveApp { + let mut s = Session::default(); + for (id, p) in cmds { + s.execute(id, p).unwrap(); + } + SolveApp::new(s, Services::default()) +} + +/// A 600 × 400 rectangle on XY as the frame sketch `Frame`, and, drawn after it on YZ, a ring of +/// two circles as the section sketch `Ring` (its circles' ids are not the frame's line ids). +fn frame_app() -> SolveApp { + app_with(&[ + ("sketch.create", json!({"plane": "XY", "name": "Frame"})), + ("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [600, 400]})), + ("sketch.finish", json!({})), + ("sketch.create", json!({"plane": "YZ", "name": "Ring"})), + ("sketch.circle.center", json!({"center": [0, 0], "radius": 20})), + ("sketch.circle.center", json!({"center": [0, 0], "radius": 17})), + ("sketch.finish", json!({})), + ]) +} + +fn sketch_id(app: &SolveApp, name: &str) -> u64 { + app.session.doc.find_feature(name).unwrap().id +} + +fn frame_lines(app: &SolveApp) -> Vec { + app.session.doc.sketch(sketch_id(app, "Frame")).unwrap().curves.iter().map(|c| c.id.clone()).collect() +} + +fn body_names(app: &SolveApp) -> Vec { + app.session.world_state().bodies.iter().map(|b| b.name.clone()).collect() +} + +/// The dialog started with the given lines picked. +fn dialog_on(app: &mut SolveApp, lines: &[String]) -> Dialog { + app.session.selection = lines.iter().map(|id| Sel::SketchCurve { id: id.clone() }).collect(); + app.start("frame.member"); + app.dialog.clone().unwrap() +} + +fn member(d: &mut Dialog) -> &mut Member { + match &mut d.kind { + Kind::Frame(m) => m, + k => panic!("{k:?}"), + } +} + +/// Picked lines and the default built-in profile make one member a line; Edit Feature refills +/// the dialog and runs `timeline.redefine`, and the members keep their names. +#[test] +fn members_from_picked_lines_and_a_built_in_profile() { + let mut app = frame_app(); + let lines = frame_lines(&app); + let mut d = dialog_on(&mut app, &lines[..2]); + let c = apply_commands(&app, &d).unwrap(); + assert_eq!(c[0].0, "frame.member"); + let p = &c[0].1; + assert_eq!(p["sketch"], json!(sketch_id(&app, "Frame")), "the frame sketch, not the newer section sketch: {p}"); + assert_eq!(p["lines"], json!(lines[..2])); + assert_eq!(p["profile"], json!({"kind": "rect_tube", "width": "40 mm", "height": "40 mm", "wall": "3 mm"})); + assert!(p.get("anchor").is_none() && p.get("angle").is_none() && p.get("mirror").is_none() && p.get("body_name").is_none(), "{p}"); + // Another built-in profile and its dimensions; an optional one left empty takes its default. + let m = member(&mut d); + m.kind = frame::profile_kinds().iter().position(|k| k.kind == "angle").unwrap(); + m.dims[m.kind][2] = "4 mm".into(); + let c = apply_commands(&app, &d).unwrap(); + assert_eq!(c[0].1["profile"], json!({"kind": "angle", "width": "50 mm", "height": "50 mm", "thickness": "4 mm"})); + run_all(&mut app, c); + assert_eq!(body_names(&app), ["Angle 50x50x4", "Angle 50x50x4 (2)"]); + app.run("body.rename", json!({"body": "Angle 50x50x4 (2)", "name": "Rail"})).unwrap(); + let f = app.session.doc.features.last().unwrap().id; + let mut e = edit_dialog(&app, f, None).unwrap(); + assert_eq!(e.inputs[0].items.len(), 2); + let m = member(&mut e); + assert_eq!((frame::profile_kinds()[m.kind].kind, m.dims[m.kind][2].as_str()), ("angle", "4 mm")); + m.dims[m.kind][2] = "5 mm".into(); + let c = apply_commands(&app, &e).unwrap(); + assert_eq!(c[0].0, "timeline.redefine"); + run_all(&mut app, c); + assert_eq!(body_names(&app), ["Angle 50x50x4", "Rail"], "the members keep their names through the edit"); +} + +/// A section picked in another sketch is the profile, the picks naming its regions; All Lines, +/// anchor, angle, mirror and name reach the command and come back when editing. +#[test] +fn a_picked_section_and_the_placement() { + let mut app = frame_app(); + let lines = frame_lines(&app); + let ring = sketch_id(&app, "Ring"); + let mut d = dialog_on(&mut app, &lines[..1]); + // The ring between the circles: the region whose interior point lies between radii 17 and 20. + let ss = app.session.world_state().sketch(ring).unwrap().clone(); + let index = ss.profiles.iter().position(|p| (p.region.interior_point().len() - 18.5).abs() < 1.5).unwrap(); + d.inputs[1].items = vec![Sel::Profile { sketch: ring, index }]; + let m = member(&mut d); + m.all = true; + m.anchor = ANCHORS.iter().position(|a| a.0 == "bottom").unwrap(); + m.angle = "15 deg".into(); + m.mirror = true; + m.name = " Rail ".into(); + let c = apply_commands(&app, &d).unwrap(); + let p = &c[0].1; + assert_eq!(p["sketch"], json!(sketch_id(&app, "Frame"))); + assert!(p.get("lines").is_none() && p.get("profile").is_none(), "{p}"); + assert_eq!((&p["profile_sketch"], &p["profiles"]), (&json!(ring), &json!([index]))); + assert_eq!((&p["anchor"], &p["angle"], &p["mirror"], &p["body_name"]), (&json!("bottom"), &json!("15 deg"), &json!(true), &json!("Rail"))); + run_all(&mut app, c); + assert_eq!(body_names(&app), ["Rail", "Rail (2)", "Rail (3)", "Rail (4)"]); + let f = app.session.doc.features.last().unwrap().id; + let mut e = edit_dialog(&app, f, None).unwrap(); + assert_eq!(e.inputs[0].items.len(), 4, "every line of the sketch shows as picked"); + assert_eq!(e.inputs[1].items, [Sel::Profile { sketch: ring, index }]); + let m = member(&mut e); + assert!(m.all && m.mirror && m.name == "Rail" && m.angle == "15 deg" && ANCHORS[m.anchor].0 == "bottom", "{m:?}"); +} + +/// A member whose section is a whole profile sketch keeps it when edited: the dialog shows the +/// sketch rather than picking every region of it, which would fill the tube's hole. +#[test] +fn a_whole_sketch_section_stays_whole_through_an_edit() { + let mut app = frame_app(); + app.run("frame.member", json!({"sketch": "Frame", "profile_sketch": "Ring"})).unwrap(); + let volume = |app: &mut SolveApp| app.session.execute("inspect.measure", &json!({})).unwrap()["bodies"][0]["volume_mm3"].as_f64().unwrap(); + let before = volume(&mut app); + let f = app.session.doc.features.last().unwrap().id; + let mut e = edit_dialog(&app, f, None).unwrap(); + assert!(e.inputs[1].items.is_empty()); + assert_eq!(member(&mut e).whole, Some(sketch_id(&app, "Ring"))); + member(&mut e).angle = "30 deg".into(); + let c = apply_commands(&app, &e).unwrap(); + assert!(c[0].1["params"].get("profiles").is_none(), "{}", c[0].1); + run_all(&mut app, c); + assert!((volume(&mut app) - before).abs() < 1e-6 * before, "still the ring, not the filled circle"); + // Unticked, the section is a built-in profile again. + let mut e = edit_dialog(&app, f, None).unwrap(); + member(&mut e).whole = None; + assert_eq!(apply_commands(&app, &e).unwrap()[0].1["params"]["profile"]["kind"], "rect_tube"); +} + +/// Without lines there is nothing to place; lines and the section in one sketch are refused. +#[test] +fn missing_or_mixed_picks_are_refused() { + let mut app = frame_app(); + let d = dialog_on(&mut app, &[]); + assert!(apply_commands(&app, &d).unwrap_err().contains("lines")); + let ring = sketch_id(&app, "Ring"); + let circles: Vec = app.session.doc.sketch(ring).unwrap().curves.iter().map(|c| c.id.clone()).collect(); + let mut d = dialog_on(&mut app, &circles[..1]); + d.inputs[1].items = vec![Sel::Profile { sketch: ring, index: 0 }]; + assert!(apply_commands(&app, &d).is_err()); +} + +/// Lines picked in the view keep their sketch: two 3D sketches both number their lines `w1`…, and +/// the members go on the lines that were clicked, not on the newer sketch's lines of those ids. +#[test] +fn picked_lines_stay_in_the_sketch_they_were_picked_in() { + let mut app = app_with(&[ + ("sketch.create", json!({"plane": "XY", "name": "Uprights"})), + ("sketch.options", json!({"sketch_3d": true})), + ("sketch.line3d", json!({"points": [[0, 0, 0], [0, 0, 500]]})), + ("sketch.line3d", json!({"points": [[300, 0, 0], [300, 0, 500]]})), + ("sketch.finish", json!({})), + ("sketch.create", json!({"plane": "XY", "name": "Rails"})), + ("sketch.options", json!({"sketch_3d": true})), + ("sketch.line3d", json!({"points": [[0, 0, 250], [300, 0, 250]]})), + ("sketch.line3d", json!({"points": [[0, 0, 480], [300, 0, 480]]})), + ("sketch.finish", json!({})), + ]); + let (uprights, rails) = (sketch_id(&app, "Uprights"), sketch_id(&app, "Rails")); + let wires = |id: u64| -> Vec { app.session.doc.sketch(id).unwrap().wires.iter().map(|w| w.id.clone()).collect() }; + let ids = wires(uprights); + assert_eq!(ids, wires(rails), "the two sketches share their line ids"); + app.start("frame.member"); + let mut d = app.dialog.clone().unwrap(); + d.inputs[0].items = ids.iter().map(|id| Sel::ExternalSketchCurve { sketch: uprights, id: id.clone() }).collect(); + let c = apply_commands(&app, &d).unwrap(); + assert_eq!(c[0].1["sketch"], json!(uprights)); + run_all(&mut app, c.clone()); + // Two posts the height of the uprights, not two rails. + let m = app.session.execute("inspect.measure", &json!({})).unwrap(); + for b in m["bodies"].as_array().unwrap() { + let (lo, hi) = (b["bbox"]["min"][2].as_f64().unwrap(), b["bbox"]["max"][2].as_f64().unwrap()); + assert!(lo.abs() < 1e-6 && (hi - 500.0).abs() < 1e-6, "{b}"); + } + // Lines picked in two sketches are refused. + d.inputs[0].items.push(Sel::ExternalSketchCurve { sketch: rails, id: ids[0].clone() }); + assert!(apply_commands(&app, &d).unwrap_err().contains("one sketch")); +} diff --git a/crates/ui-egui/src/dialogs_hole.rs b/crates/ui-egui/src/dialogs_hole.rs new file mode 100644 index 0000000000..3326cdb33f --- /dev/null +++ b/crates/ui-egui/src/dialogs_hole.rs @@ -0,0 +1,355 @@ +//! The Hole dialog's section (#2171): a cut through the hole as its options make it (simple, +//! counterbore or countersink; to a depth, to an object or through all; a flat or an angled +//! tip; tapped), with each size typed in a field beside the dimension it sets. The picture is +//! schematic: its widths follow the diameters loosely, its heights line up with the fields. +//! Hovering a field, or typing in it, lights its dimension. + +use egui::{Color32, Pos2, Rect, Shape, Stroke, pos2, vec2}; +use solidcraft_engine::doc::expr::Kind as ValueKind; + +use crate::dialogs::{HoleOpts, Kind}; +use crate::theme::Tokens; +use crate::ui_kit::FigureSlot; + +/// A size the section shows. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Dim { + /// The counterbore's or the countersink's diameter, across the top. + TopDiameter, + CbDepth, + CsAngle, + Depth, + Diameter, + TipAngle, +} + +impl Dim { + /// The field's name, as the rows had it (scenarios find `dialog:`). + pub(crate) fn label(self, kind: usize) -> &'static str { + match self { + Dim::TopDiameter if kind == 2 => "Countersink Ø", + Dim::TopDiameter => "Counterbore Ø", + Dim::CbDepth => "Counterbore depth", + Dim::CsAngle => "Countersink angle", + Dim::Depth => "Depth", + Dim::Diameter => "Diameter", + Dim::TipAngle => "Tip Angle", + } + } +} + +/// The sizes a hole of type `kind` takes, top to bottom: a depth unless it goes through all or +/// to an object, a diameter unless it is tapped (the thread sets it), a tip angle for an angled +/// tip at the bottom of a blind simple hole. +pub(crate) fn dims(kind: usize, opts: &HoleOpts, to_object: bool) -> Vec { + let mut v = match kind { + 1 => vec![Dim::TopDiameter, Dim::CbDepth], + 2 => vec![Dim::TopDiameter, Dim::CsAngle], + _ => Vec::new(), + }; + if !opts.all && !to_object { + v.push(Dim::Depth); + } + if !opts.tapped { + v.push(Dim::Diameter); + } + if kind == 0 && opts.angled && !opts.all { + v.push(Dim::TipAngle); + } + v +} + +/// What the picture is drawn from. +struct Section { + kind: usize, + through: bool, + to_object: bool, + angled: bool, + tapped: bool, + /// The bore's diameter over the counterbore's or countersink's (1 for a simple hole). + ratio: f32, + /// Half the countersink's and the tip's angles (radians). + cs_half: f32, + tip_half: f32, +} + +/// The section and its sizes (`ui_kit::figure_rows`): the picture in the label column, the +/// fields beside it. Returns whether Enter was accepted in a field. +pub(crate) fn rows(ui: &mut egui::Ui, k: &mut Kind, to_object: bool, eval: &dyn Fn(&str, ValueKind) -> Option) -> bool { + let Kind::Hole { diameter, depth, kind, cb_diameter, cb_depth, cs_diameter, cs_angle, opts } = k else { return false }; + let kind = *kind; + let dims = dims(kind, opts, to_object); + let len = |e: &str| eval(e, ValueKind::Length).filter(|v| v.is_finite() && *v > 0.0); + let angle = |e: &str, lo: f64, hi: f64, or: f64| eval(e, ValueKind::Angle).filter(|v| v.is_finite()).map_or(or, |v| (v / 2.0).clamp(lo, hi)); + let top = if kind == 2 { cs_diameter.as_str() } else { cb_diameter.as_str() }; + let ratio = match (len(diameter.as_str()), len(top)) { + (Some(d), Some(t)) if kind != 0 => (d / t).clamp(0.3, 0.8) as f32, + _ if kind != 0 => 0.55, + _ => 1.0, + }; + let section = Section { + kind, + through: opts.all, + to_object, + angled: kind == 0 && opts.angled, + tapped: opts.tapped, + ratio, + cs_half: angle(cs_angle.as_str(), 0.35, 1.3, std::f64::consts::FRAC_PI_4) as f32, + tip_half: angle(opts.tip_angle.as_str(), 0.5, 1.3, 1.03) as f32, + }; + // The fields in `dims`' order, each value taken once. + let mut fields: Vec<(&str, &mut String)> = Vec::with_capacity(dims.len()); + match kind { + 1 => { + fields.push((Dim::TopDiameter.label(kind), cb_diameter)); + fields.push((Dim::CbDepth.label(kind), cb_depth)); + } + 2 => { + fields.push((Dim::TopDiameter.label(kind), cs_diameter)); + fields.push((Dim::CsAngle.label(kind), cs_angle)); + } + _ => {} + } + if dims.contains(&Dim::Depth) { + fields.push((Dim::Depth.label(kind), depth)); + } + if dims.contains(&Dim::Diameter) { + fields.push((Dim::Diameter.label(kind), diameter)); + } + if dims.contains(&Dim::TipAngle) { + fields.push((Dim::TipAngle.label(kind), &mut opts.tip_angle)); + } + crate::ui_kit::figure_rows(ui, "hole section", &mut fields, |painter, pic, slots| { + let slots: Vec<(Dim, Rect, bool)> = dims.iter().zip(slots).map(|(d, s): (&Dim, &FigureSlot)| (*d, s.rect, s.hot)).collect(); + paint(painter, pic, §ion, &slots); + }) +} + +/// A small filled arrowhead at `tip`, pointing away from `from`. +fn head(p: &egui::Painter, tip: Pos2, from: Pos2, col: Color32) { + let d = (tip - from).normalized(); + let n = vec2(-d.y, d.x); + p.add(Shape::convex_polygon(vec![tip, tip - d * 6.0 + n * 2.5, tip - d * 6.0 - n * 2.5], col, Stroke::NONE)); +} + +/// A dimension line from `a` to `b` with arrowheads at both ends. +fn dimension(p: &egui::Painter, a: Pos2, b: Pos2, stroke: Stroke) { + p.line_segment([a, b], stroke); + head(p, a, b, stroke.color); + head(p, b, a, stroke.color); +} + +/// An arc about `c` of radius `r` between the directions at `from` and `to` (radians, screen y +/// down), with arrowheads at both ends; returns its middle. +fn angle_arc(p: &egui::Painter, c: Pos2, r: f32, from: f32, to: f32, stroke: Stroke) -> Pos2 { + let at = |a: f32| c + vec2(a.cos(), a.sin()) * r; + let pts: Vec = (0..=16).map(|k| at(from + (to - from) * k as f32 / 16.0)).collect(); + p.add(Shape::line(pts.clone(), stroke)); + if let (Some(a), Some(a2), Some(b), Some(b2)) = (pts.first(), pts.get(2), pts.last(), pts.get(14)) { + head(p, *a, *a2, stroke.color); + head(p, *b, *b2, stroke.color); + } + at((from + to) / 2.0) +} + +/// Diagonal hatching across `r` (a cut through material). +fn hatch(p: &egui::Painter, r: Rect, stroke: Stroke) { + let step = 7.0; + let mut x = r.left() - r.height(); + while x < r.right() { + // The line x + s, bottom - s for s in 0..height, clipped to the rectangle. + let (s0, s1) = ((r.left() - x).max(0.0), (r.right() - x).min(r.height())); + if s1 > s0 { + p.line_segment([pos2(x + s0, r.bottom() - s0), pos2(x + s1, r.bottom() - s1)], stroke); + } + x += step; + } +} + +/// Draw the section into `pic`, its dimensions at the heights of the fields in `slots`, each +/// with a leader to its field. +fn paint(p: &egui::Painter, pic: Rect, s: &Section, slots: &[(Dim, Rect, bool)]) { + let t = Tokens::get(); + let field = |d: Dim| slots.iter().find(|x| x.0 == d).map(|x| (x.1, x.2)); + let y_of = |d: Dim| field(d).map(|(r, _)| r.center().y); + let ink = |d: Dim| { + let hot = field(d).is_some_and(|(_, h)| h); + if hot { Stroke::new(1.6, t.form_primary) } else { Stroke::new(1.0, t.form_dim) } + }; + // A leader from a dimension's point to its field's left edge. + let leader = |d: Dim, from: Pos2| { + if let Some((r, _)) = field(d) { + let to = pos2(r.left() - 3.0, r.center().y); + let st = ink(d); + p.line_segment([from, to], Stroke::new(st.width.min(1.2), st.color)); + p.circle_filled(from, 1.8, st.color); + } + }; + // Across: the axis, the widest half width, the material's sides; the vertical dimensions sit + // right of the material, before the fields. + let cx = pic.left() + 44.0; + let wide = 24.0; + let (left, right) = (pic.left() + 6.0, pic.left() + 76.0); + let bottom = pic.bottom() - 3.0; + let top_r = wide; + let bore_r = if s.kind == 0 { wide * 0.55 } else { wide * s.ratio }; + // Down: the top face, the counterbore's floor or the countersink's end, the bore's bottom. + let top = y_of(Dim::TopDiameter).map_or(pic.top() + 14.0, |y| y + 14.0); + let step = match s.kind { + 1 => y_of(Dim::CbDepth).map_or(top + 16.0, |y| y + 9.0).max(top + 12.0), + 2 => top + ((top_r - bore_r) / s.cs_half.tan().max(0.2)).clamp(6.0, 28.0), + _ => top, + }; + let tip_h = if s.angled { (bore_r / s.tip_half.tan().max(0.2)).clamp(4.0, 16.0) } else { 0.0 }; + let shoulder = if s.through { + bottom + 1.0 + } else { + let mut y = step + 18.0; + for d in [Dim::Depth, Dim::Diameter] { + if let Some(v) = y_of(d) { + y = y.max(v + 10.0); + } + } + if let Some(v) = y_of(Dim::TipAngle) { + y = y.max(v - tip_h - 2.0); + } + y.min(bottom - tip_h - 10.0).max(step + 8.0) + }; + // The material, cut: hatched, with the hole's room cleared out of it. + let block = Rect::from_min_max(pos2(left, top), pos2(right, bottom)); + p.rect_filled(block, 0.0, t.form_dim.gamma_multiply(0.16)); + hatch(p, block, Stroke::new(0.8, t.form_dim.gamma_multiply(0.45))); + // The right half of the hole's outline, top to bottom (half widths), then mirrored. + let mut half: Vec<(f32, f32)> = match s.kind { + 1 => vec![(top_r, top), (top_r, step), (bore_r, step), (bore_r, shoulder)], + 2 => vec![(top_r, top), (bore_r, step), (bore_r, shoulder)], + _ => vec![(bore_r, top), (bore_r, shoulder)], + }; + if s.through + && let Some(last) = half.last_mut() + { + last.1 = bottom + 1.0; + } + let clear = t.dialog_body; + for w in half.windows(2) { + let &[(a, ya), (b, yb)] = w else { continue }; + if yb > ya { + p.add(Shape::convex_polygon(vec![pos2(cx - a, ya), pos2(cx + a, ya), pos2(cx + b, yb), pos2(cx - b, yb)], clear, Stroke::NONE)); + } + } + let apex = pos2(cx, shoulder + tip_h); + if s.angled && !s.through { + p.add(Shape::convex_polygon(vec![pos2(cx - bore_r, shoulder), pos2(cx + bore_r, shoulder), apex], clear, Stroke::NONE)); + } + // A tapped hole's thread: thin lines at the thread's major diameter, in the material. + if s.tapped { + let r = bore_r * 1.22; + let (y0, y1) = (half.iter().rev().nth(1).map_or(top, |x| x.1), if s.through { bottom } else { shoulder - 3.0 }); + for x in [cx - r, cx + r] { + p.line_segment([pos2(x, y0 + 2.0), pos2(x, y1)], Stroke::new(1.0, t.form_ink)); + } + } + // Outlines: the block's top and sides, the hole's walls and bottom. + let edge = Stroke::new(1.4, t.form_ink); + let wall = |sign: f32| -> Vec { half.iter().map(|(w, y)| pos2(cx + sign * w, *y)).collect() }; + let opening = half.first().map_or(bore_r, |x| x.0); + p.add(Shape::line(vec![pos2(left, bottom), pos2(left, top), pos2(cx - opening, top)], edge)); + p.add(Shape::line(vec![pos2(cx + opening, top), pos2(right, top), pos2(right, bottom)], edge)); + p.add(Shape::line(wall(-1.0), edge)); + p.add(Shape::line(wall(1.0), edge)); + if s.through { + p.line_segment([pos2(left, bottom), pos2(cx - bore_r, bottom)], edge); + p.line_segment([pos2(cx + bore_r, bottom), pos2(right, bottom)], edge); + } else { + p.line_segment([pos2(left, bottom), pos2(right, bottom)], edge); + if s.angled { + p.add(Shape::line(vec![pos2(cx - bore_r, shoulder), apex, pos2(cx + bore_r, shoulder)], edge)); + } else { + p.line_segment([pos2(cx - bore_r, shoulder), pos2(cx + bore_r, shoulder)], edge); + } + } + // To an object: the face it stops at, dashed across the bottom of the hole. + if s.to_object { + let y = shoulder + tip_h + 3.0; + p.add(Shape::dashed_line(&[pos2(left - 2.0, y), pos2(right + 2.0, y)], Stroke::new(1.2, t.form_primary), 4.0, 3.0)); + } + // The hole's axis. + p.add(Shape::dashed_line(&[pos2(cx, top - 6.0), pos2(cx, (shoulder + tip_h + 6.0).min(pic.bottom()))], Stroke::new(0.8, t.form_dim), 6.0, 3.0)); + + // Dimensions, each lined up with its field. + let thin = |d: Dim| { + let st = ink(d); + Stroke::new(0.8, st.color) + }; + if let Some(y) = y_of(Dim::TopDiameter) { + let (a, b) = (pos2(cx - top_r, y), pos2(cx + top_r, y)); + p.line_segment([pos2(a.x, top - 2.0), pos2(a.x, y - 3.0)], thin(Dim::TopDiameter)); + p.line_segment([pos2(b.x, top - 2.0), pos2(b.x, y - 3.0)], thin(Dim::TopDiameter)); + dimension(p, a, b, ink(Dim::TopDiameter)); + leader(Dim::TopDiameter, b); + } + // Depths stand right of the material: the counterbore's nearer, the hole's farther out. + let mut x = right + 8.0; + if let Some(y) = y_of(Dim::CbDepth) { + p.line_segment([pos2(cx + top_r, step), pos2(x + 3.0, step)], thin(Dim::CbDepth)); + p.line_segment([pos2(right, top), pos2(x + 3.0, top)], thin(Dim::CbDepth)); + dimension(p, pos2(x, top), pos2(x, step), ink(Dim::CbDepth)); + leader(Dim::CbDepth, pos2(x, y.clamp(top + 2.0, step - 2.0))); + x += 10.0; + } + if let Some(y) = y_of(Dim::Depth) { + p.line_segment([pos2(cx + bore_r, shoulder), pos2(x + 3.0, shoulder)], thin(Dim::Depth)); + p.line_segment([pos2(right, top), pos2(x + 3.0, top)], thin(Dim::Depth)); + dimension(p, pos2(x, top), pos2(x, shoulder), ink(Dim::Depth)); + leader(Dim::Depth, pos2(x, y.clamp(top + 2.0, shoulder - 2.0))); + } + if let Some(y) = y_of(Dim::CsAngle) { + // About the point where the countersink's sides meet, between them. + let k = s.cs_half.tan().max(0.2); + let meet = pos2(cx, top + top_r / k); + let r = (meet.y - top) * 0.7; + let up = -std::f32::consts::FRAC_PI_2; + let mid = angle_arc(p, meet, r, up - s.cs_half, up + s.cs_half, ink(Dim::CsAngle)); + let end = meet + vec2((up + s.cs_half).cos(), (up + s.cs_half).sin()) * r; + leader(Dim::CsAngle, if (end.y - y).abs() < (mid.y - y).abs() { end } else { mid }); + } + if let Some(y) = y_of(Dim::Diameter) { + let lo = if s.kind == 0 { top + 3.0 } else { step + 3.0 }; + let hi = if s.through { bottom - 3.0 } else { shoulder - 3.0 }; + let y = if hi > lo { y.clamp(lo, hi) } else { (lo + hi) / 2.0 }; + let (a, b) = (pos2(cx - bore_r, y), pos2(cx + bore_r, y)); + dimension(p, a, b, ink(Dim::Diameter)); + leader(Dim::Diameter, b); + } + if y_of(Dim::TipAngle).is_some() && s.angled && !s.through { + // Most of the way along the tip's sides, so the arc spans them where they are clear. + let r = (bore_r.hypot(tip_h) * 0.8).max(6.0); + let up = -std::f32::consts::FRAC_PI_2; + angle_arc(p, apex, r, up - s.tip_half, up + s.tip_half, ink(Dim::TipAngle)); + leader(Dim::TipAngle, apex + vec2((up + s.tip_half).cos(), (up + s.tip_half).sin()) * r); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn opts(all: bool, angled: bool) -> HoleOpts { + HoleOpts { all, angled, ..HoleOpts::default() } + } + + #[test] + fn the_sizes_follow_the_options() { + use Dim::*; + assert_eq!(dims(0, &opts(false, true), false), vec![Depth, Diameter, TipAngle]); + assert_eq!(dims(0, &opts(false, false), false), vec![Depth, Diameter]); + assert_eq!(dims(0, &opts(true, true), false), vec![Diameter], "through all: no depth, no tip"); + assert_eq!(dims(0, &opts(false, true), true), vec![Diameter, TipAngle], "to an object: no depth"); + assert_eq!(dims(1, &opts(false, false), false), vec![TopDiameter, CbDepth, Depth, Diameter]); + assert_eq!(dims(2, &opts(true, false), false), vec![TopDiameter, CsAngle, Diameter]); + let tapped = HoleOpts { tapped: true, ..opts(false, true) }; + assert_eq!(dims(0, &tapped, false), vec![Depth, TipAngle], "tapped: the thread sets the diameter"); + assert_eq!(TopDiameter.label(1), "Counterbore Ø"); + assert_eq!(TopDiameter.label(2), "Countersink Ø"); + } +} diff --git a/crates/ui-egui/src/dialogs_motion.rs b/crates/ui-egui/src/dialogs_motion.rs index c4b1684b4c..ef724c3f26 100644 --- a/crates/ui-egui/src/dialogs_motion.rs +++ b/crates/ui-egui/src/dialogs_motion.rs @@ -175,7 +175,7 @@ fn transport(ui: &mut egui::Ui, what: Transport, tip: &str) -> egui::Response { let t = Tokens::get(); let (r, resp) = ui.allocate_exact_size(vec2(26.0, 22.0), Sense::click()); let fill = if resp.hovered() { t.hover } else { t.field }; - ui.painter().rect(r, 3.0, fill, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + ui.painter().rect(r, t.radius_sm.saturating_sub(1), fill, t.control_border_stroke(t.border), egui::StrokeKind::Inside); let c = r.center(); let ink = if matches!(what, Transport::Play | Transport::Stop) { t.accent } else { t.icon }; let p = ui.painter(); @@ -740,7 +740,7 @@ fn name_of(v: &Value) -> String { fn key_strip(ui: &mut egui::Ui, tr: &Track, total: u32, now: u32, t: &Tokens) { let (r, _) = ui.allocate_exact_size(vec2(240.0, 16.0), Sense::hover()); let p = ui.painter(); - p.rect_filled(r, 3.0, t.field); + p.rect_filled(r, t.radius_sm.saturating_sub(1), t.field); let x = |s: u32| r.left() + 6.0 + (r.width() - 12.0) * s as f32 / total.max(1) as f32; // Keys joined by a line (the motion between them). let pts: Vec = tr.points.iter().map(|(s, _)| pos2(x(*s), r.center().y)).collect(); diff --git a/crates/ui-egui/src/dialogs_motion_plot.rs b/crates/ui-egui/src/dialogs_motion_plot.rs index 03d9b01be7..df978ca673 100644 --- a/crates/ui-egui/src/dialogs_motion_plot.rs +++ b/crates/ui-egui/src/dialogs_motion_plot.rs @@ -142,7 +142,7 @@ fn chart(ui: &mut egui::Ui, xs: &[f64], ys: &[f64], unit: &str, total: u32, now: let (r, resp) = ui.allocate_exact_size(vec2(300.0, 120.0), Sense::click_and_drag()); crate::scenario::publish_rect("motion:chart", r); let p = ui.painter_at(r); - p.rect(r, 3.0, t.field, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + p.rect(r, t.radius_sm.saturating_sub(1), t.field, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); let plot = Rect::from_min_max(r.min + vec2(6.0, 16.0), r.max - vec2(6.0, 16.0)); let total = f64::from(total.max(1)); let x = |s: f64| plot.left() + plot.width() * (s / total).clamp(0.0, 1.0) as f32; diff --git a/crates/ui-egui/src/dialogs_move_tests.rs b/crates/ui-egui/src/dialogs_move_tests.rs index 33a6b0dc32..e2ba57417f 100644 --- a/crates/ui-egui/src/dialogs_move_tests.rs +++ b/crates/ui-egui/src/dialogs_move_tests.rs @@ -148,3 +148,95 @@ fn tab_in_the_box_goes_to_the_next_axis() { let b = h.app.session.model.state().body("Body1").unwrap().mesh().bounds(); assert!((b.min.y - 12.0).abs() < 1e-6 && b.min.x.abs() < 1e-6, "moved along Y: {b:?}"); } + +fn component_selection_fixture() -> crate::scenario::Harness { + let mut h = crate::scenario::Harness::new(); + for (command, params) in [ + ("solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [-30, 0, 0], "body_name": "Outside"})), + ("component.create", json!({"name": "Part"})), + ("solid.box", json!({"length": 10, "width": 10, "height": 10, "body_name": "Inside"})), + ("component.create", json!({"name": "Child"})), + ("solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [30, 0, 0], "body_name": "Nested"})), + ("component.activate", json!({"component": "root"})), + ("component.create", json!({"name": "Sibling"})), + ("solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [60, 0, 0], "body_name": "SiblingBody"})), + ("component.activate", json!({"component": "Part"})), + ] { + h.app.run(command, params).unwrap(); + } + h.app.ui.show_browser = false; + h.app.ui.show_timeline = false; + h.app.ui.show_origin = false; + h.step(&json!({"call": "ui.view", "params": {"view": "top"}})).unwrap(); + // Keep the selection rectangle to the right of the Move dialog. + h.step(&json!({"call": "ui.camera", "params": {"target": [0, 0, 0], "distance": 250}})).unwrap(); + h +} + +fn select_component_boxes(h: &mut crate::scenario::Harness, crossing: bool, add: bool) { + let ends = if crossing { [[75, -5, 0], [-35, 15, 0]] } else { [[-35, -5, 0], [75, 15, 0]] }; + h.step(&json!({"drag": [{"world": ends[0]}, {"world": ends[1]}], "shift": add})).unwrap(); +} + +#[test] +fn move_box_selection_stays_in_the_active_component_and_its_children() { + for crossing in [false, true] { + let mut h = component_selection_fixture(); + h.app.start("solid.move"); + h.settle(); + for add in [false, true] { + select_component_boxes(&mut h, crossing, add); + let names = h.app.dialog.as_ref().unwrap().inputs[0].items.body_names(); + assert_eq!(names, ["Inside", "Nested"], "crossing={crossing}, add={add}"); + } + } +} + +#[test] +fn body_box_preselection_uses_component_scope_and_root_keeps_all_bodies() { + let mut h = component_selection_fixture(); + select_component_boxes(&mut h, false, false); + assert_eq!(h.app.session.selection.body_names(), ["Inside", "Nested"]); + h.app.start("solid.move"); + assert_eq!(h.app.dialog.as_ref().unwrap().inputs[0].items.body_names(), ["Inside", "Nested"]); + h.step(&json!({"key": "Escape"})).unwrap(); + h.app.run("component.activate", json!({"component": "root"})).unwrap(); + h.app.run("select.clear", json!({})).unwrap(); + h.app.start("solid.move"); + h.settle(); + select_component_boxes(&mut h, true, false); + assert_eq!(h.app.dialog.as_ref().unwrap().inputs[0].items.body_names(), ["Outside", "Inside", "SiblingBody", "Nested"]); +} + +#[test] +fn move_box_selection_uses_the_active_occurrence_path() { + let mut h = component_selection_fixture(); + let part = h.app.session.active_component; + let second = h.app.run("occurrence.copy", json!({"component": part, "translate": [0, 30, 0]})).unwrap()["occurrence"].as_u64().unwrap(); + h.app.run("component.activate", json!({"component": part, "occurrence": second, "path": [second]})).unwrap(); + let mut expected = h.app.session.occurrence_bodies(&[second], true).unwrap(); + expected.sort(); + h.app.start("solid.move"); + h.settle(); + h.step(&json!({"drag": [{"world": [-35, -5, 0]}, {"world": [75, 45, 0]}]})).unwrap(); + let mut names = h.app.dialog.as_ref().unwrap().inputs[0].items.body_names(); + names.sort(); + assert_eq!(names, expected, "only the active placement and its nested child are picked"); +} + +#[test] +fn move_reference_box_selection_can_still_use_an_inactive_body() { + let mut h = component_selection_fixture(); + h.app.start("solid.move"); + { + let d = h.app.dialog.as_mut().unwrap(); + d.kind = kind(ROTATE); + d.inputs = inputs(ROTATE); + d.inputs[0].items = vec![Sel::Body { name: "Inside".into() }]; + d.active = 1; + } + h.settle(); + h.step(&json!({"drag": [{"world": [-35, -5, 0]}, {"world": [-15, 15, 0]}]})).unwrap(); + let axis = &h.app.dialog.as_ref().unwrap().inputs[1].items; + assert!(matches!(axis.as_slice(), [Sel::Edge { body, .. }] if body == "Outside"), "{axis:?}"); +} diff --git a/crates/ui-egui/src/dialogs_save_mesh.rs b/crates/ui-egui/src/dialogs_save_mesh.rs index 3e158ce0a2..1352edcadd 100644 --- a/crates/ui-egui/src/dialogs_save_mesh.rs +++ b/crates/ui-egui/src/dialogs_save_mesh.rs @@ -144,7 +144,7 @@ fn paint_icon(ui: &egui::Ui, rect: egui::Rect, tex: Option, nam } None => { let t = Tokens::get(); - ui.painter().rect_filled(rect, 3.0, t.chip); + ui.painter().rect_filled(rect, t.radius_sm.saturating_sub(1), t.chip); let letter = name.chars().next().map(|c| c.to_uppercase().to_string()).unwrap_or_default(); ui.painter().text( rect.center(), @@ -212,7 +212,7 @@ fn slicer_combo(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut SaveMesh) { let selected = e.key == k.slicer; if resp.hovered() || selected { let fill = if resp.hovered() { Tokens::get().hover } else { Tokens::get().selected_bg }; - ui.painter().rect_filled(rect, 2.0, fill); + ui.painter().rect_filled(rect, Tokens::get().radius_sm / 2, fill); } paint_icon(ui, egui::Rect::from_min_size(rect.left_center() + vec2(3.0, -ICON / 2.0), vec2(ICON, ICON)), tex, &e.slicer.name); ui.painter().text( @@ -240,7 +240,7 @@ fn slicer_combo(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut SaveMesh) { } let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); if resp.hovered() { - ui.painter().rect_filled(rect, 2.0, Tokens::get().hover); + ui.painter().rect_filled(rect, Tokens::get().radius_sm / 2, Tokens::get().hover); } ui.painter().text( rect.left_center() + vec2(ICON + 9.0, 0.0), diff --git a/crates/ui-egui/src/dialogs_sheet.rs b/crates/ui-egui/src/dialogs_sheet.rs index 635cfb6588..e5138081d6 100644 --- a/crates/ui-egui/src/dialogs_sheet.rs +++ b/crates/ui-egui/src/dialogs_sheet.rs @@ -11,7 +11,7 @@ use solidcraft_engine::Sel; use solidcraft_engine::doc::expr::Kind as ValueKind; use solidcraft_engine::doc::sheet::SheetRule; use solidcraft_engine::doc::{FeatureKind, ProfileSel}; -use solidcraft_engine::geom::Vec3; +use solidcraft_engine::geom::{Vec2, Vec3}; use crate::SolveApp; use crate::dialogs::{Dialog, Kind, edge_sel, face_sel, form_row, profile_indices}; @@ -756,8 +756,8 @@ fn bend_colors() -> (Color32, Color32) { (t.bend_up, t.bend_down) } -/// The flat pattern view: the outline, cut-outs and bend lines (up and down in their colours) -/// drawn flat over the viewport, fitted to it. True when the dialog is a flat pattern. +/// The flat pattern view: outline, cut-outs, perforations and bend lines (up and down in +/// their colours), fitted over the viewport. True when the dialog is a flat pattern. pub fn overlay(app: &mut SolveApp, painter: &egui::Painter, rect: Rect, d: &Dialog) -> bool { let Kind::Sheet(Sm::FlatPattern { data, .. }) = &d.kind else { return false }; let t = Tokens::get(); @@ -819,6 +819,22 @@ pub fn overlay(app: &mut SolveApp, painter: &egui::Painter, rect: Rect, d: &Dial painter.add(egui::Shape::closed_line(pts, Stroke::new(1.4, t.text))); } } + if let Some(body) = v["body"].as_str() { + app.flat_view.cache_perforations(&app.session, body); + let clip = rect.intersect(painter.clip_rect()); + let pixels_per_point = painter.ctx().pixels_per_point(); + for &(centre, radius) in &app.flat_view.perforations { + let radius = (radius * scale) as f32; + // Egui uses logical points; reject holes smaller than one physical pixel. + if !radius.is_finite() || radius * 2.0 * pixels_per_point < 1.0 { + continue; + } + let centre = to((centre.x, centre.y)); + if centre.x.is_finite() && centre.y.is_finite() && clip.intersects(Rect::from_center_size(centre, vec2(radius * 2.0, radius * 2.0))) { + painter.circle_stroke(centre, radius, Stroke::new(1.4, t.text)); + } + } + } // A pocket retains material below (or above) the cut. Dashed contours and a depth // annotation distinguish it from the solid through-cut boundaries. let pockets: Vec<&Value> = v["pockets"].as_array().into_iter().flatten().collect(); @@ -894,6 +910,9 @@ pub struct FlatView { pub cam: solidcraft_engine::render::Camera, /// The camera frames the pattern; cleared by Fit and Home, and whenever the dialog is closed. pub fitted: bool, + /// Flat centres stay out of the command JSON and are reused while navigating. + perforation_key: Option<(std::sync::Weak, String)>, + perforations: Vec<(Vec2, f64)>, } impl Default for FlatView { @@ -902,11 +921,35 @@ impl Default for FlatView { cam.set_view(solidcraft_engine::render::StandardView::Top); cam.fov = 0.0; cam.up = solidcraft_engine::render::UpAxis::Z; - FlatView { cam, fitted: false } + FlatView { cam, fitted: false, perforation_key: None, perforations: Vec::new() } } } impl FlatView { + fn cache_perforations(&mut self, session: &solidcraft_engine::Session, body: &str) { + let state = session.world_state(); + let state_key = std::sync::Arc::downgrade(&state); + if self.perforation_key.as_ref().is_some_and(|(cached, name)| cached.ptr_eq(&state_key) && name == body) { + return; + } + self.perforation_key = Some((state_key, body.to_owned())); + self.perforations.clear(); + let Some(sheet) = state.sheets.iter().find(|sheet| sheet.body == body) else { return }; + // Bound overlay storage and drawing work, including designs with many fields. + const MAX_HOLES: usize = 1_000_000; + for field in &sheet.perforations { + if self.perforations.len() >= MAX_HOLES { + break; + } + if !field.diameter.is_finite() || field.diameter <= 0.0 { + continue; + } + let Ok(centres) = sheet.perforation_holes(field) else { continue }; + let remaining = MAX_HOLES.saturating_sub(self.perforations.len()); + self.perforations.extend(centres.into_iter().take(remaining).map(|centre| (centre, field.diameter / 2.0))); + } + } + /// Frame the pattern's bounds `lo..hi` in `area`, the part of `rect` the command panel leaves. fn fit(&mut self, rect: Rect, area: Rect, lo: (f64, f64), hi: (f64, f64)) { let scale = (f64::from(area.width()) / (hi.0 - lo.0)).min(f64::from(area.height()) / (hi.1 - lo.1)); diff --git a/crates/ui-egui/src/dialogs_sheet_tests.rs b/crates/ui-egui/src/dialogs_sheet_tests.rs index 94f1ed43f1..b752350f2f 100644 --- a/crates/ui-egui/src/dialogs_sheet_tests.rs +++ b/crates/ui-egui/src/dialogs_sheet_tests.rs @@ -879,3 +879,77 @@ fn fit_and_home_frame_the_flat_pattern() { assert!(!app.flat_view.fitted); } } + +fn perforated_flat() -> (SolveApp, Dialog) { + let mut app = plate(crate::shell::LayoutMode::Floating); + app.run("sheet.flange", json!({"sketch": "Base"})).unwrap(); + app.run("sheet.perforate", json!({"diameter": 3, "pitch": 6})).unwrap(); + let flat = app.session.execute("sheet.flat_pattern", &json!({})).unwrap(); + assert_eq!(flat["perforations"][0]["count"], 155); + app.start("sheet.flat_pattern"); + let mut dialog = app.dialog.clone().unwrap(); + let Kind::Sheet(Sm::FlatPattern { data, .. }) = &mut dialog.kind else { panic!("flat dialog") }; + *data = Some(Ok(flat)); + (app, dialog) +} + +fn flat_circles(app: &mut SolveApp, dialog: &Dialog, rect: Rect) -> Vec { + let ctx = crate::fonts::test_context(); + let input = egui::RawInput { screen_rect: Some(rect), ..Default::default() }; + let output = crate::test_support::frame(&ctx, input, |ui| { + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Middle, egui::Id::new("perforation-test"))); + assert!(overlay(app, &painter, rect, dialog)); + }); + output.shapes.into_iter().filter_map(|s| if let egui::Shape::Circle(c) = s.shape { Some(c) } else { None }).collect() +} + +#[test] +fn flat_perforations_match_export_centres_and_cutout_style() { + let (mut app, dialog) = perforated_flat(); + let state = app.session.world_state(); + let sheet = state.sheets.last().unwrap(); + let holes = sheet.perforation_holes(&sheet.perforations[0]).unwrap(); + let rect = Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 900.0)); + let circles = flat_circles(&mut app, &dialog, rect); + assert_eq!(circles.len(), holes.len()); + let cam = app.flat_view.cam; + let scale = f64::from(rect.height()) / (2.0 * cam.half_height()); + for (circle, hole) in circles.iter().zip(holes) { + let centre = pos2( + (f64::from(rect.center().x) + (hole.x - cam.target.x) * scale) as f32, + (f64::from(rect.center().y) - (hole.y - cam.target.y) * scale) as f32, + ); + assert!(circle.center.distance(centre) < 0.001); + assert!((circle.radius - (1.5 * scale) as f32).abs() < 0.001); + assert_eq!(circle.stroke, Stroke::new(1.4, Tokens::get().text)); + assert_eq!(circle.fill, Color32::TRANSPARENT); + } + let cached = app.flat_view.perforations.as_ptr(); + assert_eq!(flat_circles(&mut app, &dialog, rect).len(), 155); + assert_eq!(app.flat_view.perforations.as_ptr(), cached, "navigation reuses the centres"); + app.run("sheet.perforate", json!({"diameter": 2, "pitch": 10})).unwrap(); + let state = app.session.world_state(); + let sheet = state.sheets.last().unwrap(); + let expected: usize = sheet.perforations.iter().map(|p| sheet.perforation_holes(p).unwrap().len()).sum(); + assert!(expected > 155); + assert_eq!(flat_circles(&mut app, &dialog, rect).len(), expected, "model changes refresh the centres"); +} + +#[test] +fn flat_perforations_cull_offscreen_and_subpixel_holes() { + let (mut app, dialog) = perforated_flat(); + let rect = Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 900.0)); + assert_eq!(flat_circles(&mut app, &dialog, rect).len(), 155); + let fitted_distance = app.flat_view.cam.distance; + // Keep a hole crossing the left viewport edge, even when its centre is outside. + let scale = f64::from(rect.height()) / (2.0 * app.flat_view.cam.half_height()); + app.flat_view.cam.target.x = 40.5 + (f64::from(rect.width()) / 2.0 + 0.75 * scale) / scale; + let partial = flat_circles(&mut app, &dialog, rect); + assert!(!partial.is_empty() && partial.len() < 155); + assert!(partial.iter().any(|c| c.center.x < rect.left() && c.center.x + c.radius > rect.left())); + app.flat_view.cam.target.x = 1e6; + assert!(flat_circles(&mut app, &dialog, rect).is_empty()); + app.flat_view.cam.target.x = 50.0; + app.flat_view.cam.distance = fitted_distance * 1000.0; + assert!(flat_circles(&mut app, &dialog, rect).is_empty()); +} diff --git a/crates/ui-egui/src/dialogs_sketch_tests.rs b/crates/ui-egui/src/dialogs_sketch_tests.rs index be9bf7cd2e..d84ca5cad9 100644 --- a/crates/ui-egui/src/dialogs_sketch_tests.rs +++ b/crates/ui-egui/src/dialogs_sketch_tests.rs @@ -207,3 +207,77 @@ fn apply_keeps_the_panel_open_after_asynchronous_evaluation() { assert!(d.inputs.iter().all(|i| i.items.is_empty())); assert!(!d.repeat_after_apply); } + +#[test] +fn circular_pattern_quantity_arrows_step_the_count_within_its_limits_and_keep_parameters() { + let mut h = fixture(); + h.app.dialog = Some(picked(&h, "sketch.pattern.circular")); + h.settle(); + let count = |h: &Harness| match &h.app.dialog.as_ref().unwrap().kind { + Kind::SketchModify(Mod::PatternCirc { count, .. }) => count.clone(), + other => panic!("not a sketch pattern window: {other:?}"), + }; + let set = |h: &mut Harness, text: &str| { + if let Some(Kind::SketchModify(Mod::PatternCirc { count, .. })) = h.app.dialog.as_mut().map(|d| &mut d.kind) { + *count = text.into(); + } + h.frames(2); + }; + let sent = |h: &Harness| apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap()[0].1["count"].clone(); + let previewed = |h: &Harness| crate::dialogs::preview_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap()[0].1["count"].clone(); + assert_eq!(count(&h), "6"); + h.step(&json!({"click":{"handle":"dialog:Quantity up"}})).unwrap(); + assert_eq!(count(&h), "7"); + assert_eq!((sent(&h), previewed(&h)), (json!(7), json!(7)), "the preview follows the arrow as it follows typing"); + h.step(&json!({"click":{"handle":"dialog:Quantity down"}})).unwrap(); + h.step(&json!({"click":{"handle":"dialog:Quantity down"}})).unwrap(); + assert_eq!(count(&h), "5"); + // A circular pattern needs the original and one copy, and makes at most 500. + set(&mut h, "2"); + h.step(&json!({"click":{"handle":"dialog:Quantity down"}})).unwrap(); + assert_eq!(count(&h), "2"); + set(&mut h, "500"); + h.step(&json!({"click":{"handle":"dialog:Quantity up"}})).unwrap(); + assert_eq!(count(&h), "500"); + // A count typed past the limit steps back inside it. + set(&mut h, "900"); + h.step(&json!({"click":{"handle":"dialog:Quantity down"}})).unwrap(); + assert_eq!(count(&h), "500"); + // An expression keeps its parameter: the arrows adjust a trailing offset. + h.app.run("parameters.add", json!({"name":"holes","expression":"4","unit":""})).unwrap(); + set(&mut h, "holes"); + h.step(&json!({"click":{"handle":"dialog:Quantity up"}})).unwrap(); + assert_eq!((count(&h), sent(&h)), ("holes + 1".into(), json!("holes + 1")), "the command keeps the parameter"); + for _ in 0..2 { + h.step(&json!({"click":{"handle":"dialog:Quantity down"}})).unwrap(); + } + assert_eq!((count(&h), sent(&h)), ("holes - 1".into(), json!("holes - 1"))); + // Text that is not a count leaves the arrows without effect. + set(&mut h, "nothing"); + h.step(&json!({"click":{"handle":"dialog:Quantity up"}})).unwrap(); + assert_eq!(count(&h), "nothing"); +} + +#[test] +fn rectangular_pattern_quantity_arrows_keep_both_directions_within_the_copy_limit() { + let mut h = fixture(); + h.app.dialog = Some(picked(&h, "sketch.pattern.rectangular")); + h.settle(); + let counts = |h: &Harness| match &h.app.dialog.as_ref().unwrap().kind { + Kind::SketchModify(Mod::PatternRect { count, count2, .. }) => (count.clone(), count2.clone()), + other => panic!("not a sketch pattern window: {other:?}"), + }; + let (one, _) = counts(&h); + h.step(&json!({"click":{"handle":"dialog:Quantity 2 up"}})).unwrap(); + assert_eq!(counts(&h), (one.clone(), "2".into())); + if let Some(Kind::SketchModify(Mod::PatternRect { count, count2, .. })) = h.app.dialog.as_mut().map(|d| &mut d.kind) { + (*count, *count2) = ("250".into(), "2".into()); + } + h.frames(2); + // 250 × 2 is the 500 copies a sketch pattern makes at most. + h.step(&json!({"click":{"handle":"dialog:Quantity up"}})).unwrap(); + h.step(&json!({"click":{"handle":"dialog:Quantity 2 up"}})).unwrap(); + assert_eq!(counts(&h), ("250".into(), "2".into())); + h.step(&json!({"click":{"handle":"dialog:Quantity down"}})).unwrap(); + assert_eq!(counts(&h), ("249".into(), "2".into())); +} diff --git a/crates/ui-egui/src/dim_view.rs b/crates/ui-egui/src/dim_view.rs index 7b4dd61b5c..994bb9a482 100644 --- a/crates/ui-egui/src/dim_view.rs +++ b/crates/ui-egui/src/dim_view.rs @@ -155,11 +155,14 @@ pub fn begin_dimension(app: &mut SolveApp, entities: Vec, at: Vec2) -> R /// Type-first creation: the picks determine a dimension, so its value takes the keyboard at /// once (D, click a line, type). The text follows the pointer; a click pins it, Enter places -/// it. False when the dimension would only be a driven one: that keeps the place-first flow, -/// where placing it adds it. +/// it. Midpoint pairs and dimensions that would only be driven keep the place-first flow: +/// the pointer chooses the orientation, and a click places the text before entering a value. pub fn begin_live(app: &mut SolveApp, entities: Vec, pointer: Option) -> Result { + // A midpoint pair chooses its orientation and text position before entering a value. + // This also permits switching freely between driving and reference spans. + let midpoint_pair = entities.len() == 2 && entities.iter().all(|id| id.starts_with("mid:")); let (param, expr, draft) = dimension_draft(app, entities, None)?; - if draft.driven { + if draft.driven || midpoint_pair { return Ok(false); } let live = Some(Live { origin: pointer, moved: false, typed: false }); @@ -572,7 +575,7 @@ pub fn show(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter, proj: &P } let Some(tc) = to(lay.text) else { continue }; let r = Rect::from_center_size(tc, size); - painter.rect_filled(r, 2.0, tk.viewport_top.gamma_multiply(0.85)); + painter.rect_filled(r, tk.radius_sm / 2, tk.viewport_top.gamma_multiply(0.85)); painter.galley(r.min + vec2(3.0, 1.0), galley, col); if matches!(frame, DimFrame::ArcLength { .. }) { // The arc-length mark over the value. @@ -814,6 +817,7 @@ fn edit_box(app: &mut SolveApp, ctx: &egui::Context) { fn clear_picks(app: &mut SolveApp) { if let Some(tool) = app.tool.as_mut().filter(|t| t.kind == crate::tools::Kind::Dimension) { tool.picks.clear(); + tool.point_picks.clear(); } } @@ -835,6 +839,20 @@ pub fn draft_preview() -> Option<(String, solidcraft_engine::sketch::Constraint, }) } +/// A place-first draft, including a driven dimension, for creation tests. +#[cfg(test)] +pub fn placement_dimension_preview(app: &SolveApp) -> Option { + let (id, draft) = placement_preview(app)?; + draft + .preview + .sketch(draft.sketch)? + .sketch + .constraints + .iter() + .find(|c| c.param.as_deref() == Some(id.as_str()) || (c.driven && c.id == id)) + .cloned() +} + /// Dimensions drawn last frame: (id, text centre on screen), for tests and the control channel. pub fn drawn() -> Vec<(String, Pos2)> { DRAWN.with(|d| d.borrow().iter().map(|x| (x.id.clone(), x.text.center())).collect()) @@ -863,7 +881,7 @@ mod tests { h.app.run("sketch.line", json!({"points":[[10,10],[20,10]],"infer":false,"connect":false})).unwrap(); h.app.run("sketch.line", json!({"points":[[10,10],[10,20]],"infer":false,"connect":false})).unwrap(); h.call("ui.view", json!({"view":"top"})); - for (picks, at, angular) in [(vec![[35, 30]], [40, 40], false), (vec![[15, 10], [10, 15]], [18, 18], true)] { + for (picks, at, angular) in [(vec![[35, 30]], [40, 40], false), (vec![[13, 10], [10, 13]], [18, 18], true)] { h.step(&json!({"start":"sketch.dimension"})).unwrap(); for pick in picks { h.step(&json!({"click":{"sketch":pick}})).unwrap(); diff --git a/crates/ui-egui/src/dimension_create_ui_tests.rs b/crates/ui-egui/src/dimension_create_ui_tests.rs index 6d7717abde..ccd4c8c550 100644 --- a/crates/ui-egui/src/dimension_create_ui_tests.rs +++ b/crates/ui-egui/src/dimension_create_ui_tests.rs @@ -815,3 +815,280 @@ fn type_first_switching_between_aligned_horizontal_and_vertical_keeps_the_picked } } } + +#[test] +fn midpoint_then_edge_creates_a_distance_in_one_undo_step() { + use solidcraft_engine::sketch::ConstraintKind; + let mut h = line([20., 0.]); + h.app.run("sketch.line", json!({"points":[[-10,-10],[-10,20]]})).unwrap(); + h.app.run("sketch.constraint.fix", json!({"entity":"l2"})).unwrap(); + h.settle(); + let baseline = h.app.session.doc.to_json(); + let undo = h.app.session.undo.len(); + for accept in [false, true] { + place(&mut h, &[[10., 0.], [-10., -5.]], [0., -8.]); + let (_, c, sk) = crate::dim_view::draft_preview().unwrap(); + let ConstraintKind::PointLineDistance { p, l, .. } = c.kind else { panic!("MID to edge must be a distance: {:?}", c.kind) }; + assert_eq!(sk.curves[l].id, "l2"); + assert!(sk.constraints.iter().any(|c| matches!(c.kind, ConstraintKind::Midpoint { p: q, l: 0 } if q == p))); + assert_eq!(h.app.session.doc.to_json(), baseline, "preview creates no persistent helper"); + if !accept { + h.step(&json!({"key":"Escape"})).unwrap(); + assert!(h.app.tool.as_ref().unwrap().point_picks.is_empty()); + continue; + } + type_value(&mut h, "25 mm"); + h.step(&json!({"key":"Enter"})).unwrap(); + assert!(!crate::dim_view::editing()); + assert_eq!(h.app.session.undo.len(), undo + 1); + let state = h.app.session.world_state(); + let solved = state.sketch(h.app.session.active_sketch.unwrap()).unwrap(); + assert!(solved.report.ok()); + let sk = &solved.sketch; + let c = sk.constraints.iter().find(|c| c.param.is_some()).unwrap(); + assert!(matches!(c.kind, ConstraintKind::PointLineDistance { .. })); + assert!((solidcraft_engine::sketch::measure_dimension(sk, &c.kind).unwrap() - 25.).abs() < 1e-6); + let accepted = h.app.session.doc.to_json(); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.doc.to_json(), baseline); + h.app.run("edit.redo", json!({})).unwrap(); + assert_eq!(h.app.session.doc.to_json(), accepted); + } + crate::dim_view::cancel(); +} + +/// The short sketch edge overlaps a face's automatically projected boundary. +fn midpoint_face_sketch() -> Harness { + use solidcraft_engine::geom::Vec3; + crate::dim_view::cancel(); + let mut h = Harness::new(); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0":[-10,-5],"p1":[10,15]})).unwrap(); + h.app.finish_sketch(); + h.app.run("solid.extrude", json!({"distance":10})).unwrap(); + h.app.run("sketch.create", json!({"plane":{"face":[0,5,10]}})).unwrap(); + let state = h.app.session.world_state(); + let ss = state.sketch(h.app.session.active_sketch.unwrap()).unwrap(); + let plane = ss.plane; + assert!(ss.sketch.curves.iter().any(|c| c.link.is_some())); + for ends in [[[-8., -5.], [2., -5.]], [[3., 2.], [7., 6.]]] { + let points: Vec<_> = ends + .into_iter() + .map(|p| { + let q = plane.to_local(Vec3::new(p[0], p[1], 10.)); + [q.x, q.y] + }) + .collect(); + h.app.run("sketch.line", json!({"points":points,"infer":false})).unwrap(); + } + h.call("ui.view", json!({"view":"top"})); + h.app.cam.distance = 90.; + h.app.cam.target = Vec3::new(0., 5., 10.); + h.settle(); + h +} + +#[test] +fn overlapping_sketch_midpoint_to_model_edge_dimensions_the_snapped_point() { + use solidcraft_engine::sketch::{ConstraintKind, Shape}; + let mut h = midpoint_face_sketch(); + let baseline = h.app.session.doc.to_json(); + let undo = h.app.session.undo.len(); + h.step(&json!({"start":"sketch.dimension"})).unwrap(); + for p in [[-3., -5.], [10., 2.], [4., -10.]] { + h.step(&json!({"click":{"world":[p[0],p[1],10.]}})).unwrap(); + } + let (_, c, sk) = crate::dim_view::draft_preview().expect("MID to projected model edge has a draft"); + let ConstraintKind::PointLineDistance { p, l, .. } = c.kind else { panic!("expected point/edge distance: {:?}", c.kind) }; + assert!(sk.curves[l].link.is_some()); + let source = sk + .constraints + .iter() + .find_map(|c| match c.kind { + ConstraintKind::Midpoint { p: q, l } if p == q => Some(l), + _ => None, + }) + .unwrap(); + assert!(sk.curves[source].link.is_none(), "the short sketch edge won the overlapping pick"); + let Shape::Line { a, b } = sk.shape(source).unwrap() else { panic!() }; + assert!((a.dist(b) - 10.).abs() < 1e-6); + assert!((solidcraft_engine::sketch::measure_dimension(&sk, &c.kind).unwrap() - 13.).abs() < 1e-6); + assert_eq!(h.app.session.doc.to_json(), baseline); + type_value(&mut h, "12 mm"); + h.step(&json!({"key":"Enter"})).unwrap(); + assert!(!crate::dim_view::editing()); + assert_eq!(h.app.session.undo.len(), undo + 1); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.doc.to_json(), baseline); + crate::dim_view::cancel(); +} + +#[test] +fn midpoint_pairs_dimension_sketch_and_projected_model_edges() { + use solidcraft_engine::sketch::ConstraintKind; + for (first, second, projected) in [ + ([-3_f64, -5.], [5., 4.], [false, false]), + ([-3., -5.], [10., 5.], [false, true]), + ([10., 5.], [-3., -5.], [true, false]), + ([0., -5.], [10., 5.], [true, true]), + ] { + let mut h = midpoint_face_sketch(); + let baseline = h.app.session.doc.to_json(); + let undo = h.app.session.undo.len(); + h.step(&json!({"start":"sketch.dimension"})).unwrap(); + for p in [first, second] { + h.step(&json!({"click":{"world":[p[0],p[1],10.]}})).unwrap(); + } + let at = [(first[0] + second[0]) * 0.5, (first[1] + second[1]) * 0.5]; + assert_eq!(h.app.session.doc.to_json(), baseline); + h.step(&json!({"click":{"world":[at[0],at[1],10.]}})).unwrap(); + if crate::dim_view::editing() { + h.step(&json!({"key":"Enter"})).unwrap(); + } + let state = h.app.session.world_state(); + let sk = &state.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch; + let c = sk.constraints.iter().rev().find(|c| c.kind.is_dimension()).expect("two MID picks commit a distance"); + let ConstraintKind::Distance { p, q, .. } = c.kind else { panic!("expected midpoint distance for {projected:?}: {:?}", c.kind) }; + for (point, expected) in [(p, projected[0]), (q, projected[1])] { + let l = sk + .constraints + .iter() + .find_map(|c| match c.kind { + ConstraintKind::Midpoint { p, l } if p == point => Some(l), + _ => None, + }) + .expect("each pick remains a midpoint"); + assert_eq!(sk.curves[l].link.is_some(), expected); + } + let measured = ((first[0] - second[0]).powi(2) + (first[1] - second[1]).powi(2)).sqrt(); + assert!((solidcraft_engine::sketch::measure_dimension(sk, &c.kind).unwrap() - measured).abs() < 1e-6); + assert_eq!(h.app.session.undo.len(), undo + 1); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.doc.to_json(), baseline); + crate::dim_view::cancel(); + } +} + +#[test] +fn midpoint_selection_keeps_line_circle_gap_dimensions_in_both_orders() { + use solidcraft_engine::sketch::ConstraintKind; + for reverse in [false, true] { + let mut h = line([20., 0.]); + h.app.run("sketch.circle.center", json!({"center":[10,10],"radius":3})).unwrap(); + h.settle(); + let baseline = h.app.session.doc.to_json(); + let picks = if reverse { [[13., 10.], [10., 0.]] } else { [[10., 0.], [13., 10.]] }; + place(&mut h, &picks, [25., 5.]); + let (_, c, sk) = crate::dim_view::draft_preview().expect("the edge-to-edge gap remains available"); + assert!(matches!(c.kind, ConstraintKind::LineCircleDistance { value, .. } if (value - 7.).abs() < 1e-6)); + assert!(!sk.constraints.iter().any(|c| matches!(c.kind, ConstraintKind::Midpoint { .. }))); + h.step(&json!({"key":"Escape"})).unwrap(); + assert_eq!(h.app.session.doc.to_json(), baseline); + crate::dim_view::cancel(); + } +} + +#[test] +fn midpoint_dimension_orientation_follows_the_pointer_before_placement() { + use solidcraft_engine::sketch::ConstraintKind; + for (first, second) in [([-3_f64, -5.], [5., 4.]), ([-3., -5.], [10., 5.]), ([10., 5.], [-3., -5.]), ([0., -5.], [10., 5.])] { + for (horizontal, held_y, reused) in + [(true, false, false), (false, false, false), (true, true, false), (false, true, false), (true, false, true), (false, false, true)] + { + if held_y && (first != [-3., -5.] || second != [10., 5.]) { + continue; + } + let mut h = midpoint_face_sketch(); + if held_y { + let state = h.app.session.world_state(); + let ss = state.sketch(h.app.session.active_sketch.unwrap()).unwrap(); + let short = ss.sketch.curves.iter().find(|c| c.link.is_none()).unwrap().id.clone(); + let bottom = ss + .sketch + .curves + .iter() + .enumerate() + .find_map(|(i, c)| { + c.link.as_ref()?; + let solidcraft_engine::sketch::Shape::Line { a, b } = ss.sketch.shape(i)? else { + return None; + }; + ((ss.plane.to_world(a).y + 5.).abs() < 1e-6 && (ss.plane.to_world(b).y + 5.).abs() < 1e-6).then(|| c.id.clone()) + }) + .unwrap(); + for end in ["start", "end"] { + h.app.run("sketch.constraint.coincident", json!({"a":format!("{short}.{end}"),"b":bottom})).unwrap(); + } + h.settle(); + } + if reused { + let state = h.app.session.world_state(); + let ss = state.sketch(h.app.session.active_sketch.unwrap()).unwrap(); + let mid_ref = |point: [f64; 2]| { + ss.sketch + .curves + .iter() + .enumerate() + .find_map(|(i, c)| { + let solidcraft_engine::sketch::Shape::Line { a, b } = ss.sketch.shape(i)? else { + return None; + }; + let at = ss.plane.to_world((a + b) * 0.5); + ((at.x - point[0]).abs() < 1e-6 && (at.y - point[1]).abs() < 1e-6).then(|| format!("mid:{}", c.id)) + }) + .unwrap() + }; + h.app.run("sketch.dimension", json!({"entities":[mid_ref(first),mid_ref(second)],"driven":true})).unwrap(); + h.settle(); + } + let baseline = h.app.session.doc.to_json(); + let undo = h.app.session.undo.len(); + h.step(&json!({"start":"sketch.dimension"})).unwrap(); + for p in [first, second] { + h.step(&json!({"click":{"world":[p[0],p[1],10.]}})).unwrap(); + } + assert!(!crate::dim_view::editing(), "choose the midpoint span's orientation before entering a value"); + let above = [(first[0] + second[0]) * 0.5, first[1].max(second[1]) + 3.]; + let beside = [first[0].max(second[0]) + 3., (first[1] + second[1]) * 0.5]; + // Switch back and forth without accepting or changing the picked midpoints. + for is_x in [true, false, true, horizontal] { + let at = if is_x { above } else { beside }; + h.step(&json!({"move":{"world":[at[0],at[1],10.]}})).unwrap(); + h.settle(); + let c = crate::dim_view::draft_preview() + .map(|(_, c, _)| c) + .or_else(|| crate::dim_view::placement_dimension_preview(&h.app)) + .expect("a midpoint dimension previews before placement"); + assert!( + if is_x { matches!(c.kind, ConstraintKind::DistanceX { .. }) } else { matches!(c.kind, ConstraintKind::DistanceY { .. }) }, + "{first:?} to {second:?}, horizontal={is_x}: {:?}", + c.kind + ); + let expected = if is_x { (first[0] - second[0]).abs() } else { (first[1] - second[1]).abs() }; + assert!((c.kind.value().unwrap() - expected).abs() < 1e-6); + assert_eq!(h.app.session.doc.to_json(), baseline); + } + let at = if horizontal { above } else { beside }; + h.step(&json!({"click":{"world":[at[0],at[1],10.]}})).unwrap(); + if crate::dim_view::editing() { + h.step(&json!({"key":"Enter"})).unwrap(); + } + let state = h.app.session.world_state(); + let sk = &state.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch; + let c = sk.constraints.iter().rev().find(|c| c.kind.is_dimension()).unwrap(); + assert!(if horizontal { matches!(c.kind, ConstraintKind::DistanceX { .. }) } else { matches!(c.kind, ConstraintKind::DistanceY { .. }) }); + if held_y { + assert_eq!(c.driven, !horizontal, "the vertical span is fixed by the model edge"); + } + assert_eq!( + sk.constraints.iter().filter(|c| matches!(c.kind, ConstraintKind::Midpoint { .. })).count(), + 2, + "reuse the picked midpoint points" + ); + assert_eq!(h.app.session.undo.len(), undo + 1); + h.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(h.app.session.doc.to_json(), baseline); + crate::dim_view::cancel(); + } + } +} diff --git a/crates/ui-egui/src/documents.rs b/crates/ui-egui/src/documents.rs index 8d7ef4fbe1..1a41f59707 100644 --- a/crates/ui-egui/src/documents.rs +++ b/crates/ui-egui/src/documents.rs @@ -144,6 +144,32 @@ fn swap_in(app: &mut SolveApp, slot: DocSlot) -> DocSlot { out } +/// Move tab `from` to place `to`, the tabs between shifting over (#3187); each design keeps its +/// state and the active one stays active. Not while a "save changes?" prompt is open or tabs are +/// being closed one after another (their tab numbers would change under them). +pub fn move_tab(app: &mut SolveApp, from: usize, to: usize) { + let n = app.docs.slots.len(); + if from >= n || to >= n || from == to || app.docs.closing.is_some() || app.docs.quitting.is_some() || app.docs.batch.is_some() { + return; + } + let slot = app.docs.slots.remove(from); + app.docs.slots.insert(to, slot); + app.docs.active = moved_index(app.docs.active, from, to); +} + +/// Where the tab at `i` ends up when the tab at `from` moves to `to`. +pub fn moved_index(i: usize, from: usize, to: usize) -> usize { + if i == from { + to + } else if from < i && i <= to { + i - 1 + } else if to <= i && i < from { + i + 1 + } else { + i + } +} + /// Make tab `i` the active design. pub fn switch(app: &mut SolveApp, i: usize) { if app.file_picker_busy() { @@ -399,8 +425,8 @@ pub fn tabs_info(app: &SolveApp) -> Vec<(String, bool)> { } /// The control channel's `ui.documents`: list, `new`, `switch`, `close` (by index; `force` -/// closes without asking), `close_others` (all but `index`), `close_all` or `quit`; `answer`: -/// save | dont_save | cancel answers "save changes?". +/// closes without asking), `close_others` (all but `index`), `close_all`, `move` (`from`, `to`) +/// or `quit`; `answer`: save | dont_save | cancel answers "save changes?". pub fn control(app: &mut SolveApp, p: &Value) -> Value { let i = p.get("index").and_then(Value::as_u64).map(|x| x as usize); match p.get("action").and_then(Value::as_str) { @@ -412,6 +438,12 @@ pub fn control(app: &mut SolveApp, p: &Value) -> Value { close_many(app, (0..app.docs.count()).filter(|k| *k != keep).collect()); } Some("close_all") => close_many(app, (0..app.docs.count()).collect()), + Some("move") => { + let at = |k: &str| p.get(k).and_then(Value::as_u64).map(|x| x as usize); + if let (Some(from), Some(to)) = (at("from"), at("to")) { + move_tab(app, from, to); + } + } Some("quit") => request_quit(app), _ => {} } @@ -428,14 +460,21 @@ pub fn control(app: &mut SolveApp, p: &Value) -> Value { json!({"documents": tabs, "active": app.docs.active, "closing": app.docs.closing, "quitting": app.docs.quitting.is_some(), "batch": app.docs.batch}) } +/// A design tab being dragged along the strip to a new place: its index, and where the pointer +/// grabbed it (from the tab's left edge). +#[derive(Clone, Copy, Debug)] +struct TabDrag { + from: usize, + grab: f32, +} + /// The document tabs in the application bar, from `x0` to `x1`: click to switch, × to close, -/// + for a new design. +/// drag to reorder, + for a new design. pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) { let t = Tokens::get(); let info = tabs_info(app); let n = info.len(); let (first, shown, w) = layout(n, app.docs.active, x1 - x0); - let mut x = x0; let mut pick: Option = None; let mut shut: Option = None; // Each tab fits its name (up to MAX_FIT) while all of them fit; otherwise they share the room. @@ -453,23 +492,74 @@ pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) { // A right-click on a tab opens its menu (#3055): the tab and where the menu opens. let menu_id = egui::Id::new("sc_doc_tab_menu"); let mut menu: Option<(usize, egui::Pos2)> = ui.ctx().data(|d| d.get_temp(menu_id)); - for (i, (name, dirty)) in info.iter().enumerate().skip(first).take(shown) { - let w = if fit_all { natural.get(i).copied().unwrap_or(w) } else { w }; - let tr = Rect::from_min_size(pos2(x, r.top() + 4.0), vec2(w - 2.0, r.height() - 8.0)); + // The shown tabs' widths, in strip order. + let widths: Vec = (first..first + shown).map(|i| if fit_all { natural.get(i).copied().unwrap_or(w) } else { w }).collect(); + let width = |i: usize| i.checked_sub(first).and_then(|k| widths.get(k)).copied().unwrap_or(w); + let strip_end = x0 + widths.iter().sum::(); + let title_bar = app.custom_titlebar || app.integrated_titlebar; + // A tab being dragged to a new place follows the pointer and the others make room for it; + // Escape puts it back. + let drag_id = egui::Id::new("sc_doc_tab_drag"); + let (held_down, released, escape) = ui.input(|i| (i.pointer.primary_down(), i.pointer.primary_released(), i.key_pressed(egui::Key::Escape))); + let mut drag = + ui.ctx().data(|d| d.get_temp::(drag_id)).filter(|d| (held_down || released) && !escape && (first..first + shown).contains(&d.from)); + let pointer = ui.ctx().pointer_latest_pos(); + let held = drag.zip(pointer).map(|(d, p)| (d.from, (p.x - d.grab).clamp(x0, (strip_end - width(d.from)).max(x0)))); + let to = held.map(|(from, left)| (from, drop_slot(left, x0, &widths, first, from))); + // Where each shown tab sits: in strip order, or with the dragged tab moved to its slot. + let mut slots: Vec = (first..first + shown).filter(|&i| to.is_none_or(|(from, _)| from != i)).collect(); + if let Some((from, to)) = to { + slots.insert(to.saturating_sub(first).min(slots.len()), from); + } + let mut lefts = vec![x0; shown]; + let mut at = x0; + for &i in &slots { + if let Some(l) = i.checked_sub(first).and_then(|k| lefts.get_mut(k)) { + *l = at; + } + at += width(i); + } + // The dragged tab is drawn last, over the others. + let mut order: Vec = (first..first + shown).filter(|&i| held.is_none_or(|(from, _)| from != i)).collect(); + order.extend(held.map(|(from, _)| from)); + let mut dropped: Option<(usize, usize)> = None; + for i in order { + let Some((name, dirty)) = info.get(i) else { continue }; + let w = width(i); + let left = match held { + Some((from, left)) if from == i => left, + _ => i.checked_sub(first).and_then(|k| lefts.get(k)).copied().unwrap_or(x0), + }; + let tr = Rect::from_min_size(pos2(left, r.top() + 4.0), vec2(w - 2.0, r.height() - 8.0)); let active = !app.home.open && (i == app.docs.active || app.docs.slots.is_empty()); let resp = ui.interact(tr, ui.id().with(("doc_tab", i)), Sense::click_and_drag()); crate::scenario::publish_target(&format!("tab:design:{i}"), tr); - // The tabs are part of the title bar: dragging one moves the window, a double click + // Dragging a tab along the strip moves it. The tabs are also part of the title bar: + // dragging the only tab, or one mostly up or down, moves the window, and a double click // maximizes it. - if app.custom_titlebar || app.integrated_titlebar { - if resp.drag_started_by(egui::PointerButton::Primary) { + if resp.drag_started_by(egui::PointerButton::Primary) { + let origin = ui.input(|i| i.pointer.press_origin()).unwrap_or(tr.center()); + let moved = pointer.unwrap_or(origin) - origin; + if n > 1 && (!title_bar || moved.x.abs() >= moved.y.abs()) { + drag = Some(TabDrag { from: i, grab: origin.x - tr.left() }); + } else if title_bar { ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); - } else if resp.double_clicked() { - crate::titlebar::toggle_maximize(ui.ctx()); } + } else if title_bar && resp.double_clicked() { + crate::titlebar::toggle_maximize(ui.ctx()); } - tab_shape(ui.painter(), &t, tr, active, resp.hovered() || resp.has_focus()); - let ink = if active || resp.hovered() { t.app_bar_text } else { t.app_bar_text.gamma_multiply(0.7) }; + if resp.drag_stopped() && drag.is_some_and(|d| d.from == i) { + // Released over the bar (not dropped somewhere down the window): the tab lands there. + if let Some((from, to)) = to + && pointer.is_some_and(|p| p.y < r.bottom() + r.height()) + { + dropped = Some((from, to)); + } + drag = None; + } + let lit = resp.hovered() || held.is_some_and(|(from, _)| from == i); + tab_shape(ui.painter(), &t, tr, active, lit || resp.has_focus()); + let ink = if active || lit { t.app_bar_text } else { t.app_bar_text.gamma_multiply(0.7) }; let name_display = crate::i18n::Locale::for_language(&app.preferences.language).given_name(name); let label = format!("{name_display}{}", if *dirty { " •" } else { "" }); let font = if active { @@ -487,7 +577,7 @@ pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) { let xresp = ui.interact(xr, ui.id().with(("doc_close", i)), if show_close { Sense::click() } else { Sense::hover() }); crate::scenario::publish_target(&format!("tab:close:{i}"), xr); if show_close && (xresp.hovered() || xresp.has_focus()) { - ui.painter().rect_filled(xr, 3.0, t.app_bar_hover_strong); + ui.painter().rect_filled(xr, t.radius_sm.saturating_sub(1), t.app_bar_hover_strong); } if show_close { crate::icons::paint_app_bar( @@ -502,18 +592,24 @@ pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) { } if xresp.on_hover_text("Close").clicked() { shut = Some(i); - } else if resp.on_hover_text(name.as_str()).clicked() { + } else if (if held.is_some() { resp } else { resp.on_hover_text(name.as_str()) }).clicked() { pick = Some(i); } - x += w; } + ui.ctx().data_mut(|d| match drag { + Some(v) => { + d.insert_temp(drag_id, v); + } + None => d.remove::(drag_id), + }); + let mut x = strip_end; // More tabs than fit: a chevron lists them all. if shown < n { let mr = Rect::from_center_size(pos2(x + 12.0, r.center().y), vec2(20.0, 22.0)); let mresp = ui.interact(mr, ui.id().with("doc_more"), Sense::click()); crate::scenario::publish_handle("tab:overflow", mr.center()); if mresp.hovered() { - ui.painter().rect_filled(mr, 4.0, t.app_bar_hover_button); + ui.painter().rect_filled(mr, t.radius_sm, t.app_bar_hover_button); } ui.painter().text( mr.center(), @@ -591,7 +687,7 @@ pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) { let pr = Rect::from_center_size(pos2(x + 14.0, r.center().y), vec2(22.0, 22.0)); let presp = ui.interact(pr, ui.id().with("doc_new"), Sense::click()); if presp.hovered() { - ui.painter().rect_filled(pr, 4.0, t.app_bar_hover_button); + ui.painter().rect_filled(pr, t.radius_sm, t.app_bar_hover_button); } crate::icons::paint_app_bar( ui.painter(), @@ -610,10 +706,29 @@ pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) { _ if many.is_some() => close_many(app, many.unwrap_or_default()), (Some(i), _) => close(app, i, false), (None, Some(i)) => switch(app, i), - (None, None) => {} + (None, None) => { + if let Some((from, to)) = dropped { + move_tab(app, from, to); + } + } } } +/// The place among the shown tabs (`first..`, `widths` wide in strip order from `x0`) for +/// tab `from` whose left edge is dragged to `left`: the slot whose place for it is nearest. +pub fn drop_slot(left: f32, x0: f32, widths: &[f32], first: usize, from: usize) -> usize { + let mut best = (0, f32::INFINITY); + let mut at = x0; + let others = widths.iter().enumerate().filter(|(k, _)| first + k != from).map(|(_, w)| *w); + for (k, w) in others.map(Some).chain([None]).enumerate() { + if (left - at).abs() < best.1 { + best = (k, (left - at).abs()); + } + at += w.unwrap_or(0.0); + } + first + best.0 +} + /// Tabs are rounded chips on the bar; the active design has the stronger fill. fn tab_shape(painter: &egui::Painter, t: &Tokens, r: Rect, active: bool, hovered: bool) { let fill = if active { @@ -686,6 +801,7 @@ pub fn prompt(app: &mut SolveApp, ctx: &egui::Context) { let (closed, action) = ui_kit::Confirm::new(egui::Id::new("sc_close_prompt"), "status_warning", &title) .tone(ui_kit::Tone::Warning) .actions(crate::i18n::tr("ui.save"), crate::i18n::tr("ui.cancel"), Some(crate::i18n::tr("ui.don_t_save"))) + .secondary_left() .selected(match choice { Answer::Save => ui_kit::Action::Ok, Answer::DontSave => ui_kit::Action::Apply, @@ -884,6 +1000,95 @@ mod tests { assert_eq!(h.app.docs.count(), 1); } + /// #3187: a tab moved to another place keeps its design, and the active design stays + /// active wherever it ends up. + #[test] + fn a_moved_tab_keeps_its_design_and_the_active_one_stays_active() { + let mut a = app(); + a.run("solid.box", json!({"length": 10, "width": 10, "height": 10})).unwrap(); + for k in 1..3 { + open_tab(&mut a, Session::default()).unwrap(); + for _ in 0..=k { + a.run("solid.sphere", json!({"diameter": 4})).unwrap(); + } + } + // Tabs hold 1, 2 and 3 features; the third is active. + let features = |a: &mut SolveApp| { + let back = a.docs.active; + let v: Vec = (0..a.docs.count()) + .map(|i| { + switch(a, i); + a.session.doc.features.len() + }) + .collect(); + switch(a, back); + v + }; + assert_eq!(features(&mut a), [1, 2, 3]); + assert_eq!(a.docs.active, 2); + move_tab(&mut a, 0, 2); + assert_eq!(features(&mut a), [2, 3, 1]); + assert_eq!(a.docs.active, 1, "the active design moved over with the others"); + assert_eq!(a.session.doc.features.len(), 3); + move_tab(&mut a, 1, 0); + assert_eq!(features(&mut a), [3, 2, 1]); + assert_eq!(a.docs.active, 0); + move_tab(&mut a, 0, 9); + assert_eq!(features(&mut a), [3, 2, 1], "out of range: nothing moves"); + assert_eq!(moved_index(5, 5, 1), 1); + assert_eq!(drop_slot(0.0, 100.0, &[90.0; 4], 2, 2), 2); + assert_eq!(drop_slot(240.0, 100.0, &[90.0; 4], 2, 2), 4); + assert_eq!(drop_slot(9000.0, 100.0, &[90.0; 4], 2, 2), 5); + // Tabs fit their names: the slots follow the other tabs' own widths. + assert_eq!(drop_slot(90.0, 0.0, &[80.0, 200.0, 80.0], 0, 0), 0); + assert_eq!(drop_slot(150.0, 0.0, &[80.0, 200.0, 80.0], 0, 0), 1); + assert_eq!(drop_slot(260.0, 0.0, &[80.0, 200.0, 80.0], 0, 0), 2); + } + + /// #3187: dragging a tab along the strip drops it where it is released; a drag mostly down + /// the window (it moves the window) or one Escape cancelled leaves the order as it was. + #[test] + fn dragging_a_tab_along_the_strip_moves_it() { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.app.custom_titlebar = true; + h.call("ui.resize", json!({"width": 1400, "height": 720})); + h.app.run("solid.box", json!({"length": 10, "width": 10, "height": 10})).unwrap(); + open_tab(&mut h.app, Session::default()).unwrap(); + open_tab(&mut h.app, Session::default()).unwrap(); + switch(&mut h.app, 0); + h.frames(3); + let at = |h: &mut crate::scenario::Harness, i: usize| { + let [x, y] = h.at(&json!({"handle": format!("tab:design:{i}")})).unwrap(); + egui::pos2(x as f32, y as f32) + }; + let button = + |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + let path = |a: egui::Pos2, b: egui::Pos2| (1..=8).map(move |k| egui::Event::PointerMoved(a + (b - a) * (k as f32 / 8.0))); + let (t0, t2) = (at(&mut h, 0), at(&mut h, 2)); + // Down first: that moves the window, and coming back along the bar moves no tab. + let down = t0 + vec2(0.0, 20.0); + h.app.synthetic.extend([egui::Event::PointerMoved(t0), button(t0, true)]); + h.app.synthetic.extend(path(t0, down).chain(path(down, t2))); + h.app.synthetic.push(button(t2, false)); + h.frames(24); + assert_eq!(h.app.docs.active, 0, "a drag down the window leaves the tabs"); + assert_eq!(h.app.session.doc.features.len(), 1); + h.app.synthetic.extend([egui::Event::PointerMoved(t0), button(t0, true)]); + h.app.synthetic.extend(path(t0, t2)); + h.app.synthetic.push(button(t2, false)); + h.frames(16); + assert_eq!(h.app.docs.active, 2, "the dragged tab is now last"); + assert_eq!(h.app.session.doc.features.len(), 1, "and still shows its design"); + let (t2, t1) = (at(&mut h, 2), at(&mut h, 1)); + let escape = egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + h.app.synthetic.extend([egui::Event::PointerMoved(t2), button(t2, true)]); + h.app.synthetic.extend(path(t2, t1)); + h.app.synthetic.extend([escape, egui::Event::PointerMoved(t1), button(t1, false)]); + h.frames(16); + assert_eq!(h.app.docs.active, 2, "Escape put it back"); + } + #[test] fn tabs_keep_their_own_design() { let mut a = app(); diff --git a/crates/ui-egui/src/drag_snap.rs b/crates/ui-egui/src/drag_snap.rs index 1c2aa10e9c..d30c9c9d7e 100644 --- a/crates/ui-egui/src/drag_snap.rs +++ b/crates/ui-egui/src/drag_snap.rs @@ -231,7 +231,7 @@ pub fn show(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { let label = s.label(); if !label.is_empty() { let r = egui::Rect::from_min_size(sp + egui::vec2(12.0, -24.0), egui::vec2(24.0, 14.0)); - painter.rect_filled(r, 3.0, tk.accent_soft); + painter.rect_filled(r, tk.radius_sm.saturating_sub(1), tk.accent_soft); painter.text(r.center(), Align2::CENTER_CENTER, label, FontId::proportional(crate::theme::Tokens::get().font_micro), tk.text); } crate::scenario::publish_count("drag_guides", s.guides.len() as f64); diff --git a/crates/ui-egui/src/edit_clipboard_tests.rs b/crates/ui-egui/src/edit_clipboard_tests.rs index 3fccda2247..3a0fcc015b 100644 --- a/crates/ui-egui/src/edit_clipboard_tests.rs +++ b/crates/ui-egui/src/edit_clipboard_tests.rs @@ -277,7 +277,7 @@ fn contribution_linked_paste_uses_the_pointer_and_pending_numeric_move() { fn contribution_paste_pointer_uses_parent_coordinates_and_safe_fallbacks() { use solidcraft_engine::{doc, geom::Vec3}; let mut h = crate::scenario::Harness::new(); - let parent = h.app.run("component.create", json!({"name":"Parent"})).unwrap(); + let parent = h.app.run("component.create", json!({"name":"Parent","grounded":false})).unwrap(); h.app .run("occurrence.move", json!({"occurrence":parent["occurrence"],"translate":[20,30,10],"axis":[1,0,0],"angle":"45 deg","capture":true})) .unwrap(); diff --git a/crates/ui-egui/src/extrude_auto_to_object_tests.rs b/crates/ui-egui/src/extrude_auto_to_object_tests.rs new file mode 100644 index 0000000000..d1f894b64c --- /dev/null +++ b/crates/ui-egui/src/extrude_auto_to_object_tests.rs @@ -0,0 +1,196 @@ +//! Extrude's automatic To Object: with profiles picked, a pick off their plane is the object to +//! extrude to, and the extent turns to To Object without the Extent Type choice. +use serde_json::json; +use solidcraft_engine::Sel; +use solidcraft_engine::geom::Vec3; + +use crate::dialogs::{Dialog, Kind, apply_commands, face_sel}; +use crate::scenario::Harness; +use crate::viewport::Hit; + +/// A 10 x 10 profile on XY, a 20 mm box beside it (x 30..50), a cylinder further on (x 65..75) +/// and construction planes 25 mm above and 12.345678 mm below XY; the Extrude dialog open with +/// the profile picked and the automatic To Object preference on. +fn scene() -> Harness { + scene_with(true) +} + +fn scene_with(auto_to_object: bool) -> Harness { + let mut h = Harness::new(); + h.app.preferences.extrude_auto_to_object = auto_to_object; + h.app.run("solid.box", json!({"length": 20, "width": 20, "height": 20, "corner": [30, 0, 0]})).unwrap(); + h.app.run("solid.cylinder", json!({"radius": 5, "height": 30, "base": [70, 10, 0], "operation": "new_body"})).unwrap(); + h.app.run("construct.plane.offset", json!({"base": "XY", "offset": 25, "name": "Lid"})).unwrap(); + h.app.run("construct.plane.offset", json!({"base": "XY", "offset": -12.345678, "name": "Under"})).unwrap(); + h.app.run("sketch.create", json!({"plane": "XY", "name": "Base"})).unwrap(); + h.app.run("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [10, 10]})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + h.app.start("solid.extrude"); + h.frames(2); + let sketch = h.app.session.doc.find_feature("Base").unwrap().id; + let d = h.app.dialog.as_mut().unwrap(); + d.inputs[0].items = vec![Sel::Profile { sketch, index: 0 }]; + d.active = 0; + h.frames(2); + h +} + +fn dialog(h: &Harness) -> &Dialog { + h.app.dialog.as_ref().unwrap() +} + +/// A click on `hit` as the viewport makes it: the dialog's candidate, then its pick. +fn click(h: &mut Harness, hit: impl FnOnce(&Harness) -> Hit) -> bool { + let hit = hit(h); + let mut d = h.app.dialog.take().unwrap(); + let sel = d.candidate(&h.app.session, &hit); + if let Some(sel) = sel.clone() { + d.pick(&h.app.session, sel); + } + h.app.dialog = Some(d); + h.frames(2); + sel.is_some() +} + +fn face_hit(h: &Harness, p: Vec3) -> Hit { + let Some(Sel::Face { body, index, point }) = face_sel(&h.app.session, p) else { panic!("no face at {p:?}") }; + Hit::Face { body, index, point } +} + +fn to_object(d: &Dialog) -> Option<&Sel> { + d.inputs.iter().find(|i| i.label == "To Object").and_then(|i| i.items.first()) +} + +fn distance(d: &Dialog) -> String { + let Kind::Extrude { distance, .. } = &d.kind else { panic!("extrude") }; + distance.clone() +} + +#[test] +fn face_off_the_profile_plane_turns_the_extent_to_that_face() { + let mut h = scene(); + assert!(to_object(dialog(&h)).is_none(), "Distance first"); + assert!(click(&mut h, |h| face_hit(h, Vec3::new(40.0, 10.0, 20.0)))); + let d = dialog(&h); + assert!(matches!(to_object(d), Some(Sel::Face { .. }))); + assert_eq!(d.inputs[0].items.len(), 1, "the profile stays the only profile"); + assert_eq!(d.active, 1, "the To Object input is active"); + assert_eq!(distance(d), "20 mm", "the length is the target's"); + let commands = apply_commands(&h.app, d).unwrap(); + let params = &commands[0].1; + assert!(params.get("to").is_some()); + let reach = solidcraft_engine::extrude_reach(&h.app.session, params).unwrap(); + assert!((reach - 20.0).abs() < 1e-9, "{reach}"); + // OK builds the extrude up to the face. + h.step(&json!({"widget": "OK"})).unwrap(); + h.frames(4); + let state = h.app.session.world_state(); + let volume: f64 = state.bodies.iter().filter_map(|b| solidcraft_engine::doc::kernel::measure(&b.body).ok()).map(|m| m.volume).sum(); + // The box, the cylinder and the 10 x 10 x 20 extrude. + let expected = 8000.0 + std::f64::consts::PI * 25.0 * 30.0 + 2000.0; + assert!((volume - expected).abs() < 1.0, "{volume}"); +} + +#[test] +fn coplanar_face_stays_a_profile() { + let mut h = scene(); + assert!(click(&mut h, |h| face_hit(h, Vec3::new(40.0, 10.0, 0.0)))); + let d = dialog(&h); + assert!(to_object(d).is_none()); + assert_eq!(d.inputs[0].items.len(), 2, "the bottom face joins the profiles"); + assert_eq!(distance(d), "10 mm"); +} + +#[test] +fn vertex_and_curved_face_are_targets() { + let mut h = scene(); + // A vertex in the profile plane is not something to go to. + assert!(!click(&mut h, |h| Hit::Vertex { body: dialog_body(h), point: Vec3::new(50.0, 20.0, 0.0) })); + assert!(to_object(dialog(&h)).is_none()); + assert!(click(&mut h, |h| Hit::Vertex { body: dialog_body(h), point: Vec3::new(50.0, 20.0, 20.0) })); + assert!(matches!(to_object(dialog(&h)), Some(Sel::Vertex { .. }))); + + let mut h = scene(); + assert!(click(&mut h, curved_hit)); + assert!(matches!(to_object(dialog(&h)), Some(Sel::Face { .. }))); +} + +/// The cylinder's side, at the middle of one of its triangles. +fn curved_hit(h: &Harness) -> Hit { + let st = h.app.session.world_state(); + for b in &st.bodies { + let m = b.mesh(); + for (t, f) in m.triangles.iter().zip(&m.tri_face) { + let Some([p, q, r]) = m.tri(t) else { continue }; + if b.body.planar_face(*f as usize).is_none() { + return Hit::Face { body: b.name.clone(), index: *f as usize, point: (p + q + r) * (1.0 / 3.0) }; + } + } + } + panic!("no curved face") +} + +fn dialog_body(h: &Harness) -> String { + h.app.session.world_state().bodies.first().unwrap().name.clone() +} + +#[test] +fn parallel_construction_plane_is_a_target_and_a_crossing_one_is_not() { + let mut h = scene(); + assert!(!click(&mut h, |_| Hit::Plane { name: "YZ".into(), point: Vec3::new(0.0, 5.0, 5.0) })); + assert!(to_object(dialog(&h)).is_none()); + assert!(click(&mut h, |_| Hit::Plane { name: "Lid".into(), point: Vec3::new(5.0, 5.0, 25.0) })); + let d = dialog(&h); + assert!(matches!(to_object(d), Some(Sel::Vertex { body, .. }) if body.is_empty())); + assert_eq!(distance(d), "25 mm"); + // The profile plane itself is nowhere to go: the target stays. + assert!(!click(&mut h, |_| Hit::Plane { name: "XY".into(), point: Vec3::new(5.0, 5.0, 0.0) })); + assert!(matches!(to_object(dialog(&h)), Some(Sel::Vertex { point, .. }) if (point.z - 25.0).abs() < 1e-9)); +} + +#[test] +fn without_profiles_a_face_is_a_profile() { + let mut h = scene(); + h.app.dialog.as_mut().unwrap().inputs[0].items.clear(); + assert!(click(&mut h, |h| face_hit(h, Vec3::new(40.0, 10.0, 20.0)))); + let d = dialog(&h); + assert!(to_object(d).is_none()); + assert!(matches!(d.inputs[0].items.as_slice(), [Sel::Face { .. }])); +} + +#[test] +fn thin_extrude_keeps_its_picks() { + let mut h = scene(); + if let Some(Kind::Extrude { thin, .. }) = h.app.dialog.as_mut().map(|d| &mut d.kind) { + *thin = true; + } + click(&mut h, |h| face_hit(h, Vec3::new(40.0, 10.0, 20.0))); + assert!(to_object(dialog(&h)).is_none()); +} + +#[test] +fn target_below_keeps_its_side_and_exact_length() { + let mut h = scene(); + // As the browser picks a plane: no place on it. + assert!(click(&mut h, |_| Hit::Plane { name: "Under".into(), point: Vec3::ZERO })); + let d = dialog(&h); + assert!(matches!(to_object(d), Some(Sel::Vertex { point, .. }) if (point.z + 12.345678).abs() < 1e-9)); + assert_eq!(distance(d), "12.345678 mm"); + assert!(matches!(d.kind, Kind::Extrude { direction: 1, .. }), "the target is below the profile"); +} + +#[test] +fn off_by_default_a_face_off_the_profile_plane_joins_the_profiles() { + let h = Harness::new(); + assert!(!h.app.preferences.extrude_auto_to_object, "the preference starts off"); + let mut h = scene_with(false); + assert!(click(&mut h, |h| face_hit(h, Vec3::new(40.0, 10.0, 20.0)))); + let d = dialog(&h); + assert!(to_object(d).is_none(), "the extent stays Distance"); + assert_eq!(d.inputs[0].items.len(), 2, "the top face joins the profiles, as before"); + assert_eq!(distance(d), "10 mm"); + // A parallel construction plane is not taken as a target either. + let mut h = scene_with(false); + assert!(!click(&mut h, |_| Hit::Plane { name: "Lid".into(), point: Vec3::new(5.0, 5.0, 25.0) })); + assert!(to_object(dialog(&h)).is_none()); +} diff --git a/crates/ui-egui/src/extrude_edit_tests.rs b/crates/ui-egui/src/extrude_edit_tests.rs index 5a2dab69ae..0b271fea88 100644 --- a/crates/ui-egui/src/extrude_edit_tests.rs +++ b/crates/ui-egui/src/extrude_edit_tests.rs @@ -110,7 +110,7 @@ fn to_offset_canvas_edit_changes_offset_and_one_undo_restores_extent() { #[test] fn moved_owner_references_survive_actual_edit_with_another_component_active() { let mut h = Harness::new(); - let owner = h.app.run("component.create", json!({"name":"Owner"})).unwrap(); + let owner = h.app.run("component.create", json!({"name":"Owner","grounded":false})).unwrap(); let mut h = make_in(h, json!({"from":[0,0,5],"to":[0,0,15],"to_offset":"2 mm"})); h.app.run("occurrence.move", json!({"occurrence":owner["occurrence"],"translate":[100,0,20]})).unwrap(); let other = h.app.run("component.create", json!({"name":"Other","parent":0})).unwrap(); diff --git a/crates/ui-egui/src/extrude_preview.rs b/crates/ui-egui/src/extrude_preview.rs index 1056937092..14d1541d43 100644 --- a/crates/ui-egui/src/extrude_preview.rs +++ b/crates/ui-egui/src/extrude_preview.rs @@ -349,7 +349,7 @@ mod tests { #[test] fn identity_component_feedback_skips_moved_and_repeated_placements() { let mut h = Harness::new(); - let made = h.app.run("component.create", json!({"name":"Imported part"})).unwrap(); + let made = h.app.run("component.create", json!({"name":"Imported part","grounded":false})).unwrap(); h.app.run("solid.box", json!({"length":4,"width":3,"height":5,"body_name":"Stock"})).unwrap(); let world = h.app.session.world_state(); let face = diff --git a/crates/ui-egui/src/file_load.rs b/crates/ui-egui/src/file_load.rs index a8a4011971..a2f1167a2c 100644 --- a/crates/ui-egui/src/file_load.rs +++ b/crates/ui-egui/src/file_load.rs @@ -180,15 +180,23 @@ impl FileLoad { pub fn features(&self) -> Option<(usize, usize)> { self.running.as_ref().map(|r| r.progress.features()) } - /// Nothing running or queued (a cancelled job may still be finishing in [`Self::busy`]'s eyes). + /// No active or queued request; cancelled import workers may still be unwinding. pub fn idle(&self) -> bool { self.running.is_none() && self.queued.is_empty() } - /// Stop the running operation at its next feature and drop the queued ones. + /// Signal cancellation, release the import slot and drop queued requests. pub fn cancel(&mut self) { if let Some(r) = &self.running { r.progress.cancel(); } + // Imports only change a scratch session. Disconnect their result and preview channels + // so a slow cancellation checkpoint cannot block the next file or apply a late result. + // Exports publish a file: keep their reply until the worker reports whether it committed. + if self.running.as_ref().is_some_and(|r| r.request.kind != Kind::ExportDxf) + && let Some(r) = self.running.take() + { + r.request.answer(Err("file operation cancelled".into())); + } for q in self.queued.drain(..) { q.answer(Err("file operation cancelled".into())); } @@ -992,9 +1000,8 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { } let cancel = closed || action == ui_kit::Action::Cancel; if cancel && !cancelled { - // Keep the running job until it exits: cancelling must not allow another large - // import to overlap it in memory. The rebuild stops at its next feature and the - // current design stays as it was. + // The worker stops at its next cancellation checkpoint; its scratch design and + // result channels are isolated from any file operation started afterwards. app.file_load.cancel(); app.set_status(if export { crate::i18n::tr_label("dxf.cancelled") } else { "file operation cancelled" }, false); } @@ -1171,34 +1178,58 @@ mod tests { assert!(app.status.as_ref().unwrap().0.contains("bad STEP")); } #[test] - fn cancelling_discards_a_finished_result_and_keeps_the_worker_slot_until_completion() { - let mut app = SolveApp::new(Session::default(), Default::default()); - let old = app.session.doc.clone(); - let (tx, rx) = std::sync::mpsc::channel(); - let progress = Arc::new(Progress::default()); - progress.cancel(); - app.file_load.running = Some(Running { - request: Request::new("cancelled.step", Kind::Insert), - base: old.clone(), - base_revision: app.session.revision, - before: Vec::new(), - rx, - phase: Arc::new(AtomicU8::new(0)), - progress, - preview_rx: None, - preview: None, - partial_rx: None, - built: None, - staged: None, - }); - assert!(!app.file_load.busy()); - assert!(app.file_load.running.is_some()); - let mut computed = Session::default(); - computed.execute("solid.box", &serde_json::json!({"length":10,"width":10,"height":10})).unwrap(); - tx.send(Ok(Made { session: computed, result: Value::Null, thumbnail: false })).unwrap(); - tick(&mut app, &crate::fonts::test_context()); - assert!(app.file_load.running.is_none()); - assert!(Arc::ptr_eq(&old, &app.session.doc)); + fn cancelling_disconnects_late_results_and_answers_queued_callers() { + for finished in [false, true] { + let mut app = SolveApp::new(Session::default(), Default::default()); + let old = app.session.doc.clone(); + let (tx, rx) = std::sync::mpsc::channel(); + let (reply_tx, reply_rx) = std::sync::mpsc::channel(); + let mut request = Request::new("cancelled.step", Kind::Insert); + request.reply = Some(reply_tx); + let progress = Arc::new(Progress::default()); + app.file_load.running = Some(Running { + request, + base: old.clone(), + base_revision: app.session.revision, + before: Vec::new(), + rx, + phase: Arc::new(AtomicU8::new(0)), + progress: progress.clone(), + preview_rx: None, + preview: None, + partial_rx: None, + built: None, + staged: None, + }); + let (queued_tx, queued_rx) = std::sync::mpsc::channel(); + let mut queued = Request::new("queued.step", Kind::Open); + queued.reply = Some(queued_tx); + app.file_load.queued.push_back(queued); + let mut computed = Session::default(); + computed.execute("solid.box", &json!({"length":10,"width":10,"height":10})).unwrap(); + let made = Ok(Made { session: computed, result: Value::Null, thumbnail: false }); + // Both races: the result was queued before Cancel, or arrives afterwards. + let late = if finished { + tx.send(made).unwrap(); + None + } else { + Some(made) + }; + app.file_load.cancel(); + assert!(progress.is_cancelled()); + assert!(app.file_load.idle()); + for reply in [&reply_rx, &queued_rx] { + assert_eq!(reply.try_recv().unwrap(), json!({"ok":false,"error":"file operation cancelled"})); + assert!(reply.try_recv().is_err(), "each caller is answered once"); + } + app.session.execute("solid.box", &json!({"length":20,"width":20,"height":20})).unwrap(); + let newer = app.session.doc.clone(); + if let Some(late) = late { + assert!(tx.send(late).is_err(), "the old worker cannot reach the next operation"); + } + tick(&mut app, &crate::fonts::test_context()); + assert!(Arc::ptr_eq(&newer, &app.session.doc)); + } } /// #1376: the web app reads a STEP file a piece per frame. The first frame only shows the diff --git a/crates/ui-egui/src/file_load_ui_tests.rs b/crates/ui-egui/src/file_load_ui_tests.rs index 57d91231b9..d802df3250 100644 --- a/crates/ui-egui/src/file_load_ui_tests.rs +++ b/crates/ui-egui/src/file_load_ui_tests.rs @@ -463,3 +463,55 @@ fn opening_a_large_design_never_stalls_the_window() { } let _ = std::fs::remove_dir_all(&dir); } + +/// #3171: the second STEP open completes even if the cancelled worker has not returned. +#[test] +fn cancelling_a_step_open_releases_the_slot_before_the_worker_returns() { + let dir = scratch("step-cancel-reopen"); + let path = dir.join("box.step").to_string_lossy().to_string(); + let mut src = Session::default(); + src.execute("solid.box", &json!({"length": 10, "width": 20, "height": 30})).unwrap(); + src.execute("file.export", &json!({"path": path})).unwrap(); + let mut expected = Session::default(); + expected.execute("doc.open", &json!({"path": path})).unwrap(); + + let mut w = Win::new(); + let blank = w.app.session.doc.clone(); + let (started_tx, started_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + w.app.file_load.worker_gate = Some((started_tx, release_rx)); + let (first, first_reply) = crate::control::ControlRequest::new("engine.execute", json!({"command":"doc.open", "params":{"path":path}})); + assert!(crate::file_load::control(&mut w.app, first).is_none()); + started_rx.recv_timeout(std::time::Duration::from_secs(60)).unwrap(); + for _ in 0..3 { + w.frame(vec![]); + } + let cancel = crate::scenario::handle_at("file_loading:cancel").unwrap(); + w.gesture(&[cancel]); + assert!(w.app.file_load.idle(), "Cancel releases the slot while the first worker is still held"); + let cancelled = first_reply.try_recv().expect("the cancelled caller is answered immediately"); + assert_eq!(cancelled["ok"], false); + assert!(cancelled["error"].as_str().unwrap().contains("cancelled")); + assert!(std::sync::Arc::ptr_eq(&blank, &w.app.session.doc)); + + let (second, second_reply) = crate::control::ControlRequest::new("engine.execute", json!({"command":"doc.open", "params":{"path":path}})); + assert!(crate::file_load::control(&mut w.app, second).is_none()); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + let reply = loop { + w.frame(vec![]); + if let Ok(reply) = second_reply.try_recv() { + break reply; + } + assert!(std::time::Instant::now() < deadline, "the second import completes before releasing the first"); + std::thread::yield_now(); + }; + assert_eq!(reply["ok"], true, "{reply}"); + assert_eq!(fingerprint(&w.app.session), fingerprint(&expected)); + assert_eq!(w.app.docs.count(), 1); + release_tx.send(()).unwrap(); + for _ in 0..3 { + w.frame(vec![]); + } + assert_eq!(fingerprint(&w.app.session), fingerprint(&expected)); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/file_picker.rs b/crates/ui-egui/src/file_picker.rs index 2c081346b5..c0f31508e9 100644 --- a/crates/ui-egui/src/file_picker.rs +++ b/crates/ui-egui/src/file_picker.rs @@ -365,4 +365,27 @@ mod tests { finish(&mut h.app, &answer, None); assert!(h.app.tool.is_none(), "cancelling does not resume the old placement"); } + + /// #3216: pickers for files that aren't designs list the files' own types. On macOS a type + /// no filter names can't be picked: CSV parameters were greyed out. + #[test] + fn parameter_import_and_canvas_images_ask_for_their_file_types() { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + let asked: Rc>>> = Rc::default(); + let log = asked.clone(); + h.app.services.pick_open = Some(Box::new(move |types: &[&str]| { + log.borrow_mut().push(types.iter().map(|t| t.to_string()).collect()); + Box::pin(std::future::ready(None)) + })); + crate::params_dialog::open(&mut h.app); + h.frames(2); + h.step(&json!({"widget": "Import…"})).unwrap(); + h.frames(2); + h.app.params = None; + h.app.run("sketch.create", json!({"plane": "XY"})).unwrap(); + h.app.start("canvas.insert"); + h.frames(2); + assert_eq!(*asked.borrow(), [vec!["csv", "json"], vec!["png", "jpg", "jpeg"]]); + } } diff --git a/crates/ui-egui/src/fonts.rs b/crates/ui-egui/src/fonts.rs index ddb733095f..b261ab34c0 100644 --- a/crates/ui-egui/src/fonts.rs +++ b/crates/ui-egui/src/fonts.rs @@ -555,12 +555,22 @@ fn scan_dir_for_font_where(dir: &std::path::Path, depth_remaining: usize, matche None } -/// Find a CJK fallback font on the local system or in CRAFT_FONTS_DIR. +/// The font folders named by `SOLIDCRAFT_FONT_DIRS` (a path list), or else by the single folder +/// in `CRAFT_FONTS_DIR` (the craft-fonts pack). +#[cfg(not(target_arch = "wasm32"))] +fn font_override_dirs(dirs: Option, pack: Option) -> Vec { + match (dirs, pack) { + (Some(dirs), _) => std::env::split_paths(&dirs).collect(), + (None, Some(pack)) => vec![std::path::PathBuf::from(pack)], + (None, None) => Vec::new(), + } +} + +/// Find a CJK fallback font on the local system or in SOLIDCRAFT_FONT_DIRS / CRAFT_FONTS_DIR. #[cfg(not(target_arch = "wasm32"))] fn find_system_cjk_font() -> Option> { - // 1. Check optional craft-fonts pack directory if specified. - if let Ok(craft_dir) = std::env::var("CRAFT_FONTS_DIR") { - let dir = std::path::Path::new(&craft_dir); + // 1. Check the font folders the user named. + for dir in font_override_dirs(std::env::var_os("SOLIDCRAFT_FONT_DIRS"), std::env::var_os("CRAFT_FONTS_DIR")) { let names = ["BIZUDPGothic-Regular.ttf", "BIZ-UDGothicR.ttc", "NotoSansCJK-Regular.ttc", "NotoSansJP-Regular.ttf"]; for name in names { if let Some(bytes) = try_read_font(&dir.join(name)) { @@ -615,7 +625,12 @@ fn find_system_cjk_font() -> Option> { #[cfg(target_os = "linux")] { - let search_dirs = [std::path::PathBuf::from("/usr/share/fonts"), std::path::PathBuf::from("/usr/local/share/fonts")]; + let search_dirs = [ + std::path::PathBuf::from("/usr/share/fonts"), + std::path::PathBuf::from("/usr/local/share/fonts"), + std::path::PathBuf::from("/run/host/fonts"), + std::path::PathBuf::from("/run/host/user-fonts"), + ]; for dir in &search_dirs { if let Some(bytes) = scan_dir_for_font(dir, 3, is_cjk_font_name) { return Some(bytes); @@ -809,6 +824,8 @@ fn chinese_font_dirs() -> Vec { { dirs.push(std::path::PathBuf::from("/usr/share/fonts")); dirs.push(std::path::PathBuf::from("/usr/local/share/fonts")); + dirs.push(std::path::PathBuf::from("/run/host/fonts")); + dirs.push(std::path::PathBuf::from("/run/host/user-fonts")); if let Ok(home) = std::env::var("HOME") { dirs.push(std::path::PathBuf::from(&home).join(".local/share/fonts")); dirs.push(std::path::PathBuf::from(home).join(".fonts")); @@ -884,6 +901,22 @@ pub(crate) fn test_context() -> egui::Context { mod tests { use super::*; + /// `SOLIDCRAFT_FONT_DIRS` wins and may list several folders; `CRAFT_FONTS_DIR` is the fallback. + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn cjk_font_folders_from_the_environment() -> Result<(), std::env::JoinPathsError> { + use std::path::PathBuf; + let list = std::env::join_paths(["/a", "/b"])?; + assert_eq!(font_override_dirs(Some(list), Some("/pack".into())), vec![PathBuf::from("/a"), PathBuf::from("/b")]); + assert_eq!(font_override_dirs(None, Some("/pack".into())), vec![PathBuf::from("/pack")]); + assert!(font_override_dirs(None, None).is_empty()); + #[cfg(target_os = "linux")] + for dir in ["/run/host/fonts", "/run/host/user-fonts"] { + assert!(chinese_font_dirs().contains(&PathBuf::from(dir)), "missing {dir}"); + } + Ok(()) + } + /// The system faces are searched for on a thread; installing fonts before they are found does /// not wait for the search and still gives the bundled faces. #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/ui-egui/src/frame_rate.rs b/crates/ui-egui/src/frame_rate.rs index 30a1aa3bbc..6904539f0e 100644 --- a/crates/ui-egui/src/frame_rate.rs +++ b/crates/ui-egui/src/frame_rate.rs @@ -1,6 +1,6 @@ //! The viewport's frame-rate readout (Preferences ▸ Graphics ▸ "Frame rate"). -use egui::{FontId, Rect, Stroke, pos2, vec2}; +use egui::{FontId, Rect, pos2, vec2}; use crate::SolveApp; use crate::theme::Tokens; @@ -69,7 +69,7 @@ pub(crate) fn paint(app: &mut SolveApp, ctx: &egui::Context, painter: &egui::Pai let galley = painter.layout_no_wrap(text, font, t.text); let size = galley.size() + vec2(16.0, 8.0); let r = Rect::from_min_size(pos2(rect.right() - 12.0 - size.x, rect.bottom() - 12.0 - size.y), size); - painter.rect(r, 4.0, t.overlay, Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + painter.rect(r, t.radius_sm, t.overlay, t.control_border_stroke(t.border), egui::StrokeKind::Inside); crate::scenario::publish_target("viewport:frame_rate", r); painter.galley(r.center() - galley.size() / 2.0, galley, t.text); } diff --git a/crates/ui-egui/src/gpu.rs b/crates/ui-egui/src/gpu.rs index 16cb977d1a..ea692167ba 100644 --- a/crates/ui-egui/src/gpu.rs +++ b/crates/ui-egui/src/gpu.rs @@ -161,9 +161,13 @@ pub(crate) fn offscreen_backends() -> wgpu::Backends { } /// A device of its own for an offscreen render or check (`None` without a GPU adapter), on -/// [`offscreen_backends`]. +/// [`offscreen_backends`]. One test at a time starts its instance, adapter and device: starting an +/// instance scans the Vulkan drivers, and tests doing that side by side crashed the test process +/// inside the Vulkan loader (`loader_icd_scan`). Rendering on the devices still runs side by side. #[cfg(all(test, not(target_arch = "wasm32")))] pub(crate) fn offscreen_device() -> Option<(wgpu::Adapter, wgpu::Device, wgpu::Queue)> { + static STARTING: std::sync::Mutex<()> = std::sync::Mutex::new(()); + let _one_at_a_time = STARTING.lock().unwrap_or_else(std::sync::PoisonError::into_inner); let mut desc = wgpu::InstanceDescriptor::new_without_display_handle_from_env(); desc.backends = offscreen_backends(); let instance = wgpu::Instance::new(desc); diff --git a/crates/ui-egui/src/history_off_thread_tests.rs b/crates/ui-egui/src/history_off_thread_tests.rs index 80356746be..8b6b710726 100644 --- a/crates/ui-egui/src/history_off_thread_tests.rs +++ b/crates/ui-egui/src/history_off_thread_tests.rs @@ -504,3 +504,44 @@ fn without_a_wait_the_delete_checks_in_place() { assert!(h.app.delete_check.is_none()); assert!(h.app.session.doc.feature(boss).is_none()); } + +/// A body renamed in its rename box (#2467): off the window thread, the features after the one +/// that made it (a pattern, a chamfer) kept as they are, and the body's lock following the new +/// name. +#[test] +fn a_body_renamed_in_the_browser_keeps_the_pattern_after_it() { + let mut h = Harness::new(); + h.app.home.open = false; + for (id, p) in [ + ("sketch.create", json!({"plane": "XY"})), + ("sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [80, 50]})), + ("sketch.finish", json!({})), + ("solid.extrude", json!({"distance": 10})), + ("solid.hole", json!({"position": [15, 12, 10], "diameter": 5, "name": "Hole1"})), + ( + "solid.pattern.rectangular", + json!({"features": ["Hole1"], "dir1": [1, 0, 0], "count1": 3, "spacing1": 25, "dir2": [0, 1, 0], "count2": 2, "spacing2": 25}), + ), + ("solid.chamfer", json!({"edges": [[40, 0, 10]], "distance": 1})), + ] { + h.app.run(id, p).unwrap(); + } + h.app.ui.locked_bodies.push("Body1".into()); + h.app.apply_off_thread = Some(Duration::ZERO); + crate::context_menu::start_rename(&mut h.app, crate::context_menu::RenameWhat::Body("Body1".into()), egui::pos2(0.0, 0.0)); + h.frames(2); + h.step(&json!({"text": "Plate"})).unwrap(); + let names = |h: &Harness| h.app.session.model.state().bodies.iter().map(|b| b.name.clone()).collect::>(); + let mesh = |h: &Harness| h.app.session.model.state().bodies.first().map(|b| b.mesh()); + let before = mesh(&h); + // What Enter does, without the frames after it that would see the rename in. + let rename = h.app.menu.rename.take().unwrap(); + crate::context_menu::commit_rename(&mut h.app, &rename); + assert!(h.app.computing_since().is_some(), "computing"); + assert_eq!(names(&h), ["Body1"], "the design is as it was while it computes"); + until_settled(&mut h); + assert_eq!(names(&h), ["Plate"]); + let kept = before.zip(mesh(&h)).is_some_and(|(a, b)| std::sync::Arc::ptr_eq(&a, &b)); + assert!(kept, "no feature computed again"); + assert_eq!(h.app.ui.locked_bodies, ["Plate"]); +} diff --git a/crates/ui-egui/src/hole_place.rs b/crates/ui-egui/src/hole_place.rs new file mode 100644 index 0000000000..d80ce81015 --- /dev/null +++ b/crates/ui-egui/src/hole_place.rs @@ -0,0 +1,388 @@ +//! Placing a Single hole on the canvas (#2171, #1047). Both behaviours are Preferences › Design +//! settings, off by default, so a hole goes where it is clicked and a second pick on its face +//! takes it out, as it always did. +//! +//! Create Hole › Snap to face and edge centres (`hole_snaps`): the planar face under the cursor offers snap points, the +//! middle of the face and the centres of its round edges (a boss standing on it, a rounded +//! corner, a hole already in it). While the cursor is over the face they show as small marks; a +//! pick within reach of one, or on one of those round edges, is taken at that point, and the mark +//! it takes is named. Sketch points on the face (#657) still come first. Ctrl or Alt held: no snap. +//! +//! Create Hole › Move holes by clicking or dragging (`hole_moves`): every placed hole has a handle at its +//! centre that drags it across the model (snapping as a pick would) and takes it out when +//! clicked, and a pick elsewhere on a face that already has a hole moves the hole there. + +use std::cell::RefCell; +use std::sync::Arc; + +use egui::{Pos2, vec2}; +use solidcraft_engine::Sel; +use solidcraft_engine::Session; +use solidcraft_engine::doc::expr::Kind as ValueKind; +use solidcraft_engine::geom::{Aabb3, Mesh, Vec3}; + +use crate::SolveApp; +use crate::dialogs::{Dialog, Kind}; +use crate::dialogs_assembly::SnapKind; +use crate::selection::same_item; +use crate::theme::Tokens; +use crate::viewport::{Hit, Proj}; + +/// How near (screen points) a pick must come to a snap point to take it. +pub(crate) const POINT_REACH: f32 = 10.0; +/// How near a pick must come to a round edge to take its centre. +pub(crate) const RIM_REACH: f32 = 6.0; +/// How far (screen points) from a hole's centre a press takes its handle. +const HANDLE_REACH: f32 = 9.0; +/// The most triangles of one face looked through for its snaps. +const MAX_TRIANGLES: usize = 1 << 18; + +/// A place a hole may snap to on a planar face. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Snap { + pub at: Vec3, + pub kind: SnapKind, + /// The round edges whose centre it is (a rim split into pieces makes one snap). + pub rims: Vec>, +} + +thread_local! { + /// The last face's snaps, with the mesh they were found on (held, so a new mesh at the same + /// address can't pass for it). + static CACHE: RefCell, usize, Arc>)>> = const { RefCell::new(None) }; +} + +/// The snaps of face `face` of mesh `m`: none unless the face is planar. +pub(crate) fn face_snaps(m: &Arc, face: usize) -> Arc> { + let cached = CACHE.with(|c| c.borrow().as_ref().filter(|(mm, f, _)| Arc::ptr_eq(mm, m) && *f == face).map(|(_, _, s)| s.clone())); + if let Some(snaps) = cached { + return snaps; + } + let snaps = Arc::new(find_snaps(m, face)); + CACHE.with(|c| *c.borrow_mut() = Some((m.clone(), face, snaps.clone()))); + snaps +} + +/// The centres of the face's round edges that lie in its plane, and the middle of the face (the +/// centre of its extent, when that is on the face and no round edge's centre is there already). +fn find_snaps(m: &Mesh, face: usize) -> Vec { + let tris: Vec<[Vec3; 3]> = + m.triangles.iter().zip(&m.tri_face).filter(|(_, f)| **f as usize == face).filter_map(|(t, _)| m.tri(t)).take(MAX_TRIANGLES).collect(); + let Some(&[a, _, _]) = tris.first() else { return Vec::new() }; + // The plane: the triangles' summed normals (counter-clockwise from outside), through a corner. + let Some(n) = tris.iter().fold(Vec3::ZERO, |s, [p, q, r]| s + (*q - *p).cross(*r - *p)).normalized() else { return Vec::new() }; + let mut bounds = Aabb3::EMPTY; + tris.iter().flatten().for_each(|p| bounds.add(*p)); + let tol = (bounds.diagonal() * 1e-6).max(1e-7); + if tris.iter().flatten().any(|p| (*p - a).dot(n).abs() > tol * 10.0) { + return Vec::new(); + } + let same = |p: Vec3, q: Vec3| p.dist(q) <= tol * 100.0; + let mut snaps: Vec = Vec::new(); + for e in m.face_edges(face as u32) { + let Some(pts) = m.edges.get(e) else { continue }; + let Some((c, axis)) = crate::dialogs_assembly::circle_of(pts) else { continue }; + // A circle or an arc in the face's plane (a seam or a slanted rim is no place for a hole). + if (c - a).dot(n).abs() > tol * 100.0 || axis.dot(n).abs() < 1.0 - 1e-6 { + continue; + } + match snaps.iter_mut().find(|s| same(s.at, c)) { + Some(s) => s.rims.push(pts.clone()), + None => snaps.push(Snap { at: c, kind: SnapKind::CircleCenter, rims: vec![pts.clone()] }), + } + } + let c = bounds.center(); + let c = c - n * (c - a).dot(n); + if on_face(c, n, &tris, tol) && !snaps.iter().any(|s| same(s.at, c)) { + snaps.insert(0, Snap { at: c, kind: SnapKind::FaceCenter, rims: Vec::new() }); + } + snaps +} + +/// Does `p` (in the plane of normal `n`) lie on one of the triangles? +fn on_face(p: Vec3, n: Vec3, tris: &[[Vec3; 3]], tol: f64) -> bool { + tris.iter().any(|[a, b, c]| [(a, b), (b, c), (c, a)].iter().all(|(u, v)| (**v - **u).cross(p - **u).dot(n) >= -tol * (**v - **u).len())) +} + +/// Is a Single hole being placed: the Hole dialog open on its face input? +pub(crate) fn placing(app: &SolveApp) -> bool { + app.dialog.as_ref().is_some_and(|d| { + d.active == 0 + && matches!(&d.kind, Kind::Hole { opts, .. } if !opts.multiple) + && d.active_input().is_some_and(|i| crate::dialogs::snaps_to_points(i.accept)) + }) +} + +/// Do picks snap while a Single hole is placed (the preference is on)? +pub(crate) fn snapping(app: &SolveApp) -> bool { + app.preferences.hole_snaps && placing(app) +} + +/// The distance on screen from `p` to the segment from `a` to `b`. +fn to_segment(p: Pos2, a: Pos2, b: Pos2) -> f32 { + let v = b - a; + let t = if v.length_sq() > 0.0 { ((p - a).dot(v) / v.length_sq()).clamp(0.0, 1.0) } else { 0.0 }; + p.distance(a + v * t) +} + +/// The snap a pick at `pos` takes among `snaps`: the nearest snap point within [`POINT_REACH`], +/// else the centre of the nearest round edge within [`RIM_REACH`]. +fn nearest(proj: &Proj, pos: Pos2, snaps: &[Snap]) -> Option { + let point = snaps + .iter() + .filter_map(|s| Some((proj.to_screen(s.at)?.distance(pos), s))) + .filter(|(d, _)| *d <= POINT_REACH) + .min_by(|a, b| a.0.total_cmp(&b.0)); + if let Some((_, s)) = point { + return Some(s.clone()); + } + snaps + .iter() + .filter_map(|s| { + let d = s + .rims + .iter() + .flat_map(|r| r.windows(2)) + .filter_map(|w| Some(to_segment(pos, proj.to_screen(*w.first()?)?, proj.to_screen(*w.get(1)?)?))) + .fold(f32::INFINITY, f32::min); + (d <= RIM_REACH).then_some((d, s)) + }) + .min_by(|a, b| a.0.total_cmp(&b.0)) + .map(|(_, s)| s.clone()) +} + +/// A pick for a Single hole's face place: the nearest face under the cursor is taken at the snap +/// within reach on it, or at the centre of a round edge the cursor is on (the edge lies between +/// the face and a hole's or a boss's wall, so the face under the cursor may be the wall). +pub(crate) fn snap_hits(app: &SolveApp, proj: &Proj, pos: Pos2, hits: &mut Vec) { + if !snapping(app) || crate::drag_snap::suppressed() { + return; + } + let st = app.session.world_state(); + let face = hits.iter().position(|h| matches!(h, Hit::Face { .. })); + let at_face = face.and_then(|i| match hits.get(i) { + Some(Hit::Face { body, index, .. }) => { + let m = st.body(body)?.mesh(); + nearest(proj, pos, &face_snaps(&m, *index)).map(|s| (body.clone(), *index, s.at)) + } + _ => None, + }); + let on_rim = || { + hits.iter().find_map(|h| { + let Hit::Edge { body, index, .. } = h else { return None }; + let m = st.body(body)?.mesh(); + let pts = m.edges.get(*index)?; + m.edge_faces.get(*index)?.iter().find_map(|f| { + let snaps = face_snaps(&m, *f as usize); + snaps.iter().find(|s| s.rims.iter().any(|r| r == pts)).map(|s| (body.clone(), *f as usize, s.at)) + }) + }) + }; + let Some((body, index, point)) = at_face.or_else(on_rim) else { return }; + let hit = Hit::Face { body, index, point }; + match face { + Some(i) => { + if let Some(h) = hits.get_mut(i) { + *h = hit; + } + } + None => hits.push(hit), + } +} + +/// The snap a face place sits on, if any. +fn snap_under(app: &SolveApp, body: &str, index: usize, point: Vec3) -> (Arc>, Option) { + let st = app.session.world_state(); + let Some(b) = st.body(body) else { return (Arc::new(Vec::new()), None) }; + let snaps = face_snaps(&b.mesh(), index); + let on = snaps.iter().find(|s| s.at.dist(point) <= 1e-9 * (1.0 + s.at.len())).cloned(); + (snaps, on) +} + +/// The holes placed so far: the index of each one's face place, and where it is. A round face +/// split at its seam is picked as both halves, the one picked first where the hole goes. +fn places(s: &Session, d: &Dialog) -> Vec<(usize, Vec3)> { + let Some(inp) = d.inputs.first() else { return Vec::new() }; + let mut taken: Vec = Vec::new(); + let mut out = Vec::new(); + for (i, x) in inp.items.iter().enumerate() { + let Sel::Face { point, .. } = x else { continue }; + if taken.iter().any(|t| same_item(t, x)) { + continue; + } + taken.extend(crate::viewport::grouped_selection(s, x)); + out.push((i, *point)); + } + out +} + +/// Move the hole placed by item `i` of the face input to the face place `to`. The place (with the +/// other half of a round face split at its seam) gives way to `to` (with its own). Not onto a face +/// another hole holds: a face takes one Single hole, so that would lose the other one. +pub(crate) fn move_place(d: &mut Dialog, s: &Session, i: usize, to: Sel) -> bool { + let group = crate::viewport::grouped_selection(s, &to); + let Some(inp) = d.inputs.first_mut() else { return false }; + let Some(old) = inp.items.get(i).cloned() else { return false }; + let own = crate::viewport::grouped_selection(s, &old); + let mine = |x: &Sel| own.iter().any(|g| same_item(x, g)); + if inp.items.iter().any(|x| !mine(x) && group.iter().any(|g| same_item(x, g))) { + return false; + } + let at = inp.items.iter().position(&mine).unwrap_or(i); + inp.items.retain(|x| !mine(x)); + let at = at.min(inp.items.len()); + for (k, x) in group.into_iter().enumerate() { + inp.items.insert(at + k, x); + } + d.error = None; + true +} + +/// A Single hole's face place picked on a face that already has a hole moves that hole there +/// (true), when holes move by clicking. A pick on the hole itself (within its radius) is left to +/// take it out, as before. +pub(crate) fn pick_moves(d: &mut Dialog, s: &Session, sel: &Sel) -> bool { + let Kind::Hole { opts, diameter, .. } = &d.kind else { return false }; + let Sel::Face { point, .. } = sel else { return false }; + if opts.multiple || d.active != 0 { + return false; + } + let radius = s.doc.eval(diameter, ValueKind::Length).ok().filter(|v| v.is_finite() && *v > 0.0).map_or(0.0, |v| v / 2.0); + let group = crate::viewport::grouped_selection(s, sel); + let Some(inp) = d.inputs.first() else { return false }; + let Some(i) = inp.items.iter().position(|x| group.iter().any(|g| same_item(x, g))) else { return false }; + let Some(Sel::Face { point: was, .. }) = inp.items.get(i) else { return false }; + if was.dist(*point) <= radius { + return false; + } + move_place(d, s, i, sel.clone()) +} + +/// The hovered face's snap marks, and the face places' handles when holes move by dragging. +/// Called after the canvas draws the dialog's arrows, so a press on a hole's centre takes the +/// hole, not the depth arrow. +pub(crate) fn show(app: &mut SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj) { + let Some(d) = app.dialog.as_ref() else { return }; + if !matches!(&d.kind, Kind::Hole { opts, .. } if !opts.multiple) || app.applying.is_some() { + return; + } + let t = Tokens::get(); + marks(app, painter, proj); + if !app.preferences.hole_moves { + return; + } + let places = places(&app.session, d); + let mut moved: Option<(usize, Sel)> = None; + let mut removed: Option = None; + for (n, (i, at)) in places.into_iter().enumerate() { + let Some(mut c) = proj.to_screen(at) else { continue }; + let id = egui::Id::new(("sc_hole_place", i)); + let r = ui.interact(egui::Rect::from_center_size(c, vec2(2.0 * HANDLE_REACH, 2.0 * HANDLE_REACH)), id, egui::Sense::click_and_drag()); + // A click on the hole takes it out, as a pick of a picked face does. + if r.clicked() { + removed = Some(i); + } + if r.dragged() + && let Some(p) = r.interact_pointer_pos() + { + // Where a click under the pointer would place a hole: on a face, snapped. + let hits = crate::viewport::pick(app, proj, p); + if let Some((_, to @ Sel::Face { .. })) = crate::viewport::candidate(app, &hits) { + if let Sel::Face { point, .. } = &to + && let Some(q) = proj.to_screen(*point) + { + c = q; + } + moved = Some((i, to)); + } + } + let hot = r.hovered() || r.dragged(); + if hot { + ui.ctx().set_cursor_icon(if r.dragged() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab }); + } + crate::icons::markers::hole_handle(painter, c, if hot { t.accent } else { t.manipulator }, hot); + crate::scenario::publish_handle(&format!("hole:{n}"), c); + if !r.dragged() { + r.on_hover_text(crate::i18n::tr_label("Drag to move the hole; click to remove it")); + } + } + if let Some((i, to)) = moved + && let Some(d) = app.dialog.as_mut() + { + move_place(d, &app.session, i, to); + } + if let Some(i) = removed + && let Some(d) = app.dialog.as_mut() + { + remove_place(d, &app.session, i); + } +} + +/// Take out the hole placed by item `i` of the face input (with the other half of a round face +/// split at its seam). +fn remove_place(d: &mut Dialog, s: &Session, i: usize) { + let Some(inp) = d.inputs.first_mut() else { return }; + let Some(old) = inp.items.get(i).cloned() else { return }; + let own = crate::viewport::grouped_selection(s, &old); + inp.items.retain(|x| !own.iter().any(|g| same_item(x, g))); + d.error = None; +} + +/// The snap points of the face under the cursor while a Single hole is placed, and the one a +/// pick there takes, named. +fn marks(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { + if !snapping(app) { + return; + } + let Some(Hit::Face { body, index, point }) = &app.viewport.hover else { return }; + let t = Tokens::get(); + let (snaps, on) = snap_under(app, body, *index, *point); + for s in snaps.iter() { + let Some(c) = proj.to_screen(s.at) else { continue }; + if on.as_ref().is_some_and(|o| o.at == s.at) { + let name = crate::i18n::tr_label(crate::dialogs_assembly::snap_name(s.kind)); + crate::icons::markers::snap(painter, c, None, s.kind, t.hover_profile_edge, Some(name)); + } else { + crate::icons::markers::snap_hint(painter, c, s.kind, t.manipulator); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use solidcraft_engine::geom::Vec3; + + /// A square face (two triangles) at z = 5 with a round edge about (2, 3) on it. + fn mesh() -> Mesh { + let p = |x: f64, y: f64| Vec3::new(x, y, 5.0); + let rim: Vec = (0..=16).map(|k| f64::from(k) * std::f64::consts::TAU / 16.0).map(|a| p(2.0 + a.cos(), 3.0 + a.sin())).collect(); + Mesh { + positions: vec![p(0.0, 0.0), p(10.0, 0.0), p(10.0, 10.0), p(0.0, 10.0)], + triangles: vec![[0, 1, 2], [0, 2, 3]], + tri_face: vec![0, 0], + edges: vec![vec![p(0.0, 0.0), p(10.0, 0.0)], rim], + edge_faces: vec![vec![0], vec![0, 1]], + ..Mesh::default() + } + } + + #[test] + fn a_planar_face_snaps_to_its_middle_and_its_round_edges_centres() { + let snaps = find_snaps(&mesh(), 0); + let at: Vec<(SnapKind, Vec3)> = snaps.iter().map(|s| (s.kind, s.at)).collect(); + assert_eq!(at.len(), 2, "{at:?}"); + assert!(at[0].0 == SnapKind::FaceCenter && at[0].1.dist(Vec3::new(5.0, 5.0, 5.0)) < 1e-9, "{at:?}"); + assert!(at[1].0 == SnapKind::CircleCenter && at[1].1.dist(Vec3::new(2.0, 3.0, 5.0)) < 1e-9, "{at:?}"); + assert!(find_snaps(&mesh(), 7).is_empty(), "no triangles, no snaps"); + } + + #[test] + fn a_curved_face_has_no_snaps() { + let mut m = mesh(); + if let Some(p) = m.positions.get_mut(2) { + p.z = 6.0; + } + assert!(find_snaps(&m, 0).is_empty()); + } +} diff --git a/crates/ui-egui/src/hole_place_ui_tests.rs b/crates/ui-egui/src/hole_place_ui_tests.rs new file mode 100644 index 0000000000..a94da42b15 --- /dev/null +++ b/crates/ui-egui/src/hole_place_ui_tests.rs @@ -0,0 +1,295 @@ +//! Placing a Single hole (#2171, #1047). With Snap to face and edge centres on (Preferences, off by +//! default), a pick near the middle of a face or on a round edge snaps there, and Alt held places +//! it where it was clicked. With Move holes by clicking or dragging on (also off by default), the +//! handle drags a placed hole (also one being edited) and a second pick on its face moves it. +//! Both off, picks behave as before. Either way the dialog shows the hole's sizes beside a +//! section of it. + +use serde_json::json; +use solidcraft_engine::Sel; +use solidcraft_engine::doc::FeatureKind; +use solidcraft_engine::geom::Vec3; + +use crate::dialogs::Kind; +use crate::scenario::{Harness, handle_at, rect_of}; +use crate::viewport::Hit; + +/// A 40 × 30 × 20 block seen from the top, with a 10 mm hole through it at (10, 10), and hole +/// snaps and moves turned on as the Preferences dialog (or `ui.prefs`) turns them on. +fn block() -> Harness { + let mut h = block_as_installed(); + crate::prefs::set(&mut h.app, &json!({"hole_snaps": true, "hole_moves": true})).unwrap(); + assert!(h.app.preferences.hole_snaps && h.app.preferences.hole_moves); + h +} + +/// The block with the preferences a new installation has. +fn block_as_installed() -> Harness { + let mut h = Harness::new(); + h.app.ui.show_origin = false; + h.app.run("solid.box", json!({"length": 40, "width": 30, "height": 20})).unwrap(); + h.app.run("solid.hole", json!({"position": [10, 10, 20], "diameter": 10, "name": "Bore"})).unwrap(); + h.step(&json!({"call": "ui.view", "params": {"view": "top"}})).unwrap(); + h.settle(); + h +} + +/// Where a world point is on screen. +fn screen(h: &mut Harness, p: Vec3) -> [f64; 2] { + h.at(&json!({"world": [p.x, p.y, p.z]})).unwrap() +} + +/// `p` on screen, moved by `dx`, `dy` points. +fn off(h: &mut Harness, p: Vec3, dx: f64, dy: f64) -> [f64; 2] { + let c = screen(h, p); + [c[0] + dx, c[1] + dy] +} + +/// The face places the open dialog holds. +fn places(h: &Harness) -> Vec { + h.app.dialog.as_ref().unwrap().inputs[0] + .items + .iter() + .map(|x| match x { + Sel::Face { point, .. } => *point, + other => panic!("a face place, not {other:?}"), + }) + .collect() +} + +/// The last feature's id and position, when it is a hole. +fn last_hole(h: &Harness) -> (u64, Vec3) { + let f = h.app.session.doc.features.last().unwrap(); + match &f.kind { + FeatureKind::Hole { position, .. } => (f.id, position.0), + other => panic!("the last feature is not a hole: {}", other.type_name()), + } +} + +fn solid_at(h: &Harness, p: Vec3) -> bool { + h.app.session.model.state().bodies.iter().any(|b| b.body.contains(p)) +} + +const MIDDLE: Vec3 = Vec3::new(20.0, 15.0, 20.0); +const BORE: Vec3 = Vec3::new(10.0, 10.0, 20.0); + +#[test] +fn a_pick_near_the_middle_of_a_face_goes_to_the_middle() { + let mut h = block(); + h.step(&json!({"start": "solid.hole"})).unwrap(); + let at = off(&mut h, MIDDLE, 5.0, -4.0); + h.step(&json!({"move": at})).unwrap(); + match &h.app.viewport.hover { + Some(Hit::Face { point, .. }) => assert!(point.dist(MIDDLE) < 1e-9, "the face lights up at its middle: {point:?}"), + other => panic!("the face lights up, not {other:?}"), + } + h.step(&json!({"click": at})).unwrap(); + assert!(matches!(places(&h).as_slice(), [p] if p.dist(MIDDLE) < 1e-9), "{:?}", places(&h)); + h.step(&json!({"key": "Enter"})).unwrap(); + assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog.as_ref().map(|d| &d.error)); + let (_, position) = last_hole(&h); + assert!(position.dist(MIDDLE) < 1e-9, "{position:?}"); + assert!(!solid_at(&h, MIDDLE - Vec3::new(0.0, 0.0, 5.0)), "drilled at the middle"); +} + +#[test] +fn a_pick_away_from_snaps_or_with_alt_held_stays_where_it_was_clicked() { + let mut h = block(); + h.step(&json!({"start": "solid.hole"})).unwrap(); + // Well away from the middle and from the bore's rim. + let away = Vec3::new(30.0, 8.0, 20.0); + let at = screen(&mut h, away); + h.step(&json!({"click": at})).unwrap(); + assert!(matches!(places(&h).as_slice(), [p] if p.dist(away) < 1e-3 && p.dist(MIDDLE) > 1.0), "{:?}", places(&h)); + h.step(&json!({"key": "Escape"})).unwrap(); + // Alt held: no snap, even beside the middle. + h.step(&json!({"start": "solid.hole"})).unwrap(); + let at = off(&mut h, MIDDLE, 5.0, -4.0); + h.step(&json!({"move": at, "alt": true})).unwrap(); + h.step(&json!({"click": at, "alt": true})).unwrap(); + assert!(matches!(places(&h).as_slice(), [p] if p.dist(MIDDLE) > 0.1 && (p.z - 20.0).abs() < 1e-9), "{:?}", places(&h)); +} + +#[test] +fn a_pick_on_a_round_edge_goes_to_its_centre() { + let mut h = block(); + h.step(&json!({"start": "solid.hole"})).unwrap(); + // On the bore's rim, and a few points outside it on the top face. + for (dx, label) in [(0.0, "on the rim"), (3.0, "beside the rim")] { + let rim = off(&mut h, BORE + Vec3::new(5.0, 0.0, 0.0), dx, 0.0); + let c = screen(&mut h, BORE); + assert!((rim[0] - c[0]).abs() > 2.0 * f64::from(crate::hole_place::POINT_REACH), "the rim is out of the centre's reach"); + h.step(&json!({"click": rim})).unwrap(); + assert!(matches!(places(&h).as_slice(), [p] if p.dist(BORE) < 1e-6), "{label}: {:?}", places(&h)); + h.step(&json!({"key": "Escape"})).unwrap(); + h.step(&json!({"start": "solid.hole"})).unwrap(); + } + // A counterbore round the bore: drilled on its axis. + let rim = screen(&mut h, BORE + Vec3::new(5.0, 0.0, 0.0)); + h.step(&json!({"click": rim})).unwrap(); + if let Some(Kind::Hole { kind, cb_diameter, cb_depth, diameter, .. }) = h.app.dialog.as_mut().map(|d| &mut d.kind) { + *kind = 1; + *diameter = "12 mm".into(); + *cb_diameter = "16 mm".into(); + *cb_depth = "3 mm".into(); + } + h.step(&json!({"key": "Enter"})).unwrap(); + assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog.as_ref().map(|d| &d.error)); + let (id, position) = last_hole(&h); + assert!(position.dist(BORE) < 1e-6, "{position:?}"); + assert!(h.app.session.model.result(id).is_some_and(|r| r.error.is_none()), "the counterbore cuts"); + assert!(!solid_at(&h, BORE + Vec3::new(7.5, 0.0, -1.5)) && solid_at(&h, BORE + Vec3::new(8.5, 0.0, -1.5)), "8 mm round the axis"); + assert!(!solid_at(&h, BORE + Vec3::new(5.5, 0.0, -8.0)) && solid_at(&h, BORE + Vec3::new(6.5, 0.0, -8.0)), "then 6 mm"); +} + +#[test] +fn the_handle_drags_a_placed_hole_and_snaps() { + let mut h = block(); + h.step(&json!({"start": "solid.hole"})).unwrap(); + let start = Vec3::new(30.0, 8.0, 20.0); + let at = screen(&mut h, start); + h.step(&json!({"click": at})).unwrap(); + h.frames(2); + assert!(handle_at("hole:0").is_some(), "the placed hole has a handle"); + // Dragged to beside the middle: it snaps there. + let to = off(&mut h, MIDDLE, 4.0, 3.0); + h.step(&json!({"drag": [{"handle": "hole:0"}, to]})).unwrap(); + assert!(matches!(places(&h).as_slice(), [p] if p.dist(MIDDLE) < 1e-9), "{:?}", places(&h)); + // Then onto a free spot: it goes where it is let go. + let free = Vec3::new(32.0, 24.0, 20.0); + let to = screen(&mut h, free); + h.step(&json!({"drag": [{"handle": "hole:0"}, to]})).unwrap(); + assert!(matches!(places(&h).as_slice(), [p] if p.dist(free) < 0.2), "{:?}", places(&h)); + // A drag is not a pick: still one hole, and nothing was box-selected. + assert_eq!(places(&h).len(), 1); + h.step(&json!({"key": "Enter"})).unwrap(); + let (_, position) = last_hole(&h); + assert!(position.dist(free) < 0.2, "{position:?}"); +} + +#[test] +fn a_second_pick_on_the_face_moves_the_hole_and_a_pick_on_it_takes_it_out() { + let mut h = block(); + h.step(&json!({"start": "solid.hole"})).unwrap(); + let first = Vec3::new(30.0, 8.0, 20.0); + let at = screen(&mut h, first); + h.step(&json!({"click": at})).unwrap(); + let second = Vec3::new(32.0, 24.0, 20.0); + let at2 = screen(&mut h, second); + h.step(&json!({"click": at2})).unwrap(); + assert!(matches!(places(&h).as_slice(), [p] if p.dist(second) < 0.2), "moved, not added: {:?}", places(&h)); + // On the hole itself (inside its 5 mm): taken out. + h.step(&json!({"click": at2})).unwrap(); + assert!(places(&h).is_empty(), "{:?}", places(&h)); +} + +#[test] +fn editing_a_hole_drags_it_to_a_new_place() { + // Moving a hole (#1047): Edit Feature, drag its handle, OK. + let mut h = block(); + let (id, _) = last_hole(&h); + h.step(&json!({"call": "ui.editFeature", "params": {"feature": id}})).unwrap(); + h.frames(4); + assert!(matches!(places(&h).as_slice(), [p] if p.dist(BORE) < 1e-6), "{:?}", places(&h)); + let to = off(&mut h, MIDDLE, -3.0, 4.0); + h.step(&json!({"drag": [{"handle": "hole:0"}, to]})).unwrap(); + h.step(&json!({"key": "Enter"})).unwrap(); + assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog.as_ref().map(|d| &d.error)); + let (again, position) = last_hole(&h); + assert_eq!(again, id, "the same feature"); + assert!(position.dist(MIDDLE) < 1e-9, "{position:?}"); + let down = Vec3::new(0.0, 0.0, 10.0); + assert!(solid_at(&h, BORE - down), "the old place is solid again"); + assert!(!solid_at(&h, MIDDLE - down), "the hole is at the middle now"); +} + +#[test] +fn snaps_and_moves_are_off_by_default_and_picks_behave_as_before() { + let mut h = block_as_installed(); + assert!(!h.app.preferences.hole_snaps && !h.app.preferences.hole_moves, "a new installation places holes as it always did"); + h.step(&json!({"start": "solid.hole"})).unwrap(); + // Beside the middle: no snap. + let at = off(&mut h, MIDDLE, 5.0, -4.0); + h.step(&json!({"move": at})).unwrap(); + match &h.app.viewport.hover { + Some(Hit::Face { point, .. }) => assert!(point.dist(MIDDLE) > 0.1, "{point:?}"), + other => panic!("the face lights up, not {other:?}"), + } + h.step(&json!({"click": at})).unwrap(); + assert!(matches!(places(&h).as_slice(), [p] if p.dist(MIDDLE) > 0.1 && (p.z - 20.0).abs() < 1e-9), "{:?}", places(&h)); + h.frames(2); + assert!(handle_at("hole:0").is_none(), "no drag handle"); + // A second pick on the face takes the hole out, as it always did. + let elsewhere = screen(&mut h, Vec3::new(32.0, 24.0, 20.0)); + h.step(&json!({"click": elsewhere})).unwrap(); + assert!(places(&h).is_empty(), "{:?}", places(&h)); +} + +#[test] +fn moving_holes_is_its_own_setting() { + // Moves on, snaps off: the handle drags a hole to where it is let go, and a second pick on + // its face moves it; neither snaps. + let mut h = block_as_installed(); + crate::prefs::set(&mut h.app, &json!({"hole_moves": true})).unwrap(); + h.step(&json!({"start": "solid.hole"})).unwrap(); + let start = screen(&mut h, Vec3::new(30.0, 8.0, 20.0)); + h.step(&json!({"click": start})).unwrap(); + h.frames(2); + let to = off(&mut h, MIDDLE, 5.0, -4.0); + h.step(&json!({"drag": [{"handle": "hole:0"}, to]})).unwrap(); + assert!(matches!(places(&h).as_slice(), [p] if p.dist(MIDDLE) > 0.1 && p.dist(MIDDLE) < 2.0), "dragged, not snapped: {:?}", places(&h)); + let elsewhere = Vec3::new(32.0, 24.0, 20.0); + let at = screen(&mut h, elsewhere); + h.step(&json!({"click": at})).unwrap(); + assert!(matches!(places(&h).as_slice(), [p] if p.dist(elsewhere) < 0.2), "moved, not taken out: {:?}", places(&h)); + // Snaps on, moves off: a pick snaps, and a second pick on the face takes the hole out. + h.step(&json!({"key": "Escape"})).unwrap(); + crate::prefs::set(&mut h.app, &json!({"hole_snaps": true, "hole_moves": false})).unwrap(); + h.step(&json!({"start": "solid.hole"})).unwrap(); + let at = off(&mut h, MIDDLE, 5.0, -4.0); + h.step(&json!({"click": at})).unwrap(); + assert!(matches!(places(&h).as_slice(), [p] if p.dist(MIDDLE) < 1e-9), "{:?}", places(&h)); + h.frames(2); + assert!(handle_at("hole:0").is_none(), "no drag handle"); + let at = screen(&mut h, elsewhere); + h.step(&json!({"click": at})).unwrap(); + assert!(places(&h).is_empty(), "{:?}", places(&h)); +} + +#[test] +fn the_dialog_shows_the_sizes_beside_a_section_of_the_hole() { + let mut h = block(); + h.step(&json!({"start": "solid.hole"})).unwrap(); + h.frames(3); + let section = rect_of("dialog:figure_picture:hole section").expect("the section is drawn"); + let at = |name: &str| handle_at(&format!("dialog:{name}")).unwrap_or_else(|| panic!("no field {name}")); + // A drilled hole to a depth: depth, diameter and tip angle, top to bottom, right of it. + let (depth, dia, tip) = (at("Depth"), at("Diameter"), at("Tip Angle")); + assert!(depth.y < dia.y && dia.y < tip.y, "{depth:?} {dia:?} {tip:?}"); + assert!([depth, dia, tip].iter().all(|p| p.x > section.right() && p.y > section.top() && p.y < section.bottom())); + // A counterbore: its diameter and depth come first. + if let Some(Kind::Hole { kind, .. }) = h.app.dialog.as_mut().map(|d| &mut d.kind) { + *kind = 1; + } + h.frames(3); + let (cb, cbd, depth, dia) = (at("Counterbore Ø"), at("Counterbore depth"), at("Depth"), at("Diameter")); + assert!(cb.y < cbd.y && cbd.y < depth.y && depth.y < dia.y, "{cb:?} {cbd:?} {depth:?} {dia:?}"); + assert!(handle_at("dialog:Tip Angle").is_none(), "no tip on a counterbore"); + // Through all: no depth. + if let Some(Kind::Hole { opts, .. }) = h.app.dialog.as_mut().map(|d| &mut d.kind) { + opts.all = true; + } + h.frames(3); + assert!(handle_at("dialog:Depth").is_none()); + // The depth field still takes typing (scenarios click it by its name). + if let Some(Kind::Hole { opts, kind, .. }) = h.app.dialog.as_mut().map(|d| &mut d.kind) { + opts.all = false; + *kind = 0; + } + h.frames(3); + h.step(&json!({"click": {"handle": "dialog:Depth"}})).unwrap(); + h.step(&json!({"key": "A", "cmd": true})).unwrap(); + h.step(&json!({"text": "7 mm"})).unwrap(); + h.frames(2); + assert!(matches!(&h.app.dialog.as_ref().unwrap().kind, Kind::Hole { depth, .. } if depth == "7 mm")); +} diff --git a/crates/ui-egui/src/hole_refs.rs b/crates/ui-egui/src/hole_refs.rs new file mode 100644 index 0000000000..876a3f419e --- /dev/null +++ b/crates/ui-egui/src/hole_refs.rs @@ -0,0 +1,352 @@ +//! A Single hole placed by distances from reference edges (#37). After the face pick the Hole +//! dialog takes up to two straight edges of that face (Reference 1 and 2), each with the hole +//! centre's distance from it (Distance 1 and 2). Picking an edge measures its distance from where +//! the hole is; typing a distance (in the dialog or in its box on the canvas) moves the hole +//! there; moving the hole (another pick, or dragging its handle) measures the distances again. +//! The canvas shows each distance as a dimension from its edge to the hole's centre. OK sends the +//! edges and distances as `solid.hole`'s `references`, so the hole follows its edges when the +//! part changes; Edit Feature picks the edges again where they are now. + +use egui::{Color32, Pos2, Stroke, vec2}; +use serde_json::{Value, json}; +use solidcraft_engine::doc::HoleReference; +use solidcraft_engine::doc::expr::Kind as ValueKind; +use solidcraft_engine::doc::hole_reference::{ReferenceFrame, on_reference_face, reference_frame}; +use solidcraft_engine::geom::Vec3; +use solidcraft_engine::{Sel, Session}; + +use crate::SolveApp; +use crate::dialogs::{Dialog, Kind}; +use crate::selection::{EDGES, SelInput}; +use crate::theme::Tokens; +use crate::viewport::Proj; + +/// The reference edge inputs, after the face input. +pub(crate) const REFERENCES: [&str; 2] = ["Reference 1", "Reference 2"]; +/// Each reference edge's distance row (and its box on the canvas). +pub(crate) const DISTANCES: [&str; 2] = ["Distance 1", "Distance 2"]; + +/// The distances typed for the reference edges, what they were last worked out for, the face's +/// frame for the picked edges, and the distance whose canvas box takes the keyboard next (a +/// newly picked edge's, so a number typed straight after the pick sets it). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Refs { + pub distance: [String; 2], + seen: Option, + frame: Option, + focus: Option, +} + +/// The place, edges and distances the dialog last had in step. +#[derive(Clone, Debug, PartialEq)] +struct Seen { + place: Vec3, + edges: [Option; 2], + distance: [String; 2], +} + +/// The face's frame (with the reference inputs its lines are for) and what it was found for: the +/// design's revision, the place and the edges. +#[derive(Clone, Debug, PartialEq)] +struct Framed { + revision: u64, + place: Vec3, + edges: [Option; 2], + frame: Option<(ReferenceFrame, Vec)>, +} + +/// The reference edge inputs a Single hole's dialog has after its face input. +pub(crate) fn inputs() -> [SelInput; 2] { + REFERENCES.map(|label| SelInput::new(label, EDGES, false)) +} + +/// Is `d` a Single hole's dialog? +fn single(d: &Dialog) -> bool { + matches!(&d.kind, Kind::Hole { opts, .. } if !opts.multiple) +} + +/// The hole's place: the first face pick (its index in the face input, and the point). +fn place(d: &Dialog) -> Option<(usize, Vec3)> { + d.inputs.first()?.items.iter().enumerate().find_map(|(i, x)| match x { + Sel::Face { point, .. } => Some((i, *point)), + _ => None, + }) +} + +/// A point on each reference edge picked, by input. +fn edges(d: &Dialog) -> [Option; 2] { + REFERENCES.map(|label| { + d.inputs.iter().find(|i| i.label == label).and_then(|i| i.items.first()).and_then(|x| match x { + Sel::Edge { point, .. } => Some(*point), + _ => None, + }) + }) +} + +/// A point `[x, y, z]` of a dialog's stored parameters. +fn vec3(v: &Value) -> Option { + let a = v.as_array()?; + let c = |i: usize| a.get(i).and_then(Value::as_f64).filter(|x| x.is_finite()); + (a.len() == 3).then(|| Some(Vec3::new(c(0)?, c(1)?, c(2)?))).flatten() +} + +/// The way the hole drills at `at`: as it was made (editing), else into the face there. +fn direction(s: &Session, d: &Dialog, at: Vec3) -> Option { + d.extra.get("direction").and_then(vec3).or_else(|| solidcraft_engine::doc::hole_direction_at(&s.world_state(), at)) +} + +/// The face's frame at the place for the picked edges (and which reference inputs they are), found +/// again only when the design, the place or the edges change. +fn frame(s: &Session, d: &mut Dialog) -> Option<(ReferenceFrame, Vec)> { + let (_, at) = place(d)?; + let edges = edges(d); + if edges.iter().all(Option::is_none) { + return None; + } + let Kind::Hole { opts, .. } = &d.kind else { return None }; + if let Some(f) = &opts.refs.frame + && f.revision == s.revision + && f.place == at + && f.edges == edges + { + return f.frame.clone(); + } + let picked: Vec<(usize, Vec3)> = edges.iter().enumerate().filter_map(|(k, e)| e.map(|p| (k, p))).collect(); + let found = direction(s, d, at).and_then(|dir| { + let pts: Vec<(Vec3, Option<&str>)> = picked.iter().map(|(_, p)| (*p, None)).collect(); + reference_frame(&s.world_state(), at, dir, &pts, true).ok().map(|f| (f, picked.iter().map(|(k, _)| *k).collect())) + }); + if let Kind::Hole { opts, .. } = &mut d.kind { + opts.refs.frame = Some(Framed { revision: s.revision, place: at, edges, frame: found.clone() }); + } + found +} + +/// Keep the distances and the hole's place in step: a new place or a new edge is measured, a +/// typed distance moves the place there (when it stays on the face). Run before the dialog's +/// commands are made. +pub(crate) fn sync(s: &Session, d: &mut Dialog) { + if !single(d) { + return; + } + let edges = edges(d); + let Some((i, at)) = place(d) else { + if let Kind::Hole { opts, .. } = &mut d.kind { + opts.refs.seen = None; + } + return; + }; + let framed = frame(s, d); + let Kind::Hole { opts, .. } = &mut d.kind else { return }; + let refs = &mut opts.refs; + let mut to = at; + if let Some((f, picked)) = &framed { + let same = refs.seen.as_ref().filter(|sn| sn.place == at && sn.edges == edges); + match same { + // Typed: the hole goes to the distances. + Some(sn) if sn.distance != refs.distance => { + let values: Option> = + picked.iter().map(|k| refs.distance.get(*k).and_then(|e| s.doc.eval(e, ValueKind::Length).ok())).collect(); + if let Some(c) = values.and_then(|v| f.place(at, &v).ok()).filter(|c| on_reference_face(&s.world_state(), f, *c)) { + to = c; + } + } + Some(_) => {} + // A new place, or a new edge: measured from where the hole is. + None => { + let moved = refs.seen.as_ref().is_none_or(|sn| sn.place != at); + for (k, v) in picked.iter().zip(f.distances(at)) { + let new_edge = refs.seen.as_ref().is_none_or(|sn| sn.edges.get(*k) != edges.get(*k)); + if let Some(slot) = refs.distance.get_mut(*k) + && (moved || new_edge || slot.trim().is_empty()) + { + *slot = crate::canvas::format_len(v.max(0.0)); + } + if new_edge && edges.get(*k).is_some_and(Option::is_some) { + refs.focus = Some(*k); + } + } + } + } + } + refs.seen = Some(Seen { place: to, edges, distance: refs.distance.clone() }); + if to != at + && let Some(Sel::Face { point, .. }) = d.inputs.first_mut().and_then(|inp| inp.items.get_mut(i)) + { + *point = to; + } +} + +/// The Single hole's selection rows, each reference edge with its distance on the row below it +/// once it is picked. Whether Enter was pressed in a distance. +pub(crate) fn input_rows(d: &mut Dialog, ui: &mut egui::Ui, s: &Session) -> bool { + let mut enter = false; + for i in 0..d.inputs.len() { + crate::dialogs::input_rows_range(d, ui, s, i..i + 1); + let Some(k) = d.inputs.get(i).filter(|inp| !inp.items.is_empty()).and_then(|inp| REFERENCES.iter().position(|l| *l == inp.label)) else { + continue; + }; + if let Kind::Hole { opts, .. } = &mut d.kind + && let (Some(label), Some(value)) = (DISTANCES.get(k), opts.refs.distance.get_mut(k)) + { + enter |= crate::ui_kit::expr_row(ui, label, value); + } + } + sync(s, d); + enter +} + +/// The `references` OK sends: each picked edge (a point on it) with its distance. +pub(crate) fn params(d: &Dialog) -> Vec { + let Kind::Hole { opts, .. } = &d.kind else { return Vec::new() }; + if opts.multiple { + return Vec::new(); + } + edges(d) + .iter() + .zip(&opts.refs.distance) + .filter_map(|(e, distance)| e.map(|p| json!({"edge": [p.x, p.y, p.z], "distance": distance.trim()}))) + .collect() +} + +/// Editing a hole with reference edges: the edges picked again where they are now, their +/// distances, and the place where those distances put the hole now. +pub(crate) fn restore(s: &Session, d: &mut Dialog, references: &[HoleReference], names: &[String]) { + if references.is_empty() { + return; + } + let Some((i, at)) = place(d) else { return }; + let st = s.world_state(); + let edges: Vec<(Vec3, Option<&str>)> = references.iter().enumerate().map(|(k, r)| (r.edge.0, names.get(k).map(String::as_str))).collect(); + let found = direction(s, d, at).and_then(|dir| reference_frame(&st, at, dir, &edges, false).ok()); + let mesh = found.as_ref().and_then(|f| st.body(&f.body)).map(|b| b.mesh()); + for (k, r) in references.iter().enumerate().take(REFERENCES.len()) { + // Where the edge is now, else where it was picked. + let sel = found.as_ref().zip(mesh.as_ref()).and_then(|(f, m)| { + let index = *f.edges.get(k)?; + let point = crate::selection::polyline_mid(m.edges.get(index)?); + Some(Sel::Edge { body: f.body.clone(), index, point }) + }); + let sel = sel.or_else(|| nearest_edge(s, at, r.edge.0)); + if let (Some(label), Some(sel)) = (REFERENCES.get(k), sel) + && let Some(inp) = d.inputs.iter_mut().find(|inp| inp.label == *label) + { + inp.items = vec![sel]; + } + if let Kind::Hole { opts, .. } = &mut d.kind + && let Some(slot) = opts.refs.distance.get_mut(k) + { + *slot = r.distance.to_string(); + } + } + // The hole where its distances put it now (the part may have changed since it was placed). + let mut to = at; + if let Some(f) = &found + && let Kind::Hole { opts, .. } = &d.kind + { + let values: Option> = opts.refs.distance.iter().take(f.lines.len()).map(|e| s.doc.eval(e, ValueKind::Length).ok()).collect(); + if let Some(c) = values.and_then(|v| f.place(at, &v).ok()).filter(|c| on_reference_face(&st, f, *c)) { + to = c; + } + } + if let Some(Sel::Face { point, .. }) = d.inputs.first_mut().and_then(|inp| inp.items.get_mut(i)) { + *point = to; + } + let edges = self::edges(d); + if let Kind::Hole { opts, .. } = &mut d.kind { + opts.refs.seen = Some(Seen { place: to, edges, distance: opts.refs.distance.clone() }); + } +} + +/// The edge of the body under `at` nearest `p`, picked at `p` (a reference edge that is not +/// found as it was made: OK then reports why). +fn nearest_edge(s: &Session, at: Vec3, p: Vec3) -> Option { + let Some(Sel::Face { body, .. }) = crate::dialogs::face_sel(s, at) else { return None }; + let st = s.world_state(); + let m = st.body(&body)?.mesh(); + let to = |e: &Vec| e.windows(2).map(|w| p.dist_to_segment(w[0], w[1])).fold(f64::INFINITY, f64::min); + let index = m.edges.iter().enumerate().map(|(i, e)| (to(e), i)).filter(|(d, _)| d.is_finite()).min_by(|a, b| a.0.total_cmp(&b.0))?.1; + Some(Sel::Edge { body, index, point: p }) +} + +/// The reference distances on the canvas: a dimension from each edge to the hole's centre, and a +/// box beside it to type the distance in. Whether a box took the keyboard (a newly picked edge's), +/// which the dialog's main value then leaves to it. +pub(crate) fn canvas(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, d: &mut Dialog) -> bool { + if !single(d) { + return false; + } + let Some((_, at)) = place(d) else { return false }; + let Some((f, picked)) = frame(&app.session, d) else { return false }; + let mut took = false; + let t = Tokens::get(); + let px = 2.0 * app.cam.half_height() / f64::from(proj.rect.height().max(1.0)); + let centre = f.onto_plane(at); + for (line, k) in f.lines.iter().zip(&picked) { + let foot = line.foot(centre); + let (Some(a), Some(b)) = (proj.to_screen(foot), proj.to_screen(centre)) else { continue }; + // The reference edge's line on either side of where the dimension leaves it. + let reach = line.along * (14.0 * px); + if let (Some(e0), Some(e1)) = (proj.to_screen(foot - reach), proj.to_screen(foot + reach)) { + painter.line_segment([e0, e1], Stroke::new(1.2, t.dimension)); + } + dimension(painter, a, b, t.dimension); + let (Some(label), Kind::Hole { opts, .. }) = (DISTANCES.get(*k), &mut d.kind) else { continue }; + let focus = opts.refs.focus == Some(*k); + if focus { + opts.refs.focus = None; + took = true; + } + let Some(value) = opts.refs.distance.get_mut(*k) else { continue }; + let at = box_beyond(a, b); + let r = crate::canvas::value_box_in( + ui, + (egui::Id::new(("sc_hole_ref_area", *k)), egui::Id::new(("sc_hole_ref", *k))), + label, + value, + at, + focus, + false, + ); + if let Some(r) = r { + crate::scenario::publish_handle(&format!("canvas:{label}"), r.center()); + } + } + took +} + +/// Where a distance's box goes: just past its edge (at `a`, the hole's centre at `b`), on the side +/// away from the hole, so it stays clear of the hole's own value box at its centre. The box's side +/// toward the edge sits there. +fn box_beyond(a: Pos2, b: Pos2) -> (Pos2, egui::Align2) { + const GAP: f32 = 10.0; + let v = a - b; + let out = if v.length() < 1.0 { vec2(1.0, 0.0) } else { v / v.length() }; + if out.x.abs() >= out.y.abs() { + if out.x > 0.0 { (a + vec2(GAP, 0.0), egui::Align2::LEFT_CENTER) } else { (a - vec2(GAP, 0.0), egui::Align2::RIGHT_CENTER) } + } else if out.y > 0.0 { + (a + vec2(0.0, GAP), egui::Align2::CENTER_TOP) + } else { + (a - vec2(0.0, GAP), egui::Align2::CENTER_BOTTOM) + } +} + +/// A dimension line from `a` to `b`, an arrowhead at each end. +fn dimension(painter: &egui::Painter, a: Pos2, b: Pos2, col: Color32) { + let v = b - a; + let l = v.length(); + if l < 1.0 { + return; + } + let u = v / l; + let n = vec2(-u.y, u.x); + painter.line_segment([a, b], Stroke::new(1.2, col)); + let head = 8.0f32.min(l * 0.4); + for (tip, out) in [(a, -u), (b, u)] { + painter.add(egui::Shape::convex_polygon( + vec![tip, tip - out * head + n * head * 0.35, tip - out * head - n * head * 0.35], + col, + Stroke::NONE, + )); + } +} diff --git a/crates/ui-egui/src/hole_refs_ui_tests.rs b/crates/ui-egui/src/hole_refs_ui_tests.rs new file mode 100644 index 0000000000..ab170e1b27 --- /dev/null +++ b/crates/ui-egui/src/hole_refs_ui_tests.rs @@ -0,0 +1,162 @@ +//! A Single hole placed by distances from reference edges, through the window (#37): pick the +//! face, pick two edges (each distance is measured from the pick), type the distances (one in +//! the dialog, one in its box on the canvas), OK. The hole sits at those distances; Edit Feature +//! picks the edges again with their distances, and moving the hole's place measures them again. + +use serde_json::json; +use solidcraft_engine::Sel; +use solidcraft_engine::doc::FeatureKind; +use solidcraft_engine::geom::Vec3; + +use crate::dialogs::Kind; +use crate::scenario::Harness; + +/// Where a world point is on screen. +fn screen(h: &mut Harness, p: Vec3) -> [f64; 2] { + h.at(&json!({"world": [p.x, p.y, p.z]})).unwrap() +} + +/// A 50 × 40 × 10 plate from the origin, seen from the top. +fn plate() -> Harness { + let mut h = Harness::new(); + h.app.ui.show_origin = false; + h.app.run("solid.box", json!({"length": 50, "width": 40, "height": 10, "corner": [0, 0, 0]})).unwrap(); + h.step(&json!({"call": "ui.view", "params": {"view": "top"}})).unwrap(); + h.settle(); + h +} + +/// The open Hole dialog's reference distances. +fn distances(h: &Harness) -> [String; 2] { + match h.app.dialog.as_ref().map(|d| &d.kind) { + Some(Kind::Hole { opts, .. }) => opts.refs.distance.clone(), + _ => panic!("the hole dialog is open"), + } +} + +/// Where the open dialog places the hole (its face pick). +fn place(h: &Harness) -> Vec3 { + match h.app.dialog.as_ref().and_then(|d| d.inputs.first()).and_then(|i| i.items.first()) { + Some(Sel::Face { point, .. }) => *point, + other => panic!("a face place, not {other:?}"), + } +} + +/// The edge picked into the open dialog's input `label`. +fn picked_edge(h: &Harness, label: &str) -> Option { + let d = h.app.dialog.as_ref()?; + match d.inputs.iter().find(|i| i.label == label)?.items.first()? { + Sel::Edge { point, .. } => Some(*point), + _ => None, + } +} + +/// Replace the text of the field at `handle` with `text`. +fn type_into(h: &mut Harness, handle: &str, text: &str) { + h.step(&json!({"click": {"handle": handle}})).unwrap(); + h.step(&json!({"key": "A", "cmd": true})).unwrap(); + h.step(&json!({"text": text})).unwrap(); + h.frames(2); +} + +/// Whether the model holds material at `p`. +fn solid_at(h: &Harness, p: Vec3) -> bool { + h.app.session.model.state().bodies.iter().any(|b| b.body.contains(p)) +} + +#[test] +fn a_hole_is_placed_by_its_distances_from_two_edges() { + let mut h = plate(); + h.step(&json!({"start": "solid.hole"})).unwrap(); + let at = screen(&mut h, Vec3::new(30.0, 25.0, 10.0)); + h.step(&json!({"click": at})).unwrap(); + assert!(place(&h).dist(Vec3::new(30.0, 25.0, 10.0)) < 0.5, "the face is picked where clicked: {:?}", place(&h)); + let picked = place(&h); + // The reference inputs come after the face; picking an edge measures its distance from the + // pick, and the second input takes the next edge. + h.step(&json!({"click": {"handle": "dialog:Reference 1"}})).unwrap(); + let left = screen(&mut h, Vec3::new(0.0, 20.0, 10.0)); + h.step(&json!({"click": left})).unwrap(); + let front = screen(&mut h, Vec3::new(25.0, 0.0, 10.0)); + h.step(&json!({"click": front})).unwrap(); + h.frames(2); + assert!(picked_edge(&h, "Reference 1").is_some_and(|p| p.x.abs() < 1e-6), "the left edge is Reference 1: {:?}", picked_edge(&h, "Reference 1")); + assert!(picked_edge(&h, "Reference 2").is_some_and(|p| p.y.abs() < 1e-6), "the front edge is Reference 2: {:?}", picked_edge(&h, "Reference 2")); + let measured = distances(&h); + let value = |s: &str| h.app.session.doc.eval(s, solidcraft_engine::doc::expr::Kind::Length).unwrap(); + assert!((value(&measured[0]) - picked.x).abs() < 1e-3 && (value(&measured[1]) - picked.y).abs() < 1e-3, "{measured:?} from {picked:?}"); + // The distances show on the canvas, each with a box to type in. + assert!(h.at(&json!({"handle": "canvas:Distance 1"})).is_ok() && h.at(&json!({"handle": "canvas:Distance 2"})).is_ok()); + // Typed in the dialog and on the canvas: the hole goes there. + type_into(&mut h, "dialog:Distance 1", "10 mm"); + type_into(&mut h, "canvas:Distance 2", "12 mm"); + assert_eq!(distances(&h), ["10 mm", "12 mm"]); + assert!(place(&h).dist(Vec3::new(10.0, 12.0, 10.0)) < 1e-6, "the hole moved to its distances: {:?}", place(&h)); + h.step(&json!({"widget": "OK"})).unwrap(); + assert!(h.app.dialog.is_none(), "OK closed the dialog: {:?}", h.app.dialog.as_ref().map(|d| &d.error)); + let f = h.app.session.doc.features.last().unwrap().clone(); + let FeatureKind::Hole { position, references, .. } = &f.kind else { panic!("the last feature is a hole: {}", f.kind.type_name()) }; + assert!(position.0.dist(Vec3::new(10.0, 12.0, 10.0)) < 1e-6, "{position:?}"); + assert_eq!(references.iter().map(|r| r.distance.to_string()).collect::>(), ["10 mm", "12 mm"]); + assert!(h.app.session.model.result(f.id).is_some_and(|r| r.error.is_none())); + assert!( + !solid_at(&h, Vec3::new(10.0, 12.0, 8.0)) && solid_at(&h, Vec3::new(30.0, 25.0, 8.0)), + "drilled at (10, 12), not where the face was picked" + ); + + // Edit Feature: the edges picked again with their distances, the hole where they put it. + h.step(&json!({"call": "ui.editFeature", "params": {"feature": f.id}})).unwrap(); + h.frames(4); + assert!(picked_edge(&h, "Reference 1").is_some_and(|p| p.x.abs() < 1e-6), "{:?}", picked_edge(&h, "Reference 1")); + assert!(picked_edge(&h, "Reference 2").is_some_and(|p| p.y.abs() < 1e-6), "{:?}", picked_edge(&h, "Reference 2")); + assert_eq!(distances(&h), ["10 mm", "12 mm"]); + assert!(place(&h).dist(Vec3::new(10.0, 12.0, 10.0)) < 1e-6, "{:?}", place(&h)); + // The hole moved on its face (a new pick, or its handle dragged): the distances follow. + if let Some(Sel::Face { point, .. }) = h.app.dialog.as_mut().and_then(|d| d.inputs.first_mut()).and_then(|i| i.items.first_mut()) { + *point = Vec3::new(20.0, 30.0, 10.0); + } + h.frames(2); + assert_eq!(distances(&h), ["20 mm", "30 mm"]); + h.step(&json!({"widget": "OK"})).unwrap(); + assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog.as_ref().map(|d| &d.error)); + assert!(!solid_at(&h, Vec3::new(20.0, 30.0, 8.0)) && solid_at(&h, Vec3::new(10.0, 12.0, 8.0)), "the edit moved the hole to (20, 30)"); +} + +/// Reference edges place one hole: with places on two faces, OK says to pick one. +#[test] +fn reference_edges_place_one_hole() { + let mut h = plate(); + h.step(&json!({"start": "solid.hole"})).unwrap(); + let at = screen(&mut h, Vec3::new(30.0, 25.0, 10.0)); + h.step(&json!({"click": at})).unwrap(); + // A second hole on another face (the bottom), as a pick there would place it. + let d = h.app.dialog.as_mut().unwrap(); + let Some(Sel::Face { body, index, .. }) = d.inputs[0].items.first().cloned() else { panic!("a face place") }; + d.inputs[0].items.push(Sel::Face { body, index: index + 1, point: Vec3::new(40.0, 10.0, 0.0) }); + h.step(&json!({"click": {"handle": "dialog:Reference 1"}})).unwrap(); + let left = screen(&mut h, Vec3::new(0.0, 20.0, 10.0)); + h.step(&json!({"click": left})).unwrap(); + h.frames(2); + let d = h.app.dialog.as_ref().unwrap(); + let err = crate::dialogs::apply_commands(&h.app, d).unwrap_err(); + assert!(err.contains("one hole"), "{err}"); +} + +/// A number typed straight after an edge pick is that edge's distance, not the diameter. +#[test] +fn a_number_typed_after_an_edge_pick_sets_its_distance() { + let mut h = plate(); + h.step(&json!({"start": "solid.hole"})).unwrap(); + let at = screen(&mut h, Vec3::new(30.0, 25.0, 10.0)); + h.step(&json!({"click": at})).unwrap(); + h.step(&json!({"click": {"handle": "dialog:Reference 1"}})).unwrap(); + let left = screen(&mut h, Vec3::new(0.0, 20.0, 10.0)); + h.step(&json!({"click": left})).unwrap(); + h.frames(2); + h.step(&json!({"text": "7 mm"})).unwrap(); + h.frames(2); + assert_eq!(distances(&h)[0], "7 mm"); + assert!((place(&h).x - 7.0).abs() < 1e-6, "the hole is 7 mm from the left edge: {:?}", place(&h)); + let Some(Kind::Hole { diameter, .. }) = h.app.dialog.as_ref().map(|d| &d.kind) else { panic!("the hole dialog") }; + assert_eq!(diameter, "5 mm", "the diameter is left as it was"); +} diff --git a/crates/ui-egui/src/i18n_ui_tests.rs b/crates/ui-egui/src/i18n_ui_tests.rs index 952d90d246..2f05ab72ab 100644 --- a/crates/ui-egui/src/i18n_ui_tests.rs +++ b/crates/ui-egui/src/i18n_ui_tests.rs @@ -243,8 +243,10 @@ fn finish_exit_label_and_its_button_are_centred() { h.app.run("sketch.create", json!({"plane":"xy"})).unwrap(); h.frames(2); let label = scenario::handles("panel:SKETCH:FINISH SKETCH")[0].1; - let button = scenario::handles("button:sketch.finish")[0].1; - assert!((label.x - button.x).abs() < 1.0, "the only button sits centred over its label: {label:?} {button:?}"); + // Finish and Cancel Sketch (#3838) sit side by side, the pair centred over the label. + let finish = scenario::handles("button:sketch.finish")[0].1; + let cancel = scenario::handles("button:sketch.cancel")[0].1; + assert!(finish.x < label.x && label.x < cancel.x, "the label is centred under the pair, Finish first: {label:?} {finish:?} {cancel:?}"); let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0)); let mut out = diff --git a/crates/ui-egui/src/icon_svg.rs b/crates/ui-egui/src/icon_svg.rs index e7063768ab..05552ea0dc 100644 --- a/crates/ui-egui/src/icon_svg.rs +++ b/crates/ui-egui/src/icon_svg.rs @@ -8,7 +8,7 @@ //! (icon, accent, theme, pixel size). A master never carries a colour of its own: it names a //! palette role, which resolves to the theme's hex, or to the accent's hex for the `active-*` //! roles. GAP instead erases preceding artwork to transparency; it never paints a background. -//! Changing a colour is an edit to `palette.toml` only. +//! `palette.toml` owns defaults; theme files may override display roles and semantic ramps. //! //! Nothing here panics on bad artwork: a master that does not parse is logged and drawn as the //! placeholder, and `every_master_parses` holds that none do. @@ -431,6 +431,37 @@ impl Doc { // --------------------------------------------------------------------------------------------- // Rendering +/// Theme-file overrides for stable SVG roles and semantic active-face ramps. +/// Fixed-size storage keeps Tokens/Look copyable and bounds imported palette size. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct Palette { + pub(crate) roles: [Option<[u8; 3]>; ROLE_NAMES.len()], + pub(crate) accents: [Option<[[u8; 3]; 3]>; ACCENT_NAMES.len() + 1], +} + +impl Palette { + fn rgb(self, role: usize, accent: Option) -> Option<[u8; 3]> { + let name = ROLE_NAMES.get(role)?; + let face = match *name { + "active-1" => Some(0), + "active-2" => Some(1), + "active-3" => Some(2), + _ => None, + }; + if let Some(face) = face { + let ramp = self.accents.get(accent.map_or(0, |a| a.saturating_add(1))).copied().flatten(); + if let Some(rgb) = ramp.and_then(|r| r.get(face).copied()) { + return Some(rgb); + } + // A base ramp never overwrites a semantic accent that the theme omitted. + if accent.is_some() { + return None; + } + } + self.roles.get(role).copied().flatten() + } +} + /// The colours of one drawing: the theme and an accent (index into `ACCENT_NAMES`, or `None` for /// the base palette). #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] @@ -438,14 +469,16 @@ pub struct Look { pub dark: bool, pub accent: Option, pub vision: crate::theme::ColorVision, + pub palette: Option, } impl Look { + pub fn themed(dark: bool, accent: Option, vision: crate::theme::ColorVision) -> Self { + Self { dark, accent, vision, palette: crate::theme::Tokens::themed(dark, vision).icon_palette } + } + fn rgb(self, role: usize) -> [u8; 3] { let theme = usize::from(self.dark); - if ROLE_NAMES.get(role) == Some(&"guide") { - return PALETTE.get(theme).and_then(|p| p.get(role)).copied().unwrap_or([255, 0, 255]); - } let vision = match self.vision { crate::theme::ColorVision::Off => "", crate::theme::ColorVision::Protan | crate::theme::ColorVision::Deutan => "red-green", @@ -454,6 +487,12 @@ impl Look { if let Some((_, _, _, l, d)) = ACCENT_VARIANTS.iter().find(|(v, a, r, _, _)| *v == vision && Some(*a) == self.accent && *r == role) { return if self.dark { *d } else { *l }; } + if let Some(rgb) = self.palette.and_then(|p| p.rgb(role, self.accent)) { + return rgb; + } + if ROLE_NAMES.get(role) == Some(&"guide") { + return PALETTE.get(theme).and_then(|p| p.get(role)).copied().unwrap_or([255, 0, 255]); + } if let Some((_, l, d)) = self.accent.and_then(|a| ACCENTS.get(a)).and_then(|items| items.iter().find(|(r, _, _)| *r == role)) { return if self.dark { *d } else { *l }; } @@ -629,7 +668,7 @@ fn default_accent(name: &str) -> Option { /// Primary semantic stroke, also used by toolbar affordances. The palette owns its value. pub(crate) fn primary(name: &str, dark: bool, vision: crate::theme::ColorVision) -> egui::Color32 { - let look = Look { dark, accent: resolve(name).accent, vision }; + let look = Look::themed(dark, resolve(name).accent, vision); role_colour("active-3", look) } @@ -719,7 +758,7 @@ mod tests { assert_eq!(blue.stroke, Paint::Role(active)); for dark in [false, true] { for accent in std::iter::once(None).chain((0..ACCENT_NAMES.len()).map(Some)) { - let look = Look { dark, accent, vision: crate::theme::ColorVision::Off }; + let look = Look { dark, accent, vision: crate::theme::ColorVision::Off, palette: None }; assert_eq!(look.rgb(guide), PALETTE[usize::from(dark)][guide]); let pm = render(&doc, look, 64).unwrap(); assert_eq!([pm.pixel(9, 20).unwrap().red(), pm.pixel(9, 20).unwrap().green(), pm.pixel(9, 20).unwrap().blue()], look.rgb(guide)); @@ -733,7 +772,7 @@ mod tests { let doc = Doc::parse("").unwrap(); for dark in [false, true] { for accent in std::iter::once(None).chain((0..ACCENT_NAMES.len()).map(Some)) { - let look = Look { dark, accent, vision: crate::theme::ColorVision::Off }; + let look = Look { dark, accent, vision: crate::theme::ColorVision::Off, palette: None }; let pm = render(&doc, look, 64).unwrap(); assert_eq!(pm.pixel(26, 20).unwrap().alpha(), 0, "fill must reveal the real background"); assert_eq!(pm.pixel(20, 44).unwrap().alpha(), 0, "stroke must reveal the real background"); @@ -747,7 +786,7 @@ mod tests { #[test] fn knockout_respects_its_clip_and_combines_explicit_masks_with_clips() { let doc = Doc::parse("").unwrap(); - let pm = render(&doc, Look { dark: false, accent: None, vision: crate::theme::ColorVision::Off }, 64).unwrap(); + let pm = render(&doc, Look { dark: false, accent: None, vision: crate::theme::ColorVision::Off, palette: None }, 64).unwrap(); assert_eq!(pm.pixel(24, 32).unwrap().alpha(), 0); assert_eq!(pm.pixel(40, 32).unwrap().alpha(), 255, "cutout must not escape its parent clip"); } @@ -755,7 +794,7 @@ mod tests { #[test] fn accepted_badges_and_environment_sun_have_transparent_holes() { for dark in [false, true] { - let look = Look { dark, accent: None, vision: crate::theme::ColorVision::Off }; + let look = Look { dark, accent: None, vision: crate::theme::ColorVision::Off, palette: None }; assert_eq!(role_colour("gap", look), egui::Color32::TRANSPARENT); for (id, x, y) in [("contact_new", 48, 48), ("environment_map", 45, 19), ("line_mid", 35, 28)] { let pm = render(doc_for(id, 64).unwrap(), look, 64).unwrap(); @@ -768,7 +807,7 @@ mod tests { fn native_clip_paths_and_plus_mask_keep_the_artwork_inside_its_shape() { for dark in [false, true] { for px in [16, 24, 64] { - let look = Look { dark, accent: None, vision: crate::theme::ColorVision::Off }; + let look = Look { dark, accent: None, vision: crate::theme::ColorVision::Off, palette: None }; for id in ["environment_map", "curvature_map", "iso_analysis", "zebra"] { let doc = doc_for(id, px).unwrap(); let pm = render(doc, look, px).unwrap(); @@ -776,7 +815,8 @@ mod tests { assert!(pm.pixel(px / 2, px / 2).unwrap().alpha() > 0, "{id} lost its clipped contents"); } } - let pm = render(doc_for("new", 64).unwrap(), Look { dark, accent: None, vision: crate::theme::ColorVision::Off }, 64).unwrap(); + let pm = + render(doc_for("new", 64).unwrap(), Look { dark, accent: None, vision: crate::theme::ColorVision::Off, palette: None }, 64).unwrap(); assert_eq!(pm.pixel(45, 45).unwrap().alpha(), 0, "the plus is genuinely punched out in the native renderer"); } } @@ -808,6 +848,40 @@ mod tests { } } + #[test] + fn shipped_icon_palettes_preserve_alpha_shading_and_colour_vision_overrides() { + for file in crate::theme_files::builtin() { + let tokens = crate::theme_files::resolve(file); + let palette = tokens.icon_palette.expect("shipped icon palette"); + for ramp in palette.accents.into_iter().flatten() { + // Ordered faces stay visibly distinct without changing accepted paths. + assert_ne!(ramp[0], ramp[1], "{} pale/medium", file.id); + assert_ne!(ramp[1], ramp[2], "{} medium/strong", file.id); + } + for vision in crate::theme::ColorVision::ALL { + for name in ["extrude", "surface_extrude", "mesh_export", "line", "c_parallel", "construction"] { + let resolved = resolve(name); + let plain = Look { dark: file.base.is_dark(), accent: resolved.accent, vision, palette: None }; + let custom = Look { palette: Some(palette), ..plain }; + let doc = doc_for(resolved.master, 32).unwrap(); + let before = render(doc, plain, 32).unwrap(); + let after = render(doc, custom, 32).unwrap(); + assert!(before.pixels().iter().zip(after.pixels()).all(|(a, b)| a.alpha() == b.alpha()), "{} {name} alpha", file.id); + for (variant, accent, role, light, dark) in ACCENT_VARIANTS { + let selected = match vision { + crate::theme::ColorVision::Off => "", + crate::theme::ColorVision::Protan | crate::theme::ColorVision::Deutan => "red-green", + crate::theme::ColorVision::Tritan => "blue-yellow", + }; + if *variant == selected && Some(*accent) == custom.accent { + assert_eq!(custom.rgb(*role), if file.base.is_dark() { *dark } else { *light }); + } + } + } + } + } + } + #[test] fn semantic_ui_tokens_and_icon_strokes_share_the_palette_including_accessibility() { for dark in [false, true] { @@ -828,7 +902,7 @@ mod tests { let resolved = resolve(id); for dark in [false, true] { for vision in ColorVision::ALL { - let look = Look { dark, accent: resolved.accent, vision }; + let look = Look { dark, accent: resolved.accent, vision, palette: None }; let base = Look { accent: None, ..look }; for role in ["ink", "line", "passive-1", "passive-2", "passive-3", "gap"] { let role = ROLE_NAMES.iter().position(|name| *name == role).unwrap(); @@ -898,7 +972,7 @@ mod tests { let source = SVGS.iter().find(|(id, fam, _)| *id == "folder" && *fam == "model").unwrap().2; let expected = Doc::parse(source).unwrap(); for dark in [false, true] { - let look = Look { dark, accent: None, vision: crate::theme::ColorVision::Off }; + let look = Look { dark, accent: None, vision: crate::theme::ColorVision::Off, palette: None }; let actual = render(doc_for("folder", 64).unwrap(), look, 64).unwrap(); let reviewed = render(&expected, look, 64).unwrap(); assert_eq!(actual.data(), reviewed.data()); @@ -922,7 +996,7 @@ mod tests { for dark in [false, true] { for px in [16, 20, 26, 64] { let Some(doc) = doc_for(id, px) else { panic!("{id}") }; - let pm = render(doc, Look { dark, accent: None, vision: crate::theme::ColorVision::Off }, px) + let pm = render(doc, Look { dark, accent: None, vision: crate::theme::ColorVision::Off, palette: None }, px) .unwrap_or_else(|| panic!("{id} at {px}")); assert!(coverage(&pm) > 8, "{id} at {px}px (dark {dark}) is blank"); } @@ -937,13 +1011,15 @@ mod tests { #[test] fn dark_theme_and_accents_change_colour_not_shape() { let doc = doc_for("extrude", 64).unwrap_or_else(|| panic!("extrude")); - let light = render(doc, Look { dark: false, accent: None, vision: crate::theme::ColorVision::Off }, 64).unwrap_or_else(|| panic!("render")); - let dark = render(doc, Look { dark: true, accent: None, vision: crate::theme::ColorVision::Off }, 64).unwrap_or_else(|| panic!("render")); + let light = render(doc, Look { dark: false, accent: None, vision: crate::theme::ColorVision::Off, palette: None }, 64) + .unwrap_or_else(|| panic!("render")); + let dark = render(doc, Look { dark: true, accent: None, vision: crate::theme::ColorVision::Off, palette: None }, 64) + .unwrap_or_else(|| panic!("render")); assert_ne!(rgba(&light), rgba(&dark)); let r = resolve("surface_extrude"); assert_eq!(r.master, "extrude"); - let surface = - render(doc, Look { dark: false, accent: r.accent, vision: crate::theme::ColorVision::Off }, 64).unwrap_or_else(|| panic!("render")); + let surface = render(doc, Look { dark: false, accent: r.accent, vision: crate::theme::ColorVision::Off, palette: None }, 64) + .unwrap_or_else(|| panic!("render")); assert!(r.accent.is_some()); assert_ne!(rgba(&light), rgba(&surface)); // Same silhouette: the alpha channel is identical. @@ -987,7 +1063,7 @@ mod tests { fn danger_tint_preserves_faces_outlines_and_transparent_details() { for dark in [false, true] { for vision in crate::theme::ColorVision::ALL { - let look = Look { dark, accent: accent_index("constraint"), vision }; + let look = Look { dark, accent: accent_index("constraint"), vision, palette: None }; for id in ["delete", "new", "extrude"] { for px in [16, 20, 64] { let doc = doc_for(id, px).unwrap(); diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index b801fed033..05715320ba 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -1,8 +1,9 @@ //! Stable UI rendering boundary for the icon artwork. Compiled only at L5. //! //! Command, menu, browser and timeline icons are the SVG masters in `assets/icons/ui/design/svg`, -//! rendered by [`crate::icon_svg`]: colours come from `palette.toml` (light and dark, and the -//! accents), never from the caller. An icon with no master yet draws the placeholder. +//! rendered by [`crate::icon_svg`]: colours come from the default `palette.toml` roles/accents +//! and optional active-theme overrides, never arbitrary caller tints. An icon with no master +//! yet draws the placeholder. //! The glyph, marker, navigation and pointer sources below are not command icons. use std::collections::HashMap; use std::sync::{Arc, Mutex}; @@ -29,7 +30,13 @@ enum Finish { /// Textures already rasterised: one per (master, look, pixel size, finish). #[derive(Clone, Default)] -struct Cache(Arc>>); +struct Cache(Arc>); + +#[derive(Default)] +struct CacheEntries { + palette: Option, + textures: HashMap<(&'static str, Look, u32, Finish), TextureHandle>, +} /// Draw palette-owned icon artwork with an explicit opacity (0 to 1). pub fn paint_icon(p: &Painter, r: Rect, name: &str, opacity: f32) { @@ -72,7 +79,7 @@ enum Mirror { fn paint_with_ink(p: &Painter, r: Rect, name: &str, dark: Option, opacity: f32, ink: Option, mirror: Mirror) { let ctx = p.ctx(); let resolved = icon_svg::resolve(name); - let look = Look { dark: dark.unwrap_or_else(|| ctx.global_style().visuals.dark_mode), accent: resolved.accent, vision: crate::theme::vision() }; + let look = Look::themed(dark.unwrap_or_else(|| ctx.global_style().visuals.dark_mode), resolved.accent, crate::theme::vision()); paint_resolved(p, r, resolved, look, opacity, ink.map_or(Finish::Artwork, Finish::Ink), mirror); } @@ -85,7 +92,7 @@ pub fn paint_accented(p: &Painter, r: Rect, name: &str, accent: Option<&str>, op Some(name) => icon_svg::accent_index(name).or(resolved.accent), None => None, }; - let look = Look { dark: p.ctx().global_style().visuals.dark_mode, accent, vision: crate::theme::vision() }; + let look = Look::themed(p.ctx().global_style().visuals.dark_mode, accent, crate::theme::vision()); paint_resolved(p, r, resolved, look, opacity, Finish::Artwork, Mirror::None); } @@ -94,7 +101,7 @@ pub fn paint_accented(p: &Painter, r: Rect, name: &str, accent: Option<&str>, op /// the constraint ramp without flattening the lid, handle or other details. pub(crate) fn paint_danger(p: &Painter, r: Rect, name: &str, opacity: f32) { let resolved = icon_svg::resolve(name); - let look = Look { dark: p.ctx().global_style().visuals.dark_mode, accent: icon_svg::accent_index("constraint"), vision: crate::theme::vision() }; + let look = Look::themed(p.ctx().global_style().visuals.dark_mode, icon_svg::accent_index("constraint"), crate::theme::vision()); paint_resolved(p, r, resolved, look, opacity, Finish::Danger, Mirror::None); } @@ -104,7 +111,13 @@ fn paint_resolved(p: &Painter, r: Rect, resolved: icon_svg::Resolved, look: Look let side = r.width().min(r.height()); let px = (side * ppp).round().clamp(8.0, 512.0) as u32; let cache = ctx.data_mut(|d| d.get_temp_mut_or_insert_with(egui::Id::new("sc_icon_cache"), Cache::default).clone()); - let Ok(mut map) = cache.0.lock() else { return }; + let Ok(mut entries) = cache.0.lock() else { return }; + let palette = crate::theme::Tokens::get().icon_palette; + if entries.palette != palette { + entries.textures.clear(); + entries.palette = palette; + } + let map = &mut entries.textures; let key = (resolved.master, look, px, finish); if let std::collections::hash_map::Entry::Vacant(slot) = map.entry(key) { let Some(doc) = icon_svg::doc_for(resolved.master, px) else { return }; @@ -136,6 +149,62 @@ mod tests { use super::*; use crate::theme::{self, ColorVision}; + #[test] + fn theme_icon_palettes_change_uploaded_pixels_and_reuse_only_matching_textures() { + use serde_json::json; + let ctx = crate::fonts::test_context(); + let dark = theme::is_dark(); + ctx.set_visuals(if dark { egui::Visuals::dark() } else { egui::Visuals::light() }); + let mut previous_alpha = None; + let mut previous_texture = None; + for (stroke, expected) in [ + ("#994477", Color32::from_rgb(0x99, 0x44, 0x77)), + ("#226699", Color32::from_rgb(0x22, 0x66, 0x99)), + ("#994477", Color32::from_rgb(0x99, 0x44, 0x77)), + ] { + let file = crate::theme_files::from_value(&json!({"format":crate::theme_files::FORMAT, + "name":"Switch","base":if dark {"dark"} else {"light"}, + "icon_palette":{"solid":["#c0d0e0","#668899",stroke]}})) + .unwrap(); + let _restore = theme::scoped_tokens(crate::theme_files::resolve(&file)); + let draw = |ui: &mut egui::Ui| { + let r = Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(64.0, 64.0)); + paint_icon(ui.painter(), r, "extrude", 1.0); + // A forced-dark monochrome app-bar icon must not evict the theme palette. + paint_app_bar(ui.painter(), r.translate(egui::vec2(70.0, 0.0)), "save", 1.0); + }; + let mut out = ctx.run_ui(egui::RawInput::default(), draw); + let (id, image) = out + .textures_delta + .set + .iter() + .flat_map(|(id, deltas)| deltas.iter().map(move |d| (id, &d.image))) + .find_map(|(id, data)| match data { + egui::ImageData::Color(image) if image.size == [64, 64] && image.pixels.contains(&expected) => Some((*id, image)), + _ => None, + }) + .expect("the new theme's actual SVG texture"); + let alpha: Vec<_> = image.pixels.iter().map(|p| p.a()).collect(); + if let Some(old) = &previous_alpha { + assert_eq!(&alpha, old, "theme colours preserve artwork and cutouts"); + } + if let Some(old) = previous_texture { + assert_ne!(id, old, "switching a same-base theme uploads a new texture"); + } + previous_alpha = Some(alpha); + previous_texture = Some(id); + out.textures_delta.clear(); + let mut steady = ctx.run_ui(egui::RawInput::default(), draw); + assert!( + steady.textures_delta.set.values().flatten().all(|d| match &d.image { + egui::ImageData::Color(i) => i.size != [64, 64], + }), + "unchanged palette reuses cached textures" + ); + steady.textures_delta.clear(); + } + } + #[test] fn assistant_pixels_use_the_application_bar_text_token() { let previous = theme::is_dark(); @@ -254,7 +323,7 @@ mod tests { assert!(!output.textures_delta.set.contains_key(&texture)); } let image = textures.get(&texture).unwrap(); - let look = Look { dark, accent: expected_accent, vision }; + let look = Look::themed(dark, expected_accent, vision); for role in ["ink", "passive-2", "passive-3", "active-1", "active-2", "active-3"] { assert!(image.pixels.contains(&icon_svg::role_colour(role, look)), "{accent:?}/{role}, {dark}/{vision:?}"); } diff --git a/crates/ui-egui/src/inference.rs b/crates/ui-egui/src/inference.rs index 6620c93db4..6dc2d299f5 100644 --- a/crates/ui-egui/src/inference.rs +++ b/crates/ui-egui/src/inference.rs @@ -1,7 +1,8 @@ //! Drawing-time inference for the Line tool: while the next point is placed the //! cursor snaps to directions and places taken from the existing geometry (parallel or -//! perpendicular to a line, on the extension of a line, tangent to a circle or arc). A dashed -//! guide and a label show it; placing the point adds the matching constraint. +//! perpendicular to a line, on the extension of a line, tangent to a circle or arc, or through +//! the centre of a circle or arc). A dashed guide and a label show it; placing the point adds +//! the matching constraint. use std::cell::RefCell; @@ -20,6 +21,8 @@ pub enum Kind { Perpendicular, Extension, Tangent, + /// The new line passes through the centre of a circle or arc. + Center, } /// A snap taken from existing geometry: what, to which curve, where the point goes, how far the @@ -31,6 +34,9 @@ pub struct Inference { pub at: Vec2, pub dist: f64, pub guide: (Vec2, Vec2), + /// Point argument for the new endpoint: `".center"` when it lands on the centre, or + /// `"on::x,y"` when it lands on the circle. None: a free point on the radial line. + pub arg: Option, } impl Inference { @@ -40,6 +46,7 @@ impl Inference { Kind::Perpendicular => "\u{22a5}", Kind::Extension => "Ext", Kind::Tangent => "T", + Kind::Center => "Ctr", } } } @@ -90,13 +97,67 @@ pub fn tangent(sk: &Sketch, prev: Vec2, lp: Vec2, tol: f64) -> Option let on_arc = sk.shape(i).is_none_or(|sh| sh.dist(t) < 1e-6); let e = t.dist(lp); if on_arc && e < tol && best.as_ref().is_none_or(|b| e < b.dist) { - best = Some(Inference { kind: Kind::Tangent, curve: c.id.clone(), at: t, dist: e, guide: (prev, t) }); + best = Some(Inference { kind: Kind::Tangent, curve: c.id.clone(), at: t, dist: e, guide: (prev, t), arg: None }); } } } keep(best) } +/// The line from `prev` passes near the centre of a circle or arc (within `tol`). +/// +/// The point lands on that radial line, so the segment contains the centre. On the circle it +/// lands on the rim (a diameter, or a radius when the other end is already the centre's side). +/// On the centre point itself it lands on that point. A chord that misses the centre, or that +/// stops before reaching it, is left alone. +pub fn through_center(sk: &Sketch, prev: Vec2, lp: Vec2, tol: f64) -> Option { + let mut best: Option = None; + for (i, c) in sk.curves.iter().enumerate() { + if !matches!(c.kind, CurveKind::Circle { .. } | CurveKind::Arc { .. }) { + continue; + } + let (Some(o), Some(r)) = (sk.center(i), sk.radius(i)) else { continue }; + if r <= 1e-9 { + continue; + } + // Already standing on the centre: every direction goes through it. + let Some(dir) = (o - prev).normalized() else { continue }; + let off = (lp - prev).cross(dir).abs(); + if off > tol { + continue; + } + let along = (lp - prev).dot(dir); + if along < tol { + continue; + } + let d = prev.dist(o); + let past = along + tol >= d; + let far = prev + dir * (d + r); + let on_arc = sk.shape(i).is_none_or(|sh| sh.dist(far) < 1e-6); + let near_curve = sk.shape(i).is_some_and(|sh| sh.dist(lp) < tol * 0.8); + let rim = near_curve && on_arc && past && far.dist(lp) <= tol; + let at_centre = lp.dist(o) <= tol; + let (at, arg) = if rim && (!at_centre || far.dist(lp) <= lp.dist(o)) { + (far, Some(format!("on:{}:{},{}", c.id, far.x, far.y))) + } else if at_centre { + (o, Some(format!("{}.center", c.id))) + } else if past { + let at = prev + dir * along; + if at.dist(o) <= tol { (o, Some(format!("{}.center", c.id))) } else { (at, None) } + } else { + continue; + }; + if at.dist(prev) < 1e-6 { + continue; + } + let dist = at.dist(lp); + if dist <= tol && best.as_ref().is_none_or(|b| dist < b.dist) { + best = Some(Inference { kind: Kind::Center, curve: c.id.clone(), at, dist, guide: (prev, at), arg }); + } + } + keep(best) +} + /// Directions and places from lines: parallel or perpendicular to a line (from `prev`), or on /// the extension of a line beyond its ends. pub fn lines(sk: &Sketch, prev: Vec2, lp: Vec2, tol: f64) -> Option { @@ -133,6 +194,7 @@ pub fn lines(sk: &Sketch, prev: Vec2, lp: Vec2, tol: f64) -> Option { at, dist: e, guide: (prev - d * (t.signum() * tol * 6.0), at + d * (t.signum() * tol * 6.0)), + arg: None, }); } } @@ -144,7 +206,7 @@ pub fn lines(sk: &Sketch, prev: Vec2, lp: Vec2, tol: f64) -> Option { let e = at.dist(lp); if e < tol * 0.8 && !touches { let from = if s < 0.0 { a } else { b }; - offer(Inference { kind: Kind::Extension, curve: c.id.clone(), at, dist: e * 0.9, guide: (from, at) }); + offer(Inference { kind: Kind::Extension, curve: c.id.clone(), at, dist: e * 0.9, guide: (from, at), arg: None }); } } } @@ -156,11 +218,16 @@ pub fn lines(sk: &Sketch, prev: Vec2, lp: Vec2, tol: f64) -> Option { pub fn commit(app: &mut SolveApp, line: &str, end: Vec2) { let Some(i) = current().filter(|i| i.at.dist(end) < 1e-9) else { return }; clear(); + // The endpoint is the circle's centre: the line already ends on that point. + if matches!(i.kind, Kind::Center) && i.arg.as_deref().is_some_and(|a| a.ends_with(".center")) { + return; + } let (cmd, mut params) = match i.kind { Kind::Parallel => ("sketch.constraint.parallel", json!({"a": i.curve, "b": line})), Kind::Perpendicular => ("sketch.constraint.perpendicular", json!({"a": i.curve, "b": line})), Kind::Tangent => ("sketch.constraint.tangent", json!({"a": i.curve, "b": line})), Kind::Extension => ("sketch.constraint.coincident", json!({"a": format!("{line}.end"), "b": i.curve})), + Kind::Center => ("sketch.constraint.coincident", json!({"a": format!("{}.center", i.curve), "b": line})), }; // An inference only adds what the sketch does not already have: one that is already implied // (redundant) or would conflict is simply not kept. @@ -225,4 +292,85 @@ mod tests { assert!(i.at.dist(t) < 1e-9); assert!((i.at - Vec2::new(30.0, 0.0)).dot(i.at - prev).abs() < 1e-9); } + + fn circle() -> Sketch { + let mut s = Sketch::new(); + let _ = s.add_circle(Vec2::ZERO, 10.0, None, Some("c1")); + s + } + + /// `o` lies on the segment from `prev` to `at`. + fn through(prev: Vec2, at: Vec2, o: Vec2) -> bool { + let d = at - prev; + let len2 = d.len2(); + if len2 < 1e-18 { + return false; + } + let t = (o - prev).dot(d) / len2; + (0.0..=1.0).contains(&t) && (prev + d * t).dist(o) < 1e-8 + } + + #[test] + fn a_near_diameter_lands_on_the_circle_through_its_centre() { + let s = circle(); + // Start on the left of the circle; the cursor is on the right, a fraction off the diameter. + let i = through_center(&s, Vec2::new(-10.0, 0.0), Vec2::new(10.0, 0.3), 0.5).expect("diameter"); + assert_eq!(i.kind, Kind::Center); + assert!(i.at.dist(Vec2::new(10.0, 0.0)) < 1e-9, "{:?}", i.at); + assert!(through(Vec2::new(-10.0, 0.0), i.at, Vec2::ZERO)); + assert_eq!(i.arg.as_deref(), Some("on:c1:10,0")); + } + + #[test] + fn a_chord_that_misses_the_centre_stays_free() { + let s = circle(); + assert!(through_center(&s, Vec2::new(-10.0, 0.0), Vec2::new(10.0, 4.0), 0.5).is_none()); + // Stopped short of the centre: still a free point on the near side. + assert!(through_center(&s, Vec2::new(-30.0, 0.0), Vec2::new(-15.0, 0.1), 0.5).is_none()); + } + + #[test] + fn the_endpoint_snaps_onto_the_centre_point() { + let s = circle(); + let i = through_center(&s, Vec2::new(20.0, 0.0), Vec2::new(0.2, 0.1), 0.5).expect("centre"); + assert!(i.at.dist(Vec2::ZERO) < 1e-9); + assert_eq!(i.arg.as_deref(), Some("c1.center")); + // Already standing on the centre: nothing further to snap. + assert!(through_center(&s, Vec2::ZERO, Vec2::new(10.0, 0.2), 0.5).is_none()); + } + + #[test] + fn a_line_past_the_centre_locks_onto_the_radial_line() { + let s = circle(); + let i = through_center(&s, Vec2::new(-20.0, 0.0), Vec2::new(5.0, 0.2), 0.5).expect("radial"); + assert!(i.at.dist(Vec2::new(5.0, 0.0)) < 1e-9, "{:?}", i.at); + assert!(i.arg.is_none()); + assert!(through(Vec2::new(-20.0, 0.0), i.at, Vec2::ZERO)); + } + + #[test] + fn an_arc_only_accepts_a_rim_point_that_lies_on_it() { + let mut s = Sketch::new(); + // Quarter arc in the first quadrant. + let _ = s.add_arc(Vec2::ZERO, Vec2::new(10.0, 0.0), Vec2::new(0.0, 10.0), [None; 3], Some("a1")); + let on = through_center(&s, Vec2::new(-20.0, 0.0), Vec2::new(10.1, 0.2), 0.5).expect("on the arc"); + assert!(on.at.dist(Vec2::new(10.0, 0.0)) < 1e-9, "{:?}", on.at); + assert!(on.arg.as_deref().unwrap().starts_with("on:a1:")); + // The opposite point, (-10, 0), is not on this arc, so the line still locks through the + // centre but does not claim a point on the arc. + let off = through_center(&s, Vec2::new(20.0, 0.0), Vec2::new(-10.0, 0.2), 0.5).expect("through, off the arc"); + assert!(off.at.dist(Vec2::new(-10.0, 0.0)) < 1e-9, "{:?}", off.at); + assert!(off.arg.is_none()); + } + + #[test] + fn a_construction_circle_still_snaps_and_the_nearer_centre_wins() { + let mut s = circle(); + s.curves[0].construction = true; + let i = through_center(&s, Vec2::new(-10.0, 0.0), Vec2::new(9.8, 0.2), 0.5).expect("construction"); + assert!(i.at.dist(Vec2::new(10.0, 0.0)) < 1e-9); + let _ = s.add_circle(Vec2::new(0.0, 40.0), 10.0, None, Some("c2")); + let i = through_center(&s, Vec2::new(-20.0, 0.0), Vec2::new(10.0, 0.2), 0.5).expect("nearer"); + assert_eq!(i.curve, "c1"); + } } diff --git a/crates/ui-egui/src/issue_report.rs b/crates/ui-egui/src/issue_report.rs index 9fefbe76c3..5ed16c95c2 100644 --- a/crates/ui-egui/src/issue_report.rs +++ b/crates/ui-egui/src/issue_report.rs @@ -23,6 +23,12 @@ thread_local! { static CAD_INPUT_BLOCKED: std::cell::Cell = const { std::cell::Cell::new(false) }; } +/// Tests set the barrier by hand to see what ignores it. +#[cfg(test)] +pub(crate) fn set_cad_input_blocked_for_test(blocked: bool) { + CAD_INPUT_BLOCKED.with(|cell| cell.set(blocked)); +} + pub(crate) fn cad_input_blocked() -> bool { CAD_INPUT_BLOCKED.with(std::cell::Cell::get) } diff --git a/crates/ui-egui/src/joint_drag.rs b/crates/ui-egui/src/joint_drag.rs index bea0d15fb3..f7de2b4988 100644 --- a/crates/ui-egui/src/joint_drag.rs +++ b/crates/ui-egui/src/joint_drag.rs @@ -187,6 +187,24 @@ mod tests { assert!(arm_at(&h, end).dist(end) < 1e-9, "{:?}", arm_at(&h, end)); } + /// #5137: while a part is dragged along its joint, nothing under the pointer highlights; hover + /// comes back once the drag ends. + #[test] + fn dragging_a_jointed_part_highlights_nothing_under_the_pointer() { + let (mut h, _, a, b) = hinged_arm(); + h.app.synthetic.push(egui::Event::PointerMoved(a)); + play(&mut h); + assert!(h.app.viewport.hover.is_some(), "the arm under the pointer must highlight before the drag"); + press_and_move(&mut h, a, b); + assert!(h.app.viewport.joint_drag.is_some(), "the drag must move the arm"); + assert!(h.app.viewport.hover.is_none(), "dragging a part must not highlight what is under the pointer"); + h.frames(2); + assert!(h.app.viewport.hover.is_none(), "a drag held still must not highlight either"); + release(&mut h, b); + h.frames(2); + assert!(h.app.viewport.hover.is_some(), "hover must return after the drag"); + } + /// With the undo history full, a drag still adds one step: it drops the oldest once, not one /// per frame. #[test] diff --git a/crates/ui-egui/src/keymap.rs b/crates/ui-egui/src/keymap.rs index 1020a456d1..eaa3b45943 100644 --- a/crates/ui-egui/src/keymap.rs +++ b/crates/ui-egui/src/keymap.rs @@ -61,11 +61,118 @@ pub struct Keymap { filter: String, /// The command waiting for a key press. capturing: Option, + recorder: Recorder, /// A key that another command uses: (command, key, the other commands). conflict: Option<(String, String, Vec)>, message: Option, } +/// A chord is one ordinary key plus modifier state, as in the platform key-event model. +/// The one-second settling period is an editor affordance, not a shortcut activation delay. +const RECORD_SETTLE_SECONDS: f64 = 1.0; + +#[derive(Default)] +struct Recorder { + key: Option, + modifiers: egui::Modifiers, + changed_at: f64, +} + +enum Recording { + Waiting, + Cancelled, + MultipleKeys, + Complete(String), +} + +impl Recorder { + fn add_modifiers(&mut self, modifiers: egui::Modifiers, now: f64) { + if self.key.is_none() { + // Before the ordinary key, preview only what is currently held. A modifier + // pressed and released while choosing a chord must not become part of it. + self.modifiers = modifiers; + } else { + let combined = self.modifiers | modifiers; + if combined != self.modifiers { + self.modifiers = combined; + self.changed_at = now; + } + } + } + + fn preview(&self) -> Option { + match self.key { + Some(key) => Some(format!("{}…", display(&key_name(key, self.modifiers)))), + None if !self.modifiers.is_none() => Some(format!("{}…", display(&modifier_prefix(self.modifiers)))), + None => None, + } + } + + fn hint(&self, input: &egui::InputState) -> &'static str { + if self.key.is_none() { + if self.modifiers.is_none() { "ui.shortcut_capture_press" } else { "ui.shortcut_capture_add_key" } + } else if !input.keys_down.is_empty() || !input.modifiers.is_none() { + "ui.shortcut_capture_release" + } else { + "ui.shortcut_capture_settling" + } + } + + fn update(&mut self, input: &egui::InputState) -> Recording { + // Cancel on an actual focus loss. The control channel can legitimately open the + // recorder while the window is already in the background. + if input.events.iter().any(|event| matches!(event, egui::Event::WindowFocused(false))) { + return Recording::Cancelled; + } + for event in &input.events { + match event { + egui::Event::ModifiersChanged(modifiers) => self.add_modifiers(*modifiers, input.time), + // egui also emits physical left/right modifier key events. They carry + // modifier state but must never occupy the chord's ordinary-key slot. + egui::Event::Key { + key: + egui::Key::ShiftLeft + | egui::Key::ShiftRight + | egui::Key::ControlLeft + | egui::Key::ControlRight + | egui::Key::AltLeft + | egui::Key::AltRight + | egui::Key::SuperLeft + | egui::Key::SuperRight, + modifiers, + .. + } => self.add_modifiers(*modifiers, input.time), + egui::Event::Key { key, pressed, repeat, modifiers, .. } => { + if *pressed && *key == egui::Key::Escape { + return Recording::Cancelled; + } + if *pressed && !repeat { + if self.key.is_some_and(|previous| previous != *key) { + return Recording::MultipleKeys; + } + if self.key.is_none() { + self.key = Some(*key); + self.modifiers = *modifiers; + self.changed_at = input.time; + } + } + self.add_modifiers(*modifiers, input.time); + } + _ => {} + } + } + self.add_modifiers(input.modifiers, input.time); + if let Some(key) = self.key + && input.keys_down.is_empty() + && input.modifiers.is_none() + && input.time - self.changed_at >= RECORD_SETTLE_SECONDS + { + return Recording::Complete(key_name(key, self.modifiers)); + } + Recording::Waiting + } +} + impl Keymap { pub fn prefs(&self) -> Value { json!(self.custom) @@ -78,17 +185,27 @@ impl Keymap { /// A key press as a shortcut name: `E`, `Shift+E`, `Ctrl+Alt+K`, `F5`, `Delete`. pub fn key_name(key: egui::Key, mods: egui::Modifiers) -> String { + let mut s = modifier_prefix(mods); + s.push_str(key.name()); + s +} + +fn modifier_prefix(mods: egui::Modifiers) -> String { let mut s = String::new(); if mods.command || mods.ctrl { s.push_str("Ctrl+"); } + // Keep the historical Ctrl/Cmd alias for existing bindings, but don't discard a + // physical Control pressed together with macOS Command. + if mods.mac_cmd && mods.ctrl { + s.push_str("Cmd+"); + } if mods.alt { s.push_str("Alt+"); } if mods.shift { s.push_str("Shift+"); } - s.push_str(key.name()); s } @@ -106,6 +223,28 @@ fn effective_for_spec<'a>(app: &'a SolveApp, command: &CommandSpec) -> Option<&' app.keymap.custom.get(command.id).map(String::as_str).or(command.shortcut).filter(|key| !key.is_empty()) } +/// Presentation only: stored Ctrl bindings retain their historical primary-modifier alias. +/// A recorded macOS Control+Command chord includes both names explicitly. +pub fn display(key: &str) -> String { + display_for_platform(key, cfg!(target_os = "macos")) +} + +fn display_for_platform(key: &str, mac: bool) -> String { + let both = key.starts_with("Ctrl+Cmd+"); + key.split('+') + .map(|part| match (mac, part) { + (true, "Ctrl") if !both => "Cmd", + (true, "Alt") => "Option", + _ => part, + }) + .collect::>() + .join("+") +} + +pub fn display_for(app: &SolveApp, id: &str) -> Option { + effective(app, id).map(|key| display(&key)) +} + /// Commands bound to `key` now. pub fn commands_for(app: &SolveApp, key: &str) -> Vec<&'static str> { command_specs().into_iter().filter(|c| effective(app, c.id).as_deref() == Some(key)).map(|c| c.id).collect() @@ -206,18 +345,31 @@ pub fn reset(app: &mut SolveApp, id: Option<&str>) { /// reset; a key another command uses is reported and taken over only on request. pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); - // A key press while waiting for one binds it (Esc cancels). + // Keep a pending chord until it has settled and all keys are released. Modifier-only + // input never starts the timer, so modifiers can be assembled at the user's own pace. if let Some(id) = app.keymap.capturing.clone() { - let press = ctx.input(|i| { - i.events.iter().find_map(|e| match e { - egui::Event::Key { key, pressed: true, modifiers, .. } => Some((*key, *modifiers)), - _ => None, - }) - }); - if let Some((k, m)) = press { - app.keymap.capturing = None; - if k != egui::Key::Escape { - let name = key_name(k, m); + match ctx.input(|i| app.keymap.recorder.update(i)) { + Recording::Waiting => { + if app.keymap.recorder.key.is_some() { + let remaining = ctx.input(|i| RECORD_SETTLE_SECONDS - (i.time - app.keymap.recorder.changed_at)); + if remaining > 0.0 + && let Ok(delay) = std::time::Duration::try_from_secs_f64(remaining) + { + ctx.request_repaint_after(delay); + } + } + } + Recording::Cancelled => { + app.keymap.capturing = None; + // Escape cancels recording first, without also closing Preferences. + ctx.input_mut(|i| i.events.retain(|e| !matches!(e, egui::Event::Key { key: egui::Key::Escape, .. }))); + } + Recording::MultipleKeys => { + app.keymap.capturing = None; + app.keymap.message = Some(app.tr("ui.shortcut_multiple_keys").to_string()); + } + Recording::Complete(name) => { + app.keymap.capturing = None; match bind(app, &id, &name, false) { Ok(()) => app.keymap.message = None, Err(e) if e.contains("already used") => { @@ -228,6 +380,11 @@ pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) { } } } + // The recorder owns these events. In particular, Enter/Space must not activate + // the focused binding button again or type into the filter while still pending. + ctx.input_mut(|i| { + i.events.retain(|event| !matches!(event, egui::Event::Key { .. } | egui::Event::Text(_) | egui::Event::Paste(_))); + }); } let mut action: Option<(String, &str)> = None; ui_kit::scope(ui, |ui| { @@ -249,6 +406,10 @@ pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) { &actions, ); crate::scenario::publish_target("keymap:filter", filter.rect); + crate::scenario::publish_handle("shortcut:filter", filter.rect.center()); + if filter.clicked() { + action = Some((String::new(), "cancel")); + } if let Some(index) = toolbar_action { action = Some(( String::new(), @@ -264,7 +425,7 @@ pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) { } if let Some((id, key, _)) = app.keymap.conflict.clone() { ui.horizontal_wrapped(|ui| { - let replace = ui_kit::button(ui, crate::i18n::format("keymap.use_anyway", &[("key", &key)]), ui_kit::Button::Normal); + let replace = ui_kit::button(ui, crate::i18n::format("keymap.use_anyway", &[("key", &display(&key))]), ui_kit::Button::Normal); crate::scenario::publish_target("keymap:replace", replace.rect); if replace.clicked() { action = Some((format!("{id}\n{key}"), "replace")); @@ -275,6 +436,13 @@ pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) { }); } ui_kit::text(ui, app.tr_label("Select Other: hold the left button still on the model (or right-click › Select Other…) to list everything under the cursor; click again on the same spot to select the next thing there. Double-click the middle button to go Home (Preferences › Navigation)."), ui_kit::Text::Note("navigation")); + if !app.keymap.read_only { + ui_kit::text(ui, app.tr("ui.shortcut_capture_hint"), ui_kit::Text::Note("shortcut_capture_help")); + if app.keymap.capturing.is_some() { + let hint = ctx.input(|input| app.keymap.recorder.hint(input)); + ui_kit::text(ui, app.tr(hint), ui_kit::Text::Hint("shortcut_capture_state")); + } + } let q = app.keymap.filter.to_lowercase(); let commands: Vec<_> = command_specs() .into_iter() @@ -287,7 +455,8 @@ pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) { let t = crate::theme::Tokens::get(); let key_width = commands .iter() - .map(|(_, key, _)| ui_kit::ContentWidth::button(ui, key)) + .map(|(_, key, _)| ui_kit::ContentWidth::button(ui, &display(key))) + .chain(app.keymap.recorder.preview().filter(|_| app.keymap.capturing.is_some()).map(|text| ui_kit::ContentWidth::button(ui, &text))) .fold(ui_kit::ContentWidth::button(ui, app.tr_label("press a key…")), f32::max) .max(ui_kit::ContentWidth::label(ui, app.tr_label("Keyboard Shortcuts")) + t.space_8); let action_width = if app.keymap.read_only { @@ -314,11 +483,11 @@ pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) { let capturing = app.keymap.capturing.as_deref() == Some(c.id); let changed = app.keymap.custom.contains_key(c.id); let text = if capturing { - app.tr_label("press a key…").to_string() + app.keymap.recorder.preview().unwrap_or_else(|| app.tr_label("press a key…").to_string()) } else if key.is_empty() { "—".to_string() } else { - key.to_string() + display(key) }; row.cell(|ui| { if app.keymap.read_only { @@ -331,7 +500,11 @@ pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) { ui_kit::button(ui, &text, ui_kit::Button::Normal).on_hover_text(app.tr_label("Click, then press the new key")) }; crate::scenario::publish_target(&format!("keymap:capture:{}", c.id), capture.rect); + if ui.is_rect_visible(capture.rect) { + crate::scenario::publish_handle(&format!("shortcut:{}", c.id), capture.rect.center()); + } if capture.clicked() { + capture.surrender_focus(); action = Some((c.id.to_string(), "capture")); } } @@ -357,9 +530,15 @@ pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) { }); }); }); + crate::scenario::publish_count("shortcut_recording", f64::from(app.keymap.capturing.is_some())); + if action.is_some() { + app.keymap.capturing = None; + app.keymap.recorder = Recorder::default(); + } match action { Some((id, "capture")) => { app.keymap.capturing = Some(id); + app.keymap.recorder = Recorder::default(); app.keymap.conflict = None; app.keymap.message = None; } @@ -402,6 +581,7 @@ pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) { /// Stop waiting for a key (the dialog closed). pub fn stop_capture(app: &mut SolveApp) { app.keymap.capturing = None; + app.keymap.recorder = Recorder::default(); app.keymap.conflict = None; app.keymap.message = None; } @@ -419,6 +599,274 @@ mod tests { SolveApp::new(solidcraft_engine::Session::default(), Default::default()) } + fn recorder_frame(a: &mut SolveApp, ctx: &egui::Context, time: f64, events: Vec) { + let raw = egui::RawInput { time: Some(time), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| page(a, ui)); + out.textures_delta.clear(); + } + + #[test] + fn recorder_waits_for_late_modifiers_instead_of_binding_the_first_key_down() { + let mut a = app(); + let ctx = egui::Context::default(); + a.keymap.capturing = Some("solid.chamfer".into()); + recorder_frame( + &mut a, + &ctx, + 0.0, + vec![egui::Event::Key { key: egui::Key::M, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }], + ); + assert!(a.keymap.capturing.is_some(), "the first key-down must remain a preview"); + assert_eq!(effective(&a, "solid.chamfer"), None); + } + + fn event(key: egui::Key, pressed: bool, modifiers: egui::Modifiers) -> egui::Event { + egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers } + } + + #[test] + fn control_input_preserves_primary_alias_and_explicit_physical_modifiers() { + for (step, ctrl, mac_cmd) in [ + (json!({"key":"F12","pressed":true,"cmd":true}), true, false), + (json!({"key":"F12","pressed":true,"cmd":true,"ctrl":false}), false, true), + (json!({"key":"F12","pressed":true,"cmd":true,"ctrl":true}), true, true), + (json!({"key":"F12","pressed":true,"ctrl":true}), true, false), + ] { + let mut h = crate::scenario::Harness::new(); + h.step(&step).unwrap(); + let modifiers = h.ctx.input(|i| i.modifiers); + assert_eq!(modifiers.ctrl, ctrl, "{step}"); + assert_eq!(modifiers.mac_cmd, mac_cmd, "{step}"); + assert!(modifiers.command, "{step}"); + } + } + + #[test] + fn recorder_previews_each_modifier_and_explains_the_next_step() { + let mut h = crate::scenario::Harness::new(); + h.call("ui.prefs", json!({"open":"Keyboard Shortcuts"})); + h.step(&json!({"click":{"handle":"shortcut:filter"}})).unwrap(); + h.call("ui.text", json!({"text":"solid.chamfer"})); + h.step(&json!({"click":{"handle":"shortcut:solid.chamfer"}})).unwrap(); + h.call("ui.key", json!({"key":"ShiftLeft","pressed":true,"shift":true})); + assert!(h.widget("Shift+…").is_some(), "show Shift immediately, before any ordinary key"); + assert!(h.widget("Add one ordinary key. Escape cancels.").is_some()); + h.call("ui.key", json!({"key":"SuperLeft","pressed":true,"shift":true,"cmd":true,"ctrl":false})); + let preview = if cfg!(target_os = "macos") { "Cmd+Shift+…" } else { "Ctrl+Shift+…" }; + assert!(h.widget(preview).is_some(), "show each newly held modifier"); + h.call("ui.key", json!({"key":"SuperLeft","pressed":false,"shift":true})); + assert!(h.widget("Shift+…").is_some(), "remove released modifiers before an ordinary key"); + h.call("ui.key", json!({"key":"ShiftLeft","pressed":false})); + assert!(h.widget("press a key…").is_some()); + assert_eq!(effective(&h.app, "solid.chamfer"), None); + h.call("ui.key", json!({"key":"ShiftRight","pressed":true,"shift":true})); + h.call("ui.key", json!({"key":"C","pressed":true,"shift":true})); + assert!(h.widget("Shift+C…").is_some()); + assert!(h.widget("Release all keys to confirm. Escape cancels.").is_some()); + h.call("ui.key", json!({"key":"C","pressed":false,"shift":true})); + h.call("ui.key", json!({"key":"ShiftRight","pressed":false})); + assert!(h.widget("Confirming shortcut… Escape cancels.").is_some()); + assert!(h.widget("Shift+C…").is_some(), "retain the proposed chord until confirmed"); + assert_eq!(effective(&h.app, "solid.chamfer"), None); + h.frames(65); + assert_eq!(effective(&h.app, "solid.chamfer").as_deref(), Some("Shift+C")); + assert!(h.widget("Shift+C").is_some()); + } + + #[test] + fn physical_modifier_keys_are_not_the_ordinary_key_in_a_chord() { + use egui::Key; + for (key, mods, expected) in [ + (Key::ShiftLeft, egui::Modifiers::SHIFT, "Shift+C"), + (Key::ShiftRight, egui::Modifiers::SHIFT, "Shift+C"), + (Key::ControlLeft, egui::Modifiers::CTRL, "Ctrl+C"), + (Key::ControlRight, egui::Modifiers::CTRL, "Ctrl+C"), + (Key::AltLeft, egui::Modifiers::ALT, "Alt+C"), + (Key::AltRight, egui::Modifiers::ALT, "Alt+C"), + (Key::SuperLeft, egui::Modifiers::MAC_CMD | egui::Modifiers::COMMAND, "Ctrl+C"), + (Key::SuperRight, egui::Modifiers::MAC_CMD | egui::Modifiers::COMMAND, "Ctrl+C"), + ] { + let ctx = egui::Context::default(); + let mut recorder = Recorder::default(); + for (time, events, complete) in [ + (0.0, vec![egui::Event::ModifiersChanged(mods), event(key, true, mods)], false), + (5.0, vec![], false), + (5.1, vec![event(Key::C, true, mods)], false), + (5.2, vec![event(Key::C, false, mods)], false), + (5.3, vec![event(key, false, egui::Modifiers::NONE), egui::Event::ModifiersChanged(egui::Modifiers::NONE)], false), + (6.2, vec![], true), + ] { + let raw = egui::RawInput { time: Some(time), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + let result = ui.input(|input| recorder.update(input)); + if complete { + assert!(matches!(result, Recording::Complete(ref binding) if binding == expected), "{key:?}"); + } else { + assert!(matches!(result, Recording::Waiting), "{key:?} at {time}"); + } + }); + out.textures_delta.clear(); + if time <= 5.0 { + assert!(recorder.key.is_none(), "{key:?} must not be recorded as an ordinary key"); + } + } + } + } + + #[test] + fn recorder_accumulates_late_modifiers_and_settles_for_one_second() { + let mut a = app(); + let ctx = egui::Context::default(); + a.keymap.capturing = Some("sketch.mirror".into()); + recorder_frame(&mut a, &ctx, 0.0, vec![event(egui::Key::M, true, egui::Modifiers::NONE)]); + recorder_frame(&mut a, &ctx, 0.4, vec![egui::Event::ModifiersChanged(egui::Modifiers::SHIFT)]); + let mods = egui::Modifiers { shift: true, mac_cmd: true, command: true, ..Default::default() }; + recorder_frame(&mut a, &ctx, 0.8, vec![egui::Event::ModifiersChanged(mods)]); + recorder_frame(&mut a, &ctx, 1.0, vec![event(egui::Key::M, false, mods), egui::Event::ModifiersChanged(egui::Modifiers::NONE)]); + recorder_frame(&mut a, &ctx, 1.79, vec![]); + assert!(a.keymap.capturing.is_some()); + assert_eq!(effective(&a, "sketch.mirror"), None); + recorder_frame(&mut a, &ctx, 1.81, vec![]); + assert!(a.keymap.capturing.is_none()); + assert_eq!(effective(&a, "sketch.mirror").as_deref(), Some("Ctrl+Shift+M")); + let mut reloaded = app(); + reloaded.keymap.load(&a.keymap.prefs()); + assert_eq!(effective(&reloaded, "sketch.mirror"), effective(&a, "sketch.mirror")); + } + + #[test] + fn recorder_waits_indefinitely_for_modifier_only_input_and_for_release() { + let mut a = app(); + let ctx = egui::Context::default(); + a.keymap.capturing = Some("sketch.mirror".into()); + let mods = egui::Modifiers { shift: true, ctrl: true, alt: true, mac_cmd: true, command: true }; + recorder_frame(&mut a, &ctx, 0.0, vec![egui::Event::ModifiersChanged(mods)]); + recorder_frame(&mut a, &ctx, 10.0, vec![]); + assert!(a.keymap.capturing.is_some()); + assert_eq!(effective(&a, "sketch.mirror"), None); + recorder_frame(&mut a, &ctx, 12.0, vec![event(egui::Key::M, true, mods)]); + let mut repeat = event(egui::Key::M, true, mods); + if let egui::Event::Key { repeat, .. } = &mut repeat { + *repeat = true; + } + recorder_frame(&mut a, &ctx, 14.0, vec![repeat]); + assert!(a.keymap.capturing.is_some(), "held keys must not confirm"); + recorder_frame(&mut a, &ctx, 14.1, vec![event(egui::Key::M, false, mods)]); + assert!(a.keymap.capturing.is_some(), "held modifiers must not confirm"); + recorder_frame(&mut a, &ctx, 14.2, vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE)]); + assert_eq!(effective(&a, "sketch.mirror").as_deref(), Some("Ctrl+Cmd+Alt+Shift+M")); + } + + #[test] + fn recorder_rejects_two_ordinary_keys_and_escape_preserves_the_old_binding() { + for second in [egui::Key::N, egui::Key::Escape] { + let mut a = app(); + let ctx = egui::Context::default(); + a.keymap.capturing = Some("solid.extrude".into()); + recorder_frame(&mut a, &ctx, 0.0, vec![event(egui::Key::M, true, egui::Modifiers::SHIFT)]); + recorder_frame(&mut a, &ctx, 0.5, vec![event(second, true, egui::Modifiers::SHIFT)]); + assert!(a.keymap.capturing.is_none()); + assert_eq!(effective(&a, "solid.extrude").as_deref(), Some("E")); + assert_eq!(a.keymap.message.is_some(), second != egui::Key::Escape); + } + } + + #[test] + fn recorder_conflicts_only_after_settling_and_plain_keys_still_work() { + let mut a = app(); + let ctx = egui::Context::default(); + a.keymap.capturing = Some("solid.chamfer".into()); + recorder_frame(&mut a, &ctx, 0.0, vec![event(egui::Key::M, true, egui::Modifiers::NONE), event(egui::Key::M, false, egui::Modifiers::NONE)]); + assert!(a.keymap.conflict.is_none()); + recorder_frame(&mut a, &ctx, 1.1, vec![]); + assert_eq!(a.keymap.conflict.as_ref().map(|(_, key, _)| key.as_str()), Some("M")); + assert_eq!(effective(&a, "solid.move").as_deref(), Some("M")); + assert_eq!(effective(&a, "solid.chamfer"), None); + stop_capture(&mut a); + a.keymap.capturing = Some("solid.extrude".into()); + recorder_frame(&mut a, &ctx, 2.0, vec![event(egui::Key::K, true, egui::Modifiers::NONE), event(egui::Key::K, false, egui::Modifiers::NONE)]); + recorder_frame(&mut a, &ctx, 3.1, vec![]); + assert_eq!(effective(&a, "solid.extrude").as_deref(), Some("K")); + } + + #[test] + fn recorder_cancels_on_focus_loss_and_dialog_close() { + let mut a = app(); + let ctx = egui::Context::default(); + a.keymap.capturing = Some("solid.extrude".into()); + recorder_frame(&mut a, &ctx, 0.0, vec![event(egui::Key::K, true, egui::Modifiers::SHIFT)]); + let raw = egui::RawInput { time: Some(0.5), focused: false, events: vec![egui::Event::WindowFocused(false)], ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| page(&mut a, ui)); + out.textures_delta.clear(); + assert!(a.keymap.capturing.is_none()); + assert_eq!(effective(&a, "solid.extrude").as_deref(), Some("E")); + stop_capture(&mut a); + assert!(a.keymap.recorder.key.is_none()); + } + + #[test] + fn escape_cancels_only_recording_and_no_command_fires_during_capture() { + let mut h = crate::scenario::Harness::new(); + h.call("ui.prefs", json!({"open": "Keyboard Shortcuts"})); + h.app.keymap.capturing = Some("solid.chamfer".into()); + h.call("ui.key", json!({"key": "C", "pressed": true})); + h.frames(2); + assert!(h.app.tool.is_none() && h.app.dialog.is_none()); + assert!(h.app.session.active_sketch.is_none(), "C must not start a sketch while recording"); + h.call("ui.key", json!({"key": "Escape"})); + h.frames(3); + assert!(h.app.prefs_window.open, "Escape should cancel the recorder, not close Preferences"); + assert!(h.app.keymap.capturing.is_none()); + assert_eq!(effective(&h.app, "solid.chamfer"), None); + } + + #[test] + fn a_background_control_channel_recording_waits_for_an_actual_focus_loss() { + let mut a = app(); + let ctx = egui::Context::default(); + a.keymap.capturing = Some("sketch.mirror".into()); + let raw = + egui::RawInput { time: Some(0.0), focused: false, events: vec![event(egui::Key::M, true, egui::Modifiers::SHIFT)], ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| page(&mut a, ui)); + out.textures_delta.clear(); + assert!(a.keymap.capturing.is_some()); + assert_eq!(effective(&a, "sketch.mirror"), None); + } + + #[test] + fn enter_and_space_are_recorded_without_reactivating_the_focused_button() { + for key in ["Enter", "Space"] { + let mut h = crate::scenario::Harness::new(); + h.call("ui.prefs", json!({"open": "Keyboard Shortcuts"})); + h.step(&json!({"click":{"handle":"shortcut:filter"}})).unwrap(); + h.call("ui.text", json!({"text":"sketch.mirror"})); + h.step(&json!({"click":{"handle":"shortcut:sketch.mirror"}})).unwrap(); + h.call("ui.key", json!({"key":key})); + assert!(h.app.keymap.capturing.is_some(), "{key} should still be pending"); + h.frames(65); + assert_eq!(effective(&h.app, "sketch.mirror").as_deref(), Some(key)); + assert!(h.app.prefs_window.open); + } + } + + #[test] + fn chord_display_and_legacy_primary_modifier_bindings_are_compatible() { + assert_eq!(display_for_platform("Ctrl+Alt+Shift+M", true), "Cmd+Option+Shift+M"); + assert_eq!(display_for_platform("Ctrl+Cmd+Alt+Shift+M", true), "Ctrl+Cmd+Option+Shift+M"); + assert_eq!(display_for_platform("Ctrl+Alt+Shift+M", false), "Ctrl+Alt+Shift+M"); + let mut a = app(); + a.keymap.load(&json!({"solid.chamfer":"Shift+C", "solid.extrude":"Ctrl+Alt+E"})); + assert_eq!(effective(&a, "solid.chamfer").as_deref(), Some("Shift+C")); + let mods = egui::Modifiers { mac_cmd: true, command: true, alt: true, ..Default::default() }; + assert_eq!(commands_for(&a, &key_name(egui::Key::E, mods)), vec!["solid.extrude"]); + let extra = egui::Modifiers { shift: true, ..mods }; + assert!(commands_for(&a, &key_name(egui::Key::E, extra)).is_empty()); + let both = egui::Modifiers { ctrl: true, ..extra }; + bind(&mut a, "sketch.mirror", &key_name(egui::Key::M, both), false).unwrap(); + assert_eq!(commands_for(&a, &key_name(egui::Key::M, both)), vec!["sketch.mirror"]); + assert!(commands_for(&a, &key_name(egui::Key::M, extra)).is_empty()); + } + #[test] fn shortcut_page_only_builds_visible_rows_and_reveals_filtered_rows() { let mut h = crate::scenario::Harness::new(); @@ -536,6 +984,25 @@ mod card_tests { use super::*; use crate::scenario::{self, Harness}; + #[test] + fn live_chord_column_fits_all_modifiers() { + let mut h = Harness::new(); + h.app.home.open = false; + h.app.keymap.filter = "sketch.mirror".into(); + h.call("ui.prefs", json!({"open":"Keyboard Shortcuts"})); + h.step(&json!({"click":{"handle":"keymap:capture:sketch.mirror"}})).unwrap(); + h.step(&json!({"key":"M","pressed":true,"shift":true,"cmd":true,"ctrl":true,"alt":true})).unwrap(); + let preview = h.app.keymap.recorder.preview().unwrap(); + let cell = scenario::rect_of("dialog:table:keymap:sketch.mirror:cell:Keyboard Shortcuts").unwrap(); + let ctx = crate::fonts::test_context(); + let mut needed = 0.0; + let mut out = ctx.run_ui(Default::default(), |ui| { + ui_kit::scope(ui, |ui| needed = ui_kit::ContentWidth::button(ui, &preview)); + }); + out.textures_delta.clear(); + assert!(cell.width() >= needed, "preview {preview}: cell {} < {needed}", cell.width()); + } + #[test] fn shortcut_card_captures_clears_resets_and_esc_cancels_capture_only() { let mut h = Harness::new(); @@ -546,6 +1013,9 @@ mod card_tests { h.step(&json!({"click":{"handle":format!("keymap:{action}:solid.extrude")}})).unwrap(); if action == "capture" { h.step(&json!({"key":"E","shift":true})).unwrap(); + assert_eq!(effective(&h.app, "solid.extrude").as_deref(), Some("E"), "retain the binding during the grace period"); + h.step(&json!({"key":"ShiftLeft","pressed":false})).unwrap(); + h.frames(65); } assert_eq!(effective(&h.app, "solid.extrude").unwrap_or_default(), expected); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 83eeb13b91..2fe6fa3a08 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -34,6 +34,8 @@ pub mod dialogs; pub mod dialogs_appearance; pub mod dialogs_assembly; pub mod dialogs_coupling; +pub mod dialogs_frame; +mod dialogs_hole; pub mod dialogs_library; pub mod dialogs_motion; pub mod dialogs_move; @@ -62,8 +64,14 @@ pub mod gpu; pub mod help; #[cfg(test)] mod hole_direction_ui_tests; +pub(crate) mod hole_place; +#[cfg(test)] +mod hole_place_ui_tests; #[cfg(test)] mod hole_placement_ui_tests; +pub(crate) mod hole_refs; +#[cfg(test)] +mod hole_refs_ui_tests; pub mod home; pub mod i18n; #[cfg(test)] @@ -94,6 +102,7 @@ mod measurement_ui_tests; mod menu_tests; pub(crate) mod mesh_lod; pub mod messages; +pub mod occurrence_drag; #[cfg(test)] mod pattern_arrow_tests; #[cfg(test)] @@ -137,6 +146,8 @@ mod status_tests { mod apply_off_thread_tests; mod expression_complete; #[cfg(test)] +mod extrude_auto_to_object_tests; +#[cfg(test)] mod extrude_edit_tests; mod extrude_preview; mod file_load; @@ -223,9 +234,12 @@ pub mod smart_extrude; mod smart_extrude_ui_tests; pub mod space_mouse; pub mod space_mouse_calibration; +pub mod space_mouse_model; +pub mod space_mouse_offer; pub mod stand_up; #[cfg(test)] mod sweep_dialog_tests; +pub(crate) mod test_boat; pub mod theme; pub mod theme_files; #[cfg(test)] @@ -262,6 +276,10 @@ pub struct UiState { pub layout: shell::LayoutMode, /// Ribbon tab; SHEET METAL and the contextual SKETCH join the seven design tabs. pub tab: String, + /// The toolbar tab a sketch was started from; finishing the sketch goes back to it (#3250). + /// Not saved: it only lives while a sketch is open. + #[serde(skip)] + pub sketch_return_tab: Option, /// Last contextual body/sketch, only used to select on entry. #[serde(skip)] pub ribbon_context: Option, @@ -300,6 +318,7 @@ impl Default for UiState { layout: Default::default(), tab: "MODEL".into(), ribbon_context: None, + sketch_return_tab: None, show_browser: true, show_timeline: true, show_grid: true, @@ -840,6 +859,14 @@ impl SolveApp { } // Curves drawn in construction (or centerline) mode take that line type. sketch_palette::apply_mode(self, id, v); + // Locks follow a body's rename, from the rename box or anywhere else. + if id == "body.rename" + && let (Some(old), Some(new)) = (v.get("body").and_then(Value::as_str), v.get("name").and_then(Value::as_str)) + { + for b in self.ui.locked_bodies.iter_mut().filter(|b| *b == old) { + *b = new.to_string(); + } + } } self.finish_command(id, &r); r @@ -1038,6 +1065,10 @@ impl SolveApp { self.finish_sketch(); return; } + if id == "sketch.cancel" { + self.cancel_sketch(); + return; + } // Press Pull: edges get a fillet, faces and profiles an extrude. if id == "solid.press_pull" { let edges = self.session.selection.iter().any(|s| matches!(s, solidcraft_engine::Sel::Edge { .. })); @@ -1185,13 +1216,25 @@ impl SolveApp { /// Finish the active sketch: stop editing and restore the history marker Edit Sketch moved. /// The camera stays where it is, however the sketch is left. pub fn finish_sketch(&mut self) { + self.leave_sketch("sketch.finish"); + } + + /// Cancel Sketch (#3838): leave the sketch without its changes. When the command refuses (the + /// undo history no longer leads back to where the sketch was opened), the sketch stays open. + pub fn cancel_sketch(&mut self) { + self.leave_sketch("sketch.cancel"); + } + + fn leave_sketch(&mut self, command: &str) { sketch_text_move::cancel_drag(self); if sketch_text::editing(self) || self.dialog.as_ref().is_some_and(|d| matches!(d.kind, dialogs::Kind::TextMove(_))) { dialogs::cancel(self); } dim_view::cancel(); self.tool = None; - let _ = self.run("sketch.finish", json!({})); + if self.run(command, json!({})).is_err() && command != "sketch.finish" { + return; + } if let Some(marker) = self.pre_sketch_marker.take() { self.restore_marker(marker); } @@ -1530,6 +1573,10 @@ impl SolveApp { self.collect_screenshots(ctx); self.issue_screenshots(ctx); if self.issue_report.draft.is_none() && self.file_picker.is_none() { + // Completion owns its keys before either CAD shortcuts or text editors see them. + if !keymap::capturing(self) { + expression_complete::begin_frame(ctx); + } toolbar::shortcuts(self, ctx); } recovery_ui::tick(self); @@ -1592,7 +1639,6 @@ impl SolveApp { /// Lay out the whole window. pub fn ui(&mut self, ui: &mut egui::Ui) { - expression_complete::begin_frame(ui.ctx()); let t0 = now_ms(); // The first frames draw with the bundled faces while the system's CJK, Hangul and Thai // faces are read on a thread; the fonts are installed again once they are there. diff --git a/crates/ui-egui/src/linked_source.rs b/crates/ui-egui/src/linked_source.rs index 55cc0b3a21..a090315685 100644 --- a/crates/ui-egui/src/linked_source.rs +++ b/crates/ui-egui/src/linked_source.rs @@ -251,7 +251,7 @@ mod tests { let folder = std::env::temp_dir().join(format!("solidcraft-extract-ui-{}", std::process::id())); std::fs::create_dir_all(&folder).unwrap(); let mut app = SolveApp::new(Session::default(), Default::default()); - let made = app.run("component.create", json!({"name": "Bracket"})).unwrap(); + let made = app.run("component.create", json!({"name": "Bracket", "grounded": false})).unwrap(); let (component, occurrence) = (made["component"].as_u64().unwrap(), made["occurrence"].as_u64().unwrap()); app.run("solid.box", json!({"length": 10, "width": 20, "height": 30})).unwrap(); let find = |app: &SolveApp, id: u64| context_menu::items(app, &Target::Component { id }).into_iter().find(|i| i.id == "ui.extractComponent"); diff --git a/crates/ui-egui/src/menu_tests.rs b/crates/ui-egui/src/menu_tests.rs index 70a6d3ce3e..37b976968b 100644 --- a/crates/ui-egui/src/menu_tests.rs +++ b/crates/ui-egui/src/menu_tests.rs @@ -4,8 +4,10 @@ use crate::test_support::sample_app; use egui::pos2; use serde_json::json; use solidcraft_engine::Session; +use solidcraft_engine::geom::Vec3; use crate::context_menu::{self, Item, Target}; +use crate::viewport::Hit; use crate::{Services, SolveApp}; #[test] @@ -135,6 +137,29 @@ fn a_selected_face_keeps_the_body_menu_beside_a_sketch_curve() { assert!(!items.iter().any(|i| i.label == "Redefine Sketch Plane"), "the face menu, not the sketch menu"); } +/// A right-click on a face or edge that is part of a multi-selection keeps the whole selection: +/// the pick keeps a point of its own, so the face is compared by body and index (#3733). +#[test] +fn right_click_on_a_selected_face_keeps_the_multi_selection() { + let mut app = sample_app(); + let b = body(&app); + app.run( + "select.set", + json!({"items": [ + {"type": "face", "body": b, "index": 0, "point": [1, 2, 3]}, + {"type": "face", "body": b, "index": 1, "point": [4, 5, 6]}, + ]}), + ) + .unwrap(); + app.viewport.hover = Some(Hit::Face { body: b.clone(), index: 0, point: Vec3::new(9.0, 9.0, 9.0) }); + context_menu::open(&mut app, pos2(400.0, 300.0)); + assert_eq!(app.session.selection.len(), 2, "the right-click keeps the faces it is about: {:?}", app.session.selection); + // A face outside the selection is still picked, so its menu is about the face under the cursor. + app.viewport.hover = Some(Hit::Face { body: b, index: 2, point: Vec3::new(9.0, 9.0, 9.0) }); + context_menu::open(&mut app, pos2(400.0, 300.0)); + assert_eq!(app.session.selection.len(), 1, "a face outside the selection is picked: {:?}", app.session.selection); +} + #[test] fn face_menu_hides_and_finds_the_body() { let mut app = sample_app(); @@ -254,6 +279,11 @@ fn component_menu_activates_grounds_and_pastes() { let t = Target::Component { id: c }; act(&mut app, &t, "Activate"); assert_eq!(app.session.active_component, c); + // The design's first part comes in grounded (#2804); the menu frees it and grounds it again. + assert!(app.session.doc.occurrence_of(c).unwrap().grounded); + act(&mut app, &t, "Unground"); + assert!(!app.session.doc.occurrence_of(c).unwrap().grounded); + assert!(find(&context_menu::items(&app, &t), "Move/Copy").enabled, "free"); act(&mut app, &t, "Ground"); assert!(app.session.doc.occurrence_of(c).unwrap().grounded); assert!(!find(&context_menu::items(&app, &t), "Move/Copy").enabled, "grounded"); @@ -401,6 +431,82 @@ fn delete_key_handles_every_kind_and_asks_about_dependents() { assert_eq!(app.session.doc.features.len(), n); } +#[test] +fn browser_feature_menu_deletes_its_target_and_supports_undo() { + let mut app = SolveApp::new(Session::default(), Services::default()); + for (name, x) in [("Keep", 0), ("Delete", 30)] { + app.run("solid.box", json!({"length":10,"width":10,"height":10,"corner":[x,0,0],"body_name":name})).unwrap(); + } + let keep = app.session.doc.features[0].id; + let target = app.session.doc.features[1].id; + // A Browser context menu acts on its row, even when another feature is selected. + app.session.selection = vec![solidcraft_engine::Sel::Feature { id: keep }]; + let saved = app.session.doc.to_json(); + let undo = app.session.undo.len(); + act(&mut app, &Target::Feature { id: target }, "Delete"); + assert!(app.menu.confirm.is_none()); + assert!(app.session.doc.feature(keep).is_some()); + assert!(app.session.doc.feature(target).is_none()); + assert_eq!(app.session.undo.len(), undo + 1); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc.to_json(), saved); + app.run("edit.redo", json!({})).unwrap(); + assert!(app.session.doc.feature(keep).is_some()); + assert!(app.session.doc.feature(target).is_none()); + assert!(!find(&context_menu::items(&app, &Target::Feature { id: target }), "Delete").enabled); + assert!(!find(&context_menu::items(&app, &Target::Feature { id: target }), "Rename").enabled); +} + +#[test] +fn browser_feature_menu_renames_its_target_and_supports_undo() { + let mut app = SolveApp::new(Session::default(), Services::default()); + for x in [0, 30] { + app.run("solid.box", json!({"length":10,"width":10,"height":10,"corner":[x,0,0]})).unwrap(); + } + let keep = app.session.doc.features[0].id; + let target = app.session.doc.features[1].id; + let keep_name = app.session.doc.feature(keep).unwrap().name.clone(); + let target_name = app.session.doc.feature(target).unwrap().name.clone(); + app.session.selection = vec![solidcraft_engine::Sel::Feature { id: keep }]; + let saved = app.session.doc.to_json(); + let undo = app.session.undo.len(); + act(&mut app, &Target::Feature { id: target }, "Rename"); + assert_eq!(app.menu.rename.as_ref().unwrap().text, target_name); + assert!(context_menu::finish_rename(&mut app, Some("Cancelled"), false)); + assert_eq!(app.session.doc.to_json(), saved); + assert_eq!(app.session.undo.len(), undo); + act(&mut app, &Target::Feature { id: target }, "Rename"); + assert!(context_menu::finish_rename(&mut app, Some("Renamed feature"), true)); + assert!(app.menu.rename.is_none()); + assert_eq!(app.session.doc.feature(target).unwrap().name, "Renamed feature"); + assert_eq!(app.session.doc.feature(keep).unwrap().name, keep_name); + assert_eq!(app.session.undo.len(), undo + 1); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc.to_json(), saved); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(app.session.doc.feature(target).unwrap().name, "Renamed feature"); +} + +#[test] +fn browser_feature_menu_confirms_dependent_changes_before_deleting() { + let mut app = sample_app(); + let target = app.session.doc.features[1].id; + let saved = app.session.doc.to_json(); + let undo = app.session.undo.len(); + act(&mut app, &Target::Feature { id: target }, "Delete"); + assert!(app.menu.confirm.is_some(), "later features depend on the plate's extrusion"); + assert_eq!(app.session.doc.to_json(), saved); + assert_eq!(app.session.undo.len(), undo); + assert!(crate::delete::confirm(&mut app, false)); + assert_eq!(app.session.doc.to_json(), saved); + act(&mut app, &Target::Feature { id: target }, "Delete"); + assert!(crate::delete::confirm(&mut app, true)); + assert!(app.session.doc.feature(target).is_none()); + assert_eq!(app.session.undo.len(), undo + 1); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.session.doc.to_json(), saved); +} + #[test] fn menu_delete_items_use_the_same_path() { let mut app = sample_app(); @@ -608,6 +714,7 @@ fn an_uncaptured_move_asks_before_the_next_command() { let b = body(&app); app.run("component.from_bodies", json!({"bodies": [b]})).unwrap(); let occ = app.session.doc.occurrences[0].id; + app.run("occurrence.ground", json!({"occurrence": occ, "grounded": false})).unwrap(); app.run("occurrence.move", json!({"occurrence": occ, "translate": [10, 0, 0], "capture": false})).unwrap(); app.start("sketch.create"); assert_eq!(app.tree.capture_prompt.as_deref(), Some("sketch.create"), "asks first"); @@ -647,6 +754,7 @@ fn occurrence_moves_turn_about_their_origin_then_move() { let b = body(&app); app.run("component.from_bodies", json!({"bodies": [b]})).unwrap(); let occ = app.session.doc.occurrences[0].id; + app.run("occurrence.ground", json!({"occurrence": occ, "grounded": false})).unwrap(); let mut m = crate::browser::OccMove::new(occ); m.pivot = Some(solidcraft_engine::geom::Vec3::new(5.0, 0.0, 0.0)); m.translate = [10.0, 0.0, 0.0]; diff --git a/crates/ui-egui/src/navigation.rs b/crates/ui-egui/src/navigation.rs index 3988cdb25d..eabf858983 100644 --- a/crates/ui-egui/src/navigation.rs +++ b/crates/ui-egui/src/navigation.rs @@ -101,6 +101,7 @@ pub(crate) struct RawWheel { point_zoom: f32, /// Bracketed two-finger scroll in points (pans when panning is on). pan: egui::Vec2, + shift_pan: egui::Vec2, /// Bare horizontal input in points (thumb wheel: always pans sideways). mouse_pan: egui::Vec2, } @@ -124,7 +125,7 @@ pub(crate) fn route_wheel_vertical(points: egui::Vec2, modifiers: egui::Modifier /// moves inside one pan, bare `Move` input is a mouse (vertical zooms, /// horizontal pans). Only point input brackets; line/page input always zooms. pub(crate) fn immediate_wheel(input: &egui::InputState, options: egui::InputOptions, page_height: f32, gesture: &mut bool) -> RawWheel { - let mut out = RawWheel { zoom: 0.0, point_zoom: 0.0, pan: egui::Vec2::ZERO, mouse_pan: egui::Vec2::ZERO }; + let mut out = RawWheel { zoom: 0.0, point_zoom: 0.0, pan: egui::Vec2::ZERO, shift_pan: egui::Vec2::ZERO, mouse_pan: egui::Vec2::ZERO }; for event in &input.events { let egui::Event::MouseWheel { unit, delta, modifiers, phase } = event else { continue }; if modifiers.matches_any(options.zoom_modifier) { @@ -152,7 +153,13 @@ pub(crate) fn immediate_wheel(input: &egui::InputState, options: egui::InputOpti egui::MouseWheelUnit::Page => *delta * page_height, }; if point && *gesture { - out.pan += points; + if modifiers.shift && !modifiers.ctrl && !modifiers.command && !modifiers.alt { + if points.is_finite() { + out.shift_pan += points.clamp(vec2(-1e4, -1e4), vec2(1e4, 1e4)); + } + } else { + out.pan += points; + } // Legacy path only (ignored while panning is on). out.point_zoom += route_wheel_vertical(points, *modifiers, options); } else if point { @@ -405,7 +412,8 @@ pub(crate) fn handle(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect, resp: &e let options = ui.ctx().options(|o| o.input_options); let page_height = ui.ctx().viewport_rect().height(); let raw = ui.input(|i| immediate_wheel(i, options, page_height, &mut app.viewport.trackpad_gesture)); - if trackpad_pan && raw.pan != egui::Vec2::ZERO { + let trackpad_orbit = trackpad_pan && app.preferences.nav == "fusion"; + if trackpad_pan && (raw.pan != egui::Vec2::ZERO || raw.shift_pan != egui::Vec2::ZERO) { // Swallow egui's smoothing tail while the trackpad runs (a wheel-only // tail ends the moment trackpad input starts; mixed input is rare). app.viewport.trackpad_until_idle = true; @@ -415,7 +423,8 @@ pub(crate) fn handle(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect, resp: &e // (its End was lost); a stuck flag would pan future mouse input. app.viewport.trackpad_gesture = false; } - let mut pan = if trackpad_pan { raw.pan } else { egui::Vec2::ZERO } + raw.mouse_pan; + let mut pan = + if trackpad_pan { raw.pan + if trackpad_orbit { egui::Vec2::ZERO } else { raw.shift_pan } } else { egui::Vec2::ZERO } + raw.mouse_pan; pan.x *= crate::prefs::pan_sign_x(app); let scroll = if app.viewport.wheel_zoom_raw_until_idle || (trackpad_pan && app.viewport.trackpad_until_idle) { raw.zoom + if trackpad_pan { 0.0 } else { raw.point_zoom } @@ -466,6 +475,11 @@ pub(crate) fn handle(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect, resp: &e app.cam.pan(f64::from(pan.x), f64::from(pan.y), h); } } + if inside && caps.orbit && trackpad_orbit && raw.shift_pan != egui::Vec2::ZERO { + app.cancel_view_animation(); + app.viewport.orbit_free = None; + orbit(app, rect, f64::from(raw.shift_pan.x), f64::from(raw.shift_pan.y), hover); + } // A left press with the preset's keys (Alias: Shift+Alt) navigates until the button is let // go (kept through the release frame, so the end of the drag doesn't select either). if primary_pressed && inside { @@ -584,7 +598,7 @@ pub(crate) fn handle(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect, resp: &e } } } - if !dragging && motion.is_none() { + if !dragging && motion.is_none() && !(trackpad_orbit && app.viewport.trackpad_gesture) { app.viewport.orbit_pivot = None; app.viewport.orbit_from = None; } @@ -1288,6 +1302,21 @@ mod tests { } } assert!(panned, "{preset}: some binding pans"); + frame(&mut app, Modifiers::NONE, vec![Event::PointerMoved(at)]); + for modifiers in [Modifiers::NONE, Modifiers::SHIFT] { + let before = app.cam; + for phase in [egui::TouchPhase::Start, egui::TouchPhase::Move, egui::TouchPhase::End] { + frame( + &mut app, + modifiers, + vec![Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(20.0, -10.0), modifiers, phase }], + ); + } + assert_eq!(app.cam.back(), before.back(), "{preset}: trackpad turned a flat page"); + if preset == "fusion" && modifiers.shift { + assert_eq!(app.cam, before, "orbit gestures must do nothing on a flat page"); + } + } let before = app.cam; let wheel = Event::MouseWheel { unit: egui::MouseWheelUnit::Line, diff --git a/crates/ui-egui/src/occurrence_drag.rs b/crates/ui-egui/src/occurrence_drag.rs new file mode 100644 index 0000000000..80d19abb39 --- /dev/null +++ b/crates/ui-egui/src/occurrence_drag.rs @@ -0,0 +1,153 @@ +//! Dragging a free part puts it somewhere else (#2804): with the design (or the assembly holding +//! it) active, a left-button drag on a part moves its placement in that assembly, the one on the +//! picked body's exact path just inside the active placement. The point grabbed follows the +//! pointer in the plane through it that faces the view. Each frame runs `occurrence.drag`; the +//! whole drag is one undo step, and Escape puts everything back as it was. +//! +//! Floating chains move together whichever member is grabbed. Parts held +//! by joints to something fixed go to `joint_drag`; grounded parts and the active component's +//! own bodies are left to the selection box. + +use std::sync::Arc; + +use egui::Pos2; +use serde_json::json; +use solidcraft_engine::doc::{Document, apply_point, mat_inverse}; +use solidcraft_engine::geom::Vec3; + +use crate::SolveApp; +use crate::viewport::{Hit, Proj}; + +/// A drag under way. +#[derive(Clone, Debug)] +pub struct Drag { + /// The placement moved: its exact occurrence path. + path: Vec, + /// The point grabbed, in the placement's own coordinates. + local: Vec3, + /// The plane the pointer moves the point in: through where it was grabbed, facing the view. + origin: Vec3, + normal: Vec3, + /// What Escape goes back to (`edit.checkpoint`). + checkpoint: Option, + /// The drag's undo step is on top of the history (it went in with the first frame that + /// moved); the design it holds tells it from a step that replaced it. + step: Option>, +} + +/// The placement a drag on `body` moves: the one on its path just inside the active placement. +fn placement_of(app: &SolveApp, body: &str) -> Option> { + let s = &app.session; + let (_, _, path, _) = s.doc.body_frames(&s.model.state()).into_iter().find(|f| f.0 == body)?; + let active = if s.active_component == 0 { + Vec::new() + } else { + s.active_occurrence_path + .clone() + .filter(|p| s.occurrence_placement(p).is_ok_and(|x| x.component == s.active_component)) + .unwrap_or_else(|| s.doc.first_path(s.active_component)) + }; + if path.len() <= active.len() || !path.starts_with(&active) { + return None; + } + path.get(..active.len() + 1).map(<[u64]>::to_vec) +} + +/// Begin a drag on the part under `p` when nothing holds it in place. True when one began. +pub fn start(app: &mut SolveApp, proj: &Proj, p: Pos2) -> bool { + // The face under the pointer, past the part's own edges and vertices (not past sketches or + // construction geometry in front of it). + let hits = crate::viewport::pick(app, proj, p); + let Some((body, at)) = + hits.into_iter().take_while(|h| matches!(h, Hit::Face { .. } | Hit::Edge { .. } | Hit::Vertex { .. })).find_map(|h| match h { + Hit::Face { body, point, .. } => Some((body, point)), + _ => None, + }) + else { + return false; + }; + let Some(path) = placement_of(app, &body) else { return false }; + let s = &app.session; + if s.drag_group(&path).is_err() { + return false; + } + let Some(local) = s.occurrence_placement(&path).ok().and_then(|x| mat_inverse(&x.world)).map(|inv| apply_point(&inv, at)) else { + return false; + }; + let checkpoint = app.session.execute("edit.checkpoint", &json!({})).ok().and_then(|r| r["checkpoint"].as_u64()); + app.viewport.occurrence_drag = Some(Drag { path, local, origin: at, normal: proj.ray(p).1, checkpoint, step: None }); + true +} + +pub fn active(app: &SolveApp) -> bool { + app.viewport.occurrence_drag.is_some() +} + +/// Bring the grabbed point to the pointer at `p`. +pub fn to(app: &mut SolveApp, proj: &Proj, p: Pos2) { + let Some(d) = app.viewport.occurrence_drag.clone() else { return }; + // Something else took the drag's undo step (undo pressed mid-drag): the drag ends there. + if let Some(doc) = &d.step + && !app.session.undo.last().is_some_and(|step| Arc::ptr_eq(&step.doc, doc)) + { + end(app); + return; + } + let (o, dir) = proj.ray(p); + let facing = dir.dot(d.normal); + if facing.abs() < 1e-9 { + return; + } + let target = o + dir * ((d.origin - o).dot(d.normal) / facing); + let Ok(placement) = app.session.occurrence_placement(&d.path) else { + end(app); + return; + }; + let at = apply_point(&placement.world, d.local); + if !target.is_finite() || !at.is_finite() || target.dist(at) < 1e-9 { + return; + } + let params = json!({"path": d.path, "at": [at.x, at.y, at.z], "point": [target.x, target.y, target.z], "continuous": true}); + // One undo step for the whole drag: the first frame's, kept on top while the others run. + let own = if d.step.is_some() { app.session.undo.pop() } else { None }; + let before = app.session.doc.clone(); + let pushed = app.run("occurrence.drag", params).is_ok() && !Arc::ptr_eq(&before, &app.session.doc); + match own { + Some(step) => { + if pushed { + app.session.undo.pop(); + } + app.session.undo.push(step); + } + None if pushed => { + let step = app.session.undo.last().map(|step| step.doc.clone()); + if let Some(d) = app.viewport.occurrence_drag.as_mut() { + d.step = step; + } + } + None => {} + } +} + +/// Let go: the part stays where it was dragged. +pub fn end(app: &mut SolveApp) { + if let Some(Drag { checkpoint: Some(checkpoint), .. }) = app.viewport.occurrence_drag.take() { + let _ = app.session.execute("edit.restore_checkpoint", &json!({"checkpoint": checkpoint, "forget": true})); + } +} + +/// Escape: the part goes back, and the drag leaves no undo step. True when a drag was cancelled. +pub fn cancel(app: &mut SolveApp) -> bool { + let Some(d) = app.viewport.occurrence_drag.take() else { return false }; + match d.checkpoint { + Some(checkpoint) => drop(app.session.execute("edit.restore_checkpoint", &json!({"checkpoint": checkpoint}))), + // Without a checkpoint the drag's own step takes it back. + None if d.step.is_some() => drop(app.session.execute("edit.undo", &json!({}))), + None => {} + } + true +} + +#[cfg(test)] +#[path = "occurrence_drag_tests.rs"] +mod tests; diff --git a/crates/ui-egui/src/occurrence_drag_tests.rs b/crates/ui-egui/src/occurrence_drag_tests.rs new file mode 100644 index 0000000000..f35df26627 --- /dev/null +++ b/crates/ui-egui/src/occurrence_drag_tests.rs @@ -0,0 +1,200 @@ +//! A left-button drag on a free part repositions it (#2804). +use serde_json::{Value, json}; +use solidcraft_engine::geom::Vec3; + +use crate::scenario::Harness; + +fn run(h: &mut Harness, id: &str, p: Value) -> Value { + h.app.run(id, p.clone()).unwrap_or_else(|e| panic!("{id} {p}: {e}")) +} + +fn screen(h: &mut Harness, p: Vec3) -> egui::Pos2 { + let [x, y] = h.at(&json!({"world": [p.x, p.y, p.z]})).expect("on screen"); + egui::pos2(x as f32, y as f32) +} + +/// Where the placement `path` puts its origin. +fn origin(h: &Harness, path: &[u64]) -> Vec3 { + let w = h.app.session.occurrence_placement(path).expect("placement").world; + Vec3::new(w[3][0], w[3][1], w[3][2]) +} + +/// A grounded base (the design's first part) and a free 10 mm cube beside it, seen from the top: +/// (harness, base occurrence, free occurrence). +fn two_parts() -> (Harness, u64, u64) { + let mut h = Harness::new(); + h.app.home.open = false; + let mut occurrences = Vec::new(); + for (name, corner) in [("Base", [-5, -5, 0]), ("Free", [20, 0, 0])] { + run(&mut h, "component.activate", json!({"component": "root"})); + occurrences.push(run(&mut h, "component.create", json!({"name": name}))["occurrence"].as_u64().expect("occurrence")); + run(&mut h, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": corner})); + } + run(&mut h, "component.activate", json!({"component": "root"})); + h.call("ui.view", json!({"view": "top"})); + h.app.cam.distance = 200.0; + h.frames(3); + (h, occurrences[0], occurrences[1]) +} + +fn play(h: &mut Harness) { + while !h.app.synthetic.is_empty() { + h.frame(); + } + h.frames(2); +} + +/// Press at `a` (holding `modifiers`) and move to `b` in 12 frames, holding the button there. +fn press_and_move(h: &mut Harness, a: egui::Pos2, b: egui::Pos2, modifiers: egui::Modifiers) { + h.app.synthetic.push(egui::Event::PointerMoved(a)); + h.app.synthetic.push(egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers }); + h.app.synthetic.extend((1..=12).map(|k| egui::Event::PointerMoved(a + (b - a) * (k as f32 / 12.0)))); + play(h); +} + +fn release(h: &mut Harness, b: egui::Pos2) { + h.app.synthetic.push(egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + play(h); +} + +/// Dragged by its top face, a free part follows the pointer in the plane facing the view, in one +/// undo step that undo takes back; frames are not echoed one by one. +#[test] +fn dragging_a_free_part_moves_it_in_one_undo_step() { + let (mut h, base, free) = two_parts(); + assert!(h.app.session.doc.occurrences.iter().any(|o| o.id == base && o.grounded), "the first part is grounded"); + let undo = h.app.session.undo.len(); + let (a, b) = (screen(&mut h, Vec3::new(25.0, 5.0, 10.0)), screen(&mut h, Vec3::new(25.0, 35.0, 10.0))); + press_and_move(&mut h, a, b, Default::default()); + assert!(h.app.viewport.occurrence_drag.is_some() && h.app.viewport.boxsel.is_none(), "the drag moves the part, not a selection box"); + release(&mut h, b); + assert!(h.app.viewport.occurrence_drag.is_none()); + assert!(origin(&h, &[free]).dist(Vec3::new(0.0, 30.0, 0.0)) < 0.5, "{:?}", origin(&h, &[free])); + assert!(origin(&h, &[base]).dist(Vec3::ZERO) < 1e-9, "the grounded part stays"); + assert_eq!(h.app.session.undo.len(), undo + 1, "one undo step for the drag"); + assert_eq!(h.app.session.undo.last().map(|s| s.label.as_str()), Some("Drag Occurrence")); + assert!(h.app.session.pending_moves.is_empty(), "the position is captured"); + assert!(!h.app.session.log.iter().any(|l| l.contains("Drag Occurrence")), "frames are not echoed: {:?}", h.app.session.log); + run(&mut h, "edit.undo", json!({})); + assert!(origin(&h, &[free]).dist(Vec3::ZERO) < 1e-9, "{:?}", origin(&h, &[free])); +} + +/// A drag on the grounded part, from empty space, or with Shift, Ctrl or Alt held on a free part +/// draws a selection box and moves nothing. +#[test] +fn grounded_parts_empty_space_and_modifier_drags_draw_a_selection_box() { + let (mut h, base, free) = two_parts(); + let cases = [ + (Vec3::new(0.0, 0.0, 10.0), egui::Modifiers::NONE), + (Vec3::new(-40.0, 40.0, 0.0), egui::Modifiers::NONE), + (Vec3::new(25.0, 5.0, 10.0), egui::Modifiers::SHIFT), + (Vec3::new(25.0, 5.0, 10.0), egui::Modifiers::CTRL), + (Vec3::new(25.0, 5.0, 10.0), egui::Modifiers { command: true, mac_cmd: true, ..Default::default() }), + (Vec3::new(25.0, 5.0, 10.0), egui::Modifiers::ALT), + ]; + for (at, modifiers) in cases { + let undo = h.app.session.undo.len(); + let a = screen(&mut h, at); + let b = a + egui::vec2(60.0, 40.0); + press_and_move(&mut h, a, b, modifiers); + assert!(h.app.viewport.occurrence_drag.is_none() && h.app.viewport.boxsel.is_some(), "{at:?} {modifiers:?}: a selection box"); + release(&mut h, b); + assert!(origin(&h, &[base]).dist(Vec3::ZERO) < 1e-9 && origin(&h, &[free]).dist(Vec3::ZERO) < 1e-9, "{at:?} {modifiers:?}"); + assert_eq!(h.app.session.undo.len(), undo, "{at:?} {modifiers:?}"); + } +} + +/// Escape during a drag puts the part back and leaves no undo step. +#[test] +fn escape_cancels_a_drag() { + let (mut h, _, free) = two_parts(); + let undo = h.app.session.undo.len(); + let (a, b) = (screen(&mut h, Vec3::new(25.0, 5.0, 10.0)), screen(&mut h, Vec3::new(25.0, 35.0, 10.0))); + press_and_move(&mut h, a, b, Default::default()); + assert!(origin(&h, &[free]).dist(Vec3::ZERO) > 1.0, "it moved while dragged"); + for pressed in [true, false] { + h.app.synthetic.push(egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed, repeat: false, modifiers: Default::default() }); + } + play(&mut h); + assert!(h.app.viewport.occurrence_drag.is_none()); + assert!(origin(&h, &[free]).dist(Vec3::ZERO) < 1e-9, "{:?}", origin(&h, &[free])); + release(&mut h, b); + assert!(origin(&h, &[free]).dist(Vec3::ZERO) < 1e-9, "the release after Escape moves nothing"); + assert_eq!(h.app.session.undo.len(), undo); +} + +/// With the design active, a drag on a part inside an assembly moves that assembly's placement; +/// with the assembly active, the part inside it. +#[test] +fn the_active_placement_decides_which_occurrence_moves() { + let (mut h, _, free) = two_parts(); + let asm = h.app.session.doc.occurrences.iter().find(|o| o.id == free).map_or(0, |o| o.component); + run(&mut h, "component.activate", json!({"component": asm})); + let leaf = run(&mut h, "component.create", json!({"name": "Leaf"}))["occurrence"].as_u64().expect("occurrence"); + run(&mut h, "solid.box", json!({"length": 4, "width": 4, "height": 4, "corner": [40, 0, 0]})); + run(&mut h, "component.activate", json!({"component": "root"})); + h.frames(2); + let (a, b) = (screen(&mut h, Vec3::new(42.0, 2.0, 4.0)), screen(&mut h, Vec3::new(42.0, 22.0, 4.0))); + press_and_move(&mut h, a, b, Default::default()); + release(&mut h, b); + assert!(origin(&h, &[free]).dist(Vec3::new(0.0, 20.0, 0.0)) < 0.5, "the assembly moves: {:?}", origin(&h, &[free])); + assert!(origin(&h, &[free, leaf]).dist(Vec3::new(0.0, 20.0, 0.0)) < 0.5, "with its part"); + run(&mut h, "component.activate", json!({"component": asm, "occurrence": free, "path": [free]})); + h.frames(2); + let (a, b) = (screen(&mut h, Vec3::new(42.0, 22.0, 4.0)), screen(&mut h, Vec3::new(52.0, 22.0, 4.0))); + press_and_move(&mut h, a, b, Default::default()); + release(&mut h, b); + assert!(origin(&h, &[free]).dist(Vec3::new(0.0, 20.0, 0.0)) < 0.5, "the active assembly stays: {:?}", origin(&h, &[free])); + assert!(origin(&h, &[free, leaf]).dist(Vec3::new(10.0, 20.0, 0.0)) < 0.5, "its part moves: {:?}", origin(&h, &[free, leaf])); +} + +#[test] +fn either_member_of_a_floating_hinge_drags_the_whole_chain() { + let (mut h, base, free) = two_parts(); + run(&mut h, "occurrence.ground", json!({"occurrence": base, "grounded": false})); + let at = |o| json!({"occurrence": o, "point": [0, 0, 0], "z": [0, 0, 1]}); + run(&mut h, "joint.create", json!({"type": "revolute", "a": at(base), "b": at(free), "flip": true})); + // The base's centre is under the joint glyph; grab its face away from that glyph. + for grab in [Vec3::new(25.0, 5.0, 10.0), Vec3::new(4.0, 4.0, 10.0)] { + h.frames(2); + h.step(&json!({"shot": "floating-chain-before"})).unwrap(); + let before = h.app.session.doc.to_json(); + let values = h.app.session.doc.assembly.joints[0].values.clone(); + let undo = h.app.session.undo.len(); + let (a, b) = (screen(&mut h, grab), screen(&mut h, grab + Vec3::new(0.0, 30.0, 0.0))); + press_and_move(&mut h, a, b, Default::default()); + assert!( + h.app.viewport.occurrence_drag.is_some(), + "grab={grab:?}, base={:?}, free={:?}, group={:?}, joint={:?}, box={:?}", + origin(&h, &[base]), + origin(&h, &[free]), + h.app.session.drag_group(&[free]), + h.app.viewport.joint_drag, + h.app.viewport.boxsel + ); + assert!(h.app.viewport.joint_drag.is_none()); + release(&mut h, b); + h.step(&json!({"shot": "floating-chain-after"})).unwrap(); + for o in [base, free] { + assert!(origin(&h, &[o]).dist(Vec3::new(0.0, 30.0, 0.0)) < 0.5); + } + assert_eq!(h.app.session.doc.assembly.joints[0].values, values); + assert_eq!(h.app.session.undo.len(), undo + 1); + run(&mut h, "edit.undo", json!({})); + assert_eq!(h.app.session.doc.to_json(), before); + } +} + +fn key(h: &mut Harness, key: egui::Key, pressed: bool) { + h.app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Default::default() }); + play(h); +} + +#[test] +fn r_still_starts_rectangle_with_a_free_part_in_the_design() { + let (mut h, _, _) = two_parts(); + key(&mut h, egui::Key::R, true); + key(&mut h, egui::Key::R, false); + assert!(matches!(h.app.dialog.as_ref().map(|d| &d.kind), Some(crate::dialogs::Kind::Sketch)), "R starts Rectangle through Create Sketch"); + assert_eq!(h.app.dialog.as_ref().and_then(|d| d.then_start), Some("sketch.rectangle.two_point")); +} diff --git a/crates/ui-egui/src/occurrence_gizmo_tests.rs b/crates/ui-egui/src/occurrence_gizmo_tests.rs index bbc237d3b4..1e8a6b9881 100644 --- a/crates/ui-egui/src/occurrence_gizmo_tests.rs +++ b/crates/ui-egui/src/occurrence_gizmo_tests.rs @@ -9,7 +9,7 @@ use solidcraft_engine::{Sel, doc, geom::Vec3}; #[test] fn occurrence_gizmo_drag_matches_numeric_input_and_preserves_joints_capture_and_undo() { let mut h = Harness::new(); - let part = h.app.run("component.create", json!({"name":"Block"})).unwrap(); + let part = h.app.run("component.create", json!({"name":"Block","grounded":false})).unwrap(); let first = part["occurrence"].as_u64().unwrap(); h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap(); h.app.run("component.activate", json!({"component":0})).unwrap(); diff --git a/crates/ui-egui/src/occurrence_ui_tests.rs b/crates/ui-egui/src/occurrence_ui_tests.rs index 3c2d9539fc..e6f6b96329 100644 --- a/crates/ui-egui/src/occurrence_ui_tests.rs +++ b/crates/ui-egui/src/occurrence_ui_tests.rs @@ -40,6 +40,21 @@ fn click(h: &mut Harness, path: &[u64], control: &str) { assert_eq!(result["ok"], true); } +#[test] +fn save_mesh_browser_pick_uses_the_body_of_the_clicked_placement() { + let (mut h, _, first, second) = fixture(); + h.app.tree.collapsed.insert(super::occurrence_key(&[first])); + let expected = h.app.session.occurrence_bodies(&[second], true).unwrap(); + h.app.start("file.save_mesh"); + h.settle(); + h.step(&json!({"click":{"handle":"row:Body1"}})).unwrap(); + let d = h.app.dialog.as_ref().unwrap(); + let crate::dialogs::Kind::SaveMesh(k) = &d.kind else { panic!("Save as Mesh is open") }; + let commands = crate::dialogs_save_mesh::commands(k, &d.inputs).unwrap(); + assert_eq!(commands[0].1["bodies"], json!(expected)); + assert_ne!(expected, h.app.session.occurrence_bodies(&[first], true).unwrap()); +} + #[test] fn occurrence_second_row_selects_exact_path_and_menu_actions() { let (mut h, component, first, second) = fixture(); diff --git a/crates/ui-egui/src/params_dialog.rs b/crates/ui-egui/src/params_dialog.rs index e67ce5a44d..8294c794b3 100644 --- a/crates/ui-egui/src/params_dialog.rs +++ b/crates/ui-egui/src/params_dialog.rs @@ -4,6 +4,7 @@ //! (name, unit, expression, value, comment; add, rename, delete) and model parameters grouped by //! the sketch or feature they drive (editable). Expressions validate as you type //! (`expr.evaluate`) with a live model preview; Enter or leaving the field applies them. +//! Escape closes the window and discards pending edits and their preview. //! Import and export go through `parameters.import` / `parameters.export`. //! //! [`complete`] adds suggestions (parameters, functions, units) under any expression box: the @@ -16,6 +17,10 @@ use serde_json::{Value, json}; use crate::SolveApp; use crate::ui_kit::{self, Column}; +/// What Import reads. The picker must list them: on macOS, files of a type no filter names +/// can't be picked (#3216). +pub(crate) const IMPORT_TYPES: &[&str] = &["csv", "json"]; + const UNITS: [&str; 9] = ["mm", "cm", "m", "in", "ft", "deg", "rad", "", "um"]; /// Dialog state (what is being typed; the parameters themselves live in the document). @@ -150,6 +155,21 @@ pub fn open(app: &mut SolveApp) { app.params = Some(ParamsDialog::new()); } +/// Cancel before drawing the editors: egui has already blurred the focused TextEdit on Esc, +/// and drawing it would apply its pending value through `lost_focus`. +pub(crate) fn cancel_on_escape(app: &mut SolveApp, ctx: &egui::Context) -> bool { + if app.home.open || app.params.is_none() || !ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { + return false; + } + ctx.input_mut(|i| { + i.raw.events.retain(|event| !matches!(event, egui::Event::Key { key: egui::Key::Escape, modifiers, .. } if modifiers.matches_logically(egui::Modifiers::NONE))); + }); + // Dropping the dialog discards every draft. The regular preview update clears its scene. + app.params = None; + app.esc_handled = true; + true +} + /// Draw the dialog when it is open. Called once per frame. pub fn show(app: &mut SolveApp, ctx: &egui::Context) { // Completions are offered only under a focused text box; gathering them evaluates every @@ -223,7 +243,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { .on_hover_text(crate::i18n::tr_label("CSV or JSON: updates existing parameters, adds new ones")) .clicked() { - app.pick_open_command("parameters.import", json!({}), &[]); + app.pick_open_command("parameters.import", json!({}), IMPORT_TYPES); } for (label, ext) in [("Export CSV…", "csv"), ("Export JSON…", "json")] { if ui_kit::icon_label_button(ui, label, "export_tray", true, false).clicked() { @@ -755,6 +775,199 @@ pub fn complete(ui: &mut egui::Ui, resp: &egui::Response, text: &mut String) -> mod tests { use super::*; + fn escape_dialog() -> crate::scenario::Harness { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.app.run("parameters.add", json!({"name":"width","expression":"10 mm","comment":"Saved comment"})).unwrap(); + h.step(&json!({"start":"parameters.change"})).unwrap(); + h.frames(3); + h + } + + fn escape_state(h: &crate::scenario::Harness) -> Value { + let history = |steps: &[solidcraft_engine::Snapshot]| { + steps + .iter() + .map(|s| json!({"label":s.label,"document":s.doc.to_json(),"sketch":s.active_sketch,"moves":s.pending_moves})) + .collect::>() + }; + json!({ + "document":h.app.session.doc.to_json(), "revision":h.app.session.revision, + "undo":history(&h.app.session.undo), "redo":history(&h.app.session.redo), + "dirty":h.app.session.is_dirty(), "selection":h.app.session.selection + }) + } + + fn escape_type(h: &mut crate::scenario::Harness, handle: &str, text: &str) { + h.run(&json!([ + {"click":{"handle":handle}}, {"key":"A","cmd":true}, {"text":text} + ])) + .unwrap(); + } + + fn escape_key(h: &mut crate::scenario::Harness, pressed: bool) { + h.app.synthetic.push(egui::Event::Key { + key: egui::Key::Escape, + physical_key: None, + pressed, + repeat: false, + modifiers: egui::Modifiers::NONE, + }); + h.frame(); + } + + fn escape_closed(h: &mut crate::scenario::Harness, before: &Value, saved_expression: &str) { + escape_key(h, true); + assert!(h.app.params.is_none(), "the first Escape press closes Parameters"); + assert_eq!(&escape_state(h), before, "cancelling does not change the document, history or selection"); + assert!( + !h.app.preview.active && !h.app.preview.busy && h.app.preview.replaced.is_empty(), + "the parameter preview is cleared in the same frame" + ); + escape_key(h, false); + h.step(&json!({"start":"parameters.change"})).unwrap(); + let d = h.app.params.as_ref().unwrap(); + assert!(d.editing.is_empty() && d.comments.is_empty() && d.renaming.is_none() && d.preview.is_none()); + assert!(d.new_name.is_empty() && d.new_comment.is_empty() && !d.adding); + assert_eq!(h.app.session.doc.param("width").unwrap().expr, saved_expression); + } + + #[test] + fn escape_closes_parameters_without_clearing_selection_or_history() { + let mut h = escape_dialog(); + h.app.run("parameters.change", json!({"name":"width","expression":"12 mm"})).unwrap(); + h.app.run("edit.undo", json!({})).unwrap(); + h.app.session.selection = vec![solidcraft_engine::Sel::Plane { name: "XY".into() }]; + h.step(&json!({"click":{"handle":"params:title"}})).unwrap(); + assert!(!h.app.session.undo.is_empty() && !h.app.session.redo.is_empty()); + let before = escape_state(&h); + escape_closed(&mut h, &before, "10 mm"); + } + + #[test] + fn escape_discards_valid_parameter_expression_and_preview() { + let mut h = escape_dialog(); + let before = escape_state(&h); + escape_type(&mut h, "param:width", "20 mm"); + h.frames(2); + assert_eq!(h.app.params.as_ref().unwrap().preview.as_ref().map(|(_, e)| e.as_str()), Some("20 mm")); + assert!(h.app.preview.active || h.app.preview.busy); + assert_eq!(escape_state(&h), before, "typing only previews the edit"); + escape_closed(&mut h, &before, "10 mm"); + } + + #[test] + fn escape_discards_invalid_parameter_expression() { + let mut h = escape_dialog(); + let before = escape_state(&h); + escape_type(&mut h, "param:width", "missing + 1 mm"); + assert_eq!(h.app.params.as_ref().unwrap().editing.get("width").map(String::as_str), Some("missing + 1 mm")); + escape_closed(&mut h, &before, "10 mm"); + } + + #[test] + fn escape_discards_parameter_rename() { + let mut h = escape_dialog(); + h.frames(30); + let before = escape_state(&h); + h.run(&json!([ + {"click":{"handle":"param:name:width"},"double":true}, {"key":"A","cmd":true}, {"text":"renamed_width"} + ])) + .unwrap(); + assert_eq!(h.app.params.as_ref().unwrap().renaming.as_ref().map(|(_, n)| n.as_str()), Some("renamed_width")); + escape_closed(&mut h, &before, "10 mm"); + } + + #[test] + fn escape_discards_parameter_comment() { + let mut h = escape_dialog(); + let before = escape_state(&h); + h.run(&json!([ + {"widget":"Saved comment"}, {"key":"A","cmd":true}, {"text":"Pending comment"} + ])) + .unwrap(); + assert_eq!(h.app.params.as_ref().unwrap().comments.get("width").map(String::as_str), Some("Pending comment")); + escape_closed(&mut h, &before, "10 mm"); + } + + #[test] + fn escape_discards_valid_new_parameter_from_name() { + let mut h = escape_dialog(); + let before = escape_state(&h); + h.step(&json!({"click":{"handle":"dialog:table:user:add"}})).unwrap(); + escape_type(&mut h, "param:new:expression", "25 mm"); + escape_type(&mut h, "param:new:name", "height"); + assert_eq!(h.app.params.as_ref().unwrap().new_name, "height"); + assert_eq!(crate::scenario::count_of("param:new:valid"), Some(1.0)); + escape_closed(&mut h, &before, "10 mm"); + } + + #[test] + fn escape_discards_valid_new_parameter_from_expression() { + let mut h = escape_dialog(); + let before = escape_state(&h); + h.step(&json!({"click":{"handle":"dialog:table:user:add"}})).unwrap(); + escape_type(&mut h, "param:new:expression", "missing + 1 mm"); + escape_type(&mut h, "param:new:name", "height"); + escape_type(&mut h, "param:new:expression", "25 mm"); + assert_eq!(h.app.params.as_ref().unwrap().new_expr, "25 mm"); + assert_eq!(crate::scenario::count_of("param:new:valid"), Some(1.0)); + escape_closed(&mut h, &before, "10 mm"); + } + + #[test] + fn escape_discards_new_parameter_from_comment() { + let mut h = escape_dialog(); + let before = escape_state(&h); + h.step(&json!({"click":{"handle":"dialog:table:user:add"}})).unwrap(); + escape_type(&mut h, "param:new:expression", "missing + 1 mm"); + escape_type(&mut h, "param:new:name", "height"); + escape_type(&mut h, "param:new:comment", "Pending new comment"); + assert_eq!(h.app.params.as_ref().unwrap().new_comment, "Pending new comment"); + escape_closed(&mut h, &before, "10 mm"); + } + + #[test] + fn escape_keeps_previously_committed_parameter_edit() { + let mut h = escape_dialog(); + escape_type(&mut h, "param:width", "20 mm"); + h.step(&json!({"key":"Enter"})).unwrap(); + assert_eq!(h.app.session.doc.param("width").unwrap().expr, "20 mm"); + let before = escape_state(&h); + escape_type(&mut h, "param:width", "30 mm"); + escape_closed(&mut h, &before, "20 mm"); + } + + #[test] + fn escape_dismisses_parameter_completion_before_closing_window() { + let mut h = escape_dialog(); + let before = escape_state(&h); + escape_type(&mut h, "param:width", "wi"); + assert!(!suggestions(&h.ctx, "wi").is_empty()); + let editor = h.ctx.memory(|m| m.focused()).unwrap(); + escape_key(&mut h, true); + assert!(h.app.params.is_some(), "the completion popup owns the first Escape"); + assert!(h.ctx.memory(|m| m.has_focus(editor))); + assert_eq!(h.app.params.as_ref().unwrap().editing.get("width").map(String::as_str), Some("wi")); + assert_eq!(escape_state(&h), before); + escape_key(&mut h, false); + escape_closed(&mut h, &before, "10 mm"); + } + + #[test] + fn escape_shortcut_capture_has_priority_over_parameters() { + let mut h = escape_dialog(); + let before = escape_state(&h); + h.call("ui.prefs", json!({"open":"Keyboard Shortcuts"})); + escape_type(&mut h, "keymap:filter", "solid.extrude"); + h.step(&json!({"click":{"handle":"keymap:capture:solid.extrude"}})).unwrap(); + assert!(crate::keymap::capturing(&h.app)); + escape_key(&mut h, true); + assert!(!crate::keymap::capturing(&h.app)); + assert!(h.app.prefs_window.open && h.app.params.is_some(), "Escape cancels only shortcut capture"); + assert_eq!(escape_state(&h), before); + } + #[test] fn words_and_suggestions() { assert_eq!(word_before("2 * wid", 7), (4, "wid".to_string())); diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index 7fdcd39f21..e3f314c44c 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -285,6 +285,7 @@ fn window_page(ui: &mut egui::Ui, app: &mut SolveApp) { crate::scenario::publish_target("prefs:page:Selection", rect); } if app.prefs_window.section != INDEX { + crate::keymap::stop_capture(app); app.keymap.read_only = false; app.prefs_window.section = INDEX; } @@ -335,7 +336,8 @@ pub enum OrbitAround { #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum SpaceMouseDriver { - /// spacenavd when it runs, else the device itself (how it always was). + /// The device itself (hidraw) when it opens, else spacenavd. Spacenavd has been a known source of + /// trouble, and a device read directly keeps its settings across a replug. #[default] Automatic, Spacenavd, @@ -343,6 +345,27 @@ pub enum SpaceMouseDriver { Direct, } +/// Where the small 3D mouse picture sits in the 3D view (Preferences: "Position"). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SpaceMouseGhost { + /// The lower left corner of the viewport area, right of the browser (how it was first made). + /// Saved as `current` by the first build, which is still read. + #[default] + #[serde(alias = "current")] + LeftViewport, + /// The lower right corner of the viewport area. Saved as `right` by the first build. + #[serde(alias = "right")] + RightViewport, + /// The lower left corner of the whole window, under the browser. Saved as `left` by the first build. + #[serde(alias = "left")] + LeftWindow, +} + +impl SpaceMouseGhost { + pub const ALL: [Self; 3] = [Self::LeftViewport, Self::RightViewport, Self::LeftWindow]; +} + impl SpaceMouseDriver { pub const ALL: [Self; 3] = [Self::Automatic, Self::Spacenavd, Self::Direct]; @@ -445,7 +468,10 @@ preference_settings! { /// read, so an existing choice survives the upgrade; this version only writes `false`. orbit_cursor: bool = false, PAGE_NAVIGATION; /// Whether a connected 3D mouse moves the view. - space_mouse_enabled: bool = true, PAGE_NAVIGATION => check("Navigate with SpaceNavigator / SpaceMouse"); + space_mouse_enabled: bool = true, PAGE_NAVIGATION => check("Navigate with the 3D mouse"); + /// Whether a picture of the connected 3D mouse, moving as it does, sits in the 3D view's lower + /// left corner. Off by default. + space_mouse_in_view: bool = false, PAGE_NAVIGATION => check_id("ui.3d_mouse_show_in_view"); /// Speed multiplier for 3D mouse navigation. space_mouse_speed: f32 = 1.0, PAGE_NAVIGATION; /// Per physical axis of the cap (push X, Y, Z, then tilt X, Y, Z), whether it moves the view @@ -458,10 +484,28 @@ preference_settings! { space_mouse_axis_source: [usize; 6] = crate::space_mouse::SAME_AXES, PAGE_NAVIGATION; /// Which program reads the 3D mouse when spacenavd and the device itself are both there (Linux). space_mouse_driver: SpaceMouseDriver = SpaceMouseDriver::default(), PAGE_NAVIGATION; + /// Where the 3D mouse picture sits in the 3D view, when it is shown there (`space_mouse_in_view`). + space_mouse_ghost_position: SpaceMouseGhost = SpaceMouseGhost::default(), PAGE_NAVIGATION; + /// The user turned the calibration offer off for good ("Never show again"). Unlike closing the + /// card, which only records that device, this stops every offer, for every device: it is one flag, + /// not kept per device key. Like the settings beside it, the dialog's Restore Defaults puts it back + /// (the 3D mouse section's own restore leaves it). + space_mouse_offer_never: bool = false, PAGE_NAVIGATION; /// Per 3D mouse (`space_mouse::Input::device`), the calibration that puts its axes on /// 3Dconnexion's. A device without one is read as it reports. Page resets keep calibrations. #[serde(deserialize_with = "calibrations_that_read")] space_mouse_calibrations: std::collections::BTreeMap = Default::default(); + /// Per 3D mouse, what each of its buttons does, by number from 1, once the user has changed + /// any of them; until then a measured device has its printed keys' jobs + /// (`space_mouse::default_button_jobs`). Kept, like calibrations, when settings are reset. + #[serde(deserialize_with = "button_jobs_that_read")] + space_mouse_buttons: std::collections::BTreeMap> = Default::default(); + /// The 3D mice (`space_mouse::Input::device`) whose calibration has been offered and dismissed, so + /// the offer (`space_mouse_offer`) is made once per device. A saved entry that is not text is dropped. + /// Kept when settings are reset, like the calibrations and button mappings it goes with: resetting + /// the page would otherwise bring the offer back for every device. + #[serde(deserialize_with = "devices_that_read")] + space_mouse_offered: std::collections::BTreeSet = Default::default(); /// Anti-aliasing samples (applies on restart). msaa: u32 = 4, PAGE_GRAPHICS; /// Soft contact shading in the GPU viewport; optional and applied immediately. @@ -469,6 +513,9 @@ preference_settings! { /// The grid turns to the upright origin plane facing a side view (#1470). Off by default: the /// grid stays on the ground plane (XY) whatever the view. grid_follows_view: bool = false => check("Grid follows the view", "Side views show the grid on the upright plane facing them instead of the ground"); + /// Draw reference geometry such as inactive sketches and datum planes through shaded bodies. + /// Off by default to preserve the depth-tested viewport behaviour. + reference_geometry_through_bodies: bool = false, PAGE_GRAPHICS => check_id("settings.reference_geometry_through_bodies"); /// How finely the viewport tessellates curved faces and edges: `coarse`, `normal`, `fine` or /// `ultra` (#1287). Only the display plan the viewport draws; never the design or exports. display_quality: String = "normal".into(), PAGE_GRAPHICS; @@ -531,6 +578,16 @@ preference_settings! { up_axis: String = "z".into(), PAGE_DESIGN; /// After a STEP or IGES file opens, offer to stand a Y-up part up (`stand_up`). offer_stand_up: bool = true, PAGE_DESIGN => check("Offer to stand up Y-up files", "After a STEP or IGES file opens with Z up, offer a quarter turn about X"); + /// Extrude: with a profile picked, a face, vertex or parallel plane picked off its plane + /// switches the extent to To Object (off: picks only add profiles, as before). + extrude_auto_to_object: bool = false, PAGE_DESIGN => check("Extrude to a picked target", "In Extrude, with a profile picked, picking a face, vertex or parallel plane off its plane switches the extent to To Object"); + /// Placing a Single hole snaps to the middle of the face and the centres of its round edges + /// (`hole_place`). Off by default: a hole goes where it is clicked. + hole_snaps: bool = false, PAGE_DESIGN => check("Snap to face and edge centres"); + /// A placed Single hole has a handle that drags it, and a pick elsewhere on a face that has a + /// hole moves the hole there (`hole_place`). Off by default: a second pick on its face takes + /// the hole out, and a hole is moved by taking it out and picking again. + hole_moves: bool = false, PAGE_DESIGN => check("Move holes by clicking or dragging"); /// What a middle-button double-click in the view does: `home`, `fit` or `off`. middle_double_click: String = "home".into(), PAGE_NAVIGATION; /// Colour vision mode (#778): `off`, `protan`, `deutan` or `tritan`. @@ -561,6 +618,9 @@ preference_settings! { /// so the window stays live while they run (#1989, [`crate::control_job`]). Off: they run on /// the window thread, and the window waits for each, as before (the default). agent_off_thread: bool = false, PAGE_AI; + /// Let AI agents connect: the localhost control channel (MCP) opens on the default port at + /// start-up, unless `--control`/`SOLIDCRAFT_CONTROL_PORT` name one. Read at start-up. + agent_control: bool = true, PAGE_AI; /// Agents the user added (the installed ones are found by themselves). API keys are never /// here: they live in the system keychain. agents: Vec = Vec::new(); @@ -1145,6 +1205,8 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { } if !app.prefs_window.open { app.prefs_window.snapshot = None; + // A question left up when the dialog closed is not still asked when it opens again. + app.space_mouse.restore_pending = false; // Calibration opens from here and ends with it, so the cap moves the view again. app.space_mouse.calibrating = None; return; @@ -1208,7 +1270,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { } /// Close the dialog; `revert`: put back what was there when it opened (Cancel, Esc, ×). -fn close(app: &mut SolveApp, revert: bool) { +pub(crate) fn close(app: &mut SolveApp, revert: bool) { if let Some(s) = app.prefs_window.snapshot.take() && revert { @@ -1267,6 +1329,521 @@ fn page_index(name: &str) -> Option { } } +/// The 3D mouse's settings under Navigation, drawn with the same settings kit as the sections above +/// it: whether it moves the view and shows in it, where the picture goes, its speed, the driver and +/// calibration, each axis, the picture with its dead zone, and what its buttons do. A section like the +/// others, so it folds with its header and stays as the user last left it. +fn space_mouse_section(app: &mut SolveApp, ui: &mut egui::Ui) { + ui_kit::settings_section(ui, "space_mouse", crate::i18n::tr("ui.3d_mouse_section"), |ui| space_mouse_rows(app, ui)); +} + +/// The label column the Navigation page's forms share (`prefs/navigation.rs`). +const FORM_LABEL_WIDTH: f32 = 150.0; + +fn space_mouse_rows(app: &mut SolveApp, ui: &mut egui::Ui) { + let t = Tokens::get(); + // The two checkboxes sit in the two-column grid Wheel and trackpad uses (`navigation.rs`). + let width = (ui.available_width() - (t.space_16 + t.space_4)) * 0.5; + ui.horizontal_top(|ui| { + ui.spacing_mut().item_spacing.x = t.space_16 + t.space_4; + ui.allocate_ui_with_layout(egui::vec2(width, 0.0), egui::Layout::top_down(egui::Align::Min), |ui| { + ui.horizontal_wrapped(|ui| { + setting::space_mouse_enabled(app, ui); + let device = app.space_mouse.device.as_ref(); + let saved = device.is_some_and(|device| app.preferences.space_mouse_calibrations.contains_key(device)); + if let Some(device) = device { + let state = app.tr(if saved { "ui.3d_mouse_calibrated" } else { "ui.3d_mouse_not_calibrated" }); + ui_kit::settings_badge(ui, &format!("{state} · {device}"), !saved); + } + }); + }); + ui.allocate_ui_with_layout(egui::vec2(width, 0.0), egui::Layout::top_down(egui::Align::Min), |ui| { + setting::space_mouse_in_view(app, ui).on_hover_text(crate::i18n::tr("ui.3d_mouse_show_in_view_tip")); + }); + }); + // Where the picture goes, a choice that waits on the checkbox above. + let names: Vec<(SpaceMouseGhost, String)> = [ + (SpaceMouseGhost::LeftViewport, "ui.3d_mouse_ghost_left_viewport"), + (SpaceMouseGhost::RightViewport, "ui.3d_mouse_ghost_right_viewport"), + (SpaceMouseGhost::LeftWindow, "ui.3d_mouse_ghost_left_window"), + ] + .map(|(position, key)| (position, app.tr(key).to_owned())) + .into(); + ui_kit::settings_form(ui, FORM_LABEL_WIDTH, |ui| { + ui_kit::row(ui, crate::i18n::tr("ui.3d_mouse_ghost_position"), |ui| { + let (in_view, position) = (app.preferences.space_mouse_in_view, &mut app.preferences.space_mouse_ghost_position); + ui.add_enabled_ui(in_view, |ui| { + ui_kit::settings_control_width(ui, AXIS_DRIVEN_WIDTH, |ui| { + let current = names.iter().find(|(choice, _)| choice == position).map_or("", |(_, name)| name.as_str()); + ui_kit::dropdown_menu(ui, "sc_space_mouse_ghost", current, |ui| { + for (choice, name) in &names { + if ui_kit::list_picker(ui, name, name, choice == position).clicked() { + *position = *choice; + } + } + }); + }); + }); + }); + ui_kit::row(ui, app.tr("prefs.overall_speed"), |ui| { + ui.add_enabled_ui(app.preferences.space_mouse_enabled, |ui| { + ui_kit::slider( + ui, + &mut app.preferences.space_mouse_speed, + 0.25..=3.0, + ui_kit::Slider { step: Some(0.05), format: |value| format!("{value:.2}×"), ..Default::default() }, + ); + }); + }); + ui_kit::row(ui, app.tr("ui.3d_mouse_calibration"), |ui| { + let device = app.space_mouse.device.clone(); + let saved = device.as_ref().is_some_and(|device| app.preferences.space_mouse_calibrations.contains_key(device)); + ui.add_enabled_ui(app.preferences.space_mouse_enabled && device.is_some(), |ui| { + if ui_kit::button(ui, app.tr("ui.3d_mouse_calibrate"), ui_kit::Button::Normal) + .on_hover_text(app.tr("ui.3d_mouse_calibrate_tip")) + .on_disabled_hover_text(app.tr(if app.space_mouse.device.is_none() { + "ui.3d_mouse_calibrate_wait" + } else { + "ui.3d_mouse_calibrate_tip" + })) + .clicked() + { + crate::space_mouse_calibration::start(app); + } + if saved + && ui_kit::button(ui, app.tr("ui.3d_mouse_forget"), ui_kit::Button::Normal).clicked() + && let Some(device) = &device + { + app.preferences.space_mouse_calibrations.remove(device); + } + }); + let not_allowed = found_but_not_allowed(app.space_mouse.drivers); + // With a device, the badge beside the toggle above says this. + if device.is_some() { + return; + } + let status = if not_allowed { + app.tr("ui.3d_mouse_denied_short") + } else if waiting_for_a_record(app.space_mouse.drivers) { + app.tr("ui.3d_mouse_calibrate_wait") + } else { + app.tr("ui.3d_mouse_none") + }; + let status_line = ui_kit::text(ui, status, ui_kit::Text::Report("calibration_status", false)); + // Nothing confirmed yet (none found, or waiting for the first record): a hint to try, as a + // tooltip here and, while a reader is waiting for the first record, as a line below. + if device.is_none() && !not_allowed { + status_line.on_hover_text(app.tr("ui.3d_mouse_wiggle")); + } + }); + if app.space_mouse.device.is_none() { + if found_but_not_allowed(app.space_mouse.drivers) { + // Wiggling will not help: the device is there and SolidCraft may not read it. + hint_under_controls(ui, app.tr("ui.3d_mouse_denied"), "space_mouse_denied"); + } else { + hint_under_controls(ui, app.tr("ui.3d_mouse_wiggle"), "space_mouse_wiggle"); + } + } + }); + // The rarely needed settings sit behind one disclosure, closed until the reader opens it. + ui_kit::section(ui, "space_mouse_advanced", crate::i18n::tr_label("Advanced"), false, |ui| { + ui_kit::settings_form(ui, FORM_LABEL_WIDTH, |ui| { + // Only where the reader has seen the choice: Linux, with spacenavd and the device both there. + if let Some(present) = app.space_mouse.drivers.filter(|present| present.spacenavd || (present.direct && !present.denied)) { + let [label, auto, direct, only_spacenavd, only_direct] = + ["driver", "driver_auto", "driver_direct", "driver_only_spacenavd", "driver_only_direct"] + .map(|key| app.tr(&format!("ui.3d_mouse_{key}")).to_owned()); + let driver = &mut app.preferences.space_mouse_driver; + ui_kit::row(ui, &label, |ui| match (present.spacenavd, present.direct) { + (true, true) => { + let name = |driver| match driver { + SpaceMouseDriver::Automatic => auto.as_str(), + SpaceMouseDriver::Spacenavd => "spacenavd", + SpaceMouseDriver::Direct => direct.as_str(), + }; + ui_kit::settings_control_width(ui, AXIS_DRIVEN_WIDTH, |ui| { + ui_kit::dropdown_menu(ui, "sc_space_mouse_driver", name(*driver), |ui| { + for choice in SpaceMouseDriver::ALL { + if ui_kit::list_picker(ui, name(choice), name(choice), *driver == choice).clicked() { + *driver = choice; + } + } + }); + }); + } + (true, false) => { + ui_kit::text(ui, &only_spacenavd, ui_kit::Text::Hint("space_mouse_driver")); + } + (false, _) => { + ui_kit::text(ui, &only_direct, ui_kit::Text::Hint("space_mouse_driver")); + } + }); + } + // The setting says what was asked for; this says what is read when that is not it: Direct or + // Automatic with the daemon read because the device itself could not be opened. + if let Some(present) = app.space_mouse.drivers + && present.spacenavd + && present.direct + && app.preferences.space_mouse_driver != SpaceMouseDriver::Spacenavd + && app.space_mouse.device.as_deref() == Some("spacenavd") + { + hint_under_controls(ui, app.tr("ui.3d_mouse_driver_fallback"), "space_mouse_driver_fallback"); + } + let enabled = app.preferences.space_mouse_enabled; + let (label, tip) = (app.tr("ui.3d_mouse_dead_zone").to_owned(), app.tr("ui.3d_mouse_dead_zone_tip").to_owned()); + let device = app.space_mouse.device.clone(); + if let Some(calibration) = device.and_then(|device| app.preferences.space_mouse_calibrations.get_mut(&device)) { + ui_kit::row(ui, &label, |ui| { + ui.add_enabled_ui(enabled, |ui| { + ui_kit::slider( + ui, + &mut calibration.dead_zone_share, + 0.0..=solidcraft_space_mouse::MAX_DEAD_ZONE_SHARE, + ui_kit::Slider { step: Some(0.01), format: |v| format!("{:.0} %", v * 100.0), ..Default::default() }, + ) + .on_hover_text(tip); + }); + }); + } + }); + ui_kit::divider(ui, "space_mouse_axes"); + // Rows say what they do to the view, through the mapping navigation uses; the choices + // are the device's calibrated motions. Neither names a kind of device. + let mapping = app.space_mouse.mapping; + let rows: Vec<(String, bool)> = (0..6) + .map(|row| { + let label = crate::space_mouse::row_label(mapping, row); + let text = crate::i18n::Locale::for_language(&app.preferences.language).format(label, &[("n", &(row + 1).to_string())]); + (text, label == "ui.3d_mouse_view_off") + }) + .collect(); + let driven_by = app.tr("ui.3d_mouse_driven_by").to_owned(); + let off_tip = app.tr("ui.3d_mouse_view_off_tip").to_owned(); + let motions: Vec = crate::space_mouse::MOTIONS.iter().map(|motion| app.tr(motion).to_owned()).collect(); + // The axes are a table of their own: each row is a live meter of the motion that drives that + // view axis, then its Invert, Speed and the motion choice. + let enabled = app.preferences.space_mouse_enabled; + let raw: [i16; 6] = app.space_mouse.raw.into(); + // Frames come on input, so a reading that moved since the last one gets a frame to show it. + let seen = ui.id().with("space_mouse_meter_raw"); + let before = ui.data_mut(|data| data.get_temp::<[i16; 6]>(seen)); + ui.data_mut(|data| data.insert_temp(seen, raw)); + if before.is_some_and(|before| before != raw) { + ui.ctx().request_repaint(); + } + let calibration = app.space_mouse.device.as_ref().and_then(|device| app.preferences.space_mouse_calibrations.get(device)); + let meters: [_; 6] = std::array::from_fn(|axis| axis_meter(calibration, raw, app.preferences.space_mouse_axis_source, axis)); + ui_kit::settings_axis_header(ui); + for (axis, (name, off)) in rows.iter().enumerate() { + ui.push_id(("space_mouse_axis", axis), |ui| { + let (value, zone) = meters[axis]; + let prefs = &mut app.preferences; + let (reverse, speed, source) = + (prefs.space_mouse_reverse.get_mut(axis), prefs.space_mouse_axis_speed.get_mut(axis), &mut prefs.space_mouse_axis_source); + ui_kit::settings_axis_row( + ui, + name, + value, + zone, + // Invert and speed do nothing on a row the driver turns off. The motion choice + // stays live, so a motion can still be swapped out of it. + |ui| { + ui.add_enabled_ui(enabled && !off, |ui| { + if let Some(reverse) = reverse { + ui_kit::checkbox(ui, crate::i18n::tr_label("Invert"), reverse); + } + }); + }, + |ui| { + ui.add_enabled_ui(enabled && !off, |ui| { + if let Some(speed) = speed { + ui_kit::settings_axis_gain(ui, speed); + } + }); + }, + |ui| { + ui.add_enabled_ui(enabled, |ui| { + let current = source.get(axis).copied().unwrap_or(axis); + let mut chosen = current; + ui_kit::dropdown_menu(ui, ("sc_space_mouse_source", axis), motions.get(current).map_or("", String::as_str), |ui| { + for (from, name) in motions.iter().enumerate() { + if ui_kit::list_picker(ui, name, name, chosen == from).clicked() { + chosen = from; + } + } + }) + .on_hover_text(if *off { &off_tip } else { &driven_by }); + if chosen != current { + crate::space_mouse::reassign(source, axis, chosen); + } + }); + }, + ); + }); + } + ui_kit::settings_form(ui, FORM_LABEL_WIDTH, |ui| { + ui_kit::row(ui, "", |ui| { + let reassigned = app.preferences.space_mouse_axis_source != crate::space_mouse::SAME_AXES; + ui.add_enabled_ui(app.preferences.space_mouse_enabled && reassigned, |ui| { + if ui_kit::button(ui, app.tr("ui.3d_mouse_reset_axes"), ui_kit::Button::Normal).clicked() { + app.preferences.space_mouse_axis_source = crate::space_mouse::SAME_AXES; + } + }); + }); + // Restore this section's settings (after asking); the calibration, the button mapping and the + // test model are not touched. + if app.space_mouse.restore_pending { + // A short question on its own, not in a row (a row has one line's height and cut it off), so + // that it wraps to its whole height at any width; the buttons are under it. + hint_under_controls(ui, app.tr("ui.3d_mouse_restore_ask"), "space_mouse_restore_ask"); + ui_kit::row(ui, "", |ui| { + if ui_kit::button(ui, app.tr("ui.3d_mouse_restore"), ui_kit::Button::Normal).clicked() { + restore_space_mouse_defaults(app); + app.space_mouse.restore_pending = false; + } + if ui_kit::button(ui, app.tr("ui.cancel"), ui_kit::Button::Normal).clicked() { + app.space_mouse.restore_pending = false; + } + }); + } else { + ui_kit::row(ui, "", |ui| { + if ui_kit::button(ui, app.tr("ui.3d_mouse_restore"), ui_kit::Button::Normal).clicked() { + app.space_mouse.restore_pending = true; + } + }); + } + }); + // The calibrated device moving as it does, with the test model beside it, then its dead zones. + let (device, raw) = (app.space_mouse.device.clone(), app.space_mouse.raw.into()); + let (boat, motion, reverse) = (&mut app.space_mouse.boat, app.space_mouse.motion, (app.preferences.zoom_reverse, app.preferences.free_orbit)); + if let Some(calibration) = device.as_ref().and_then(|device| app.preferences.space_mouse_calibrations.get_mut(device)) { + ui_kit::divider(ui, "space_mouse_picture"); + // Under each circle's axes, what the view does with them: the rows' own labels. + let stories = crate::space_mouse::stories(&app.preferences.language, mapping, app.preferences.space_mouse_axis_source); + let help = crate::i18n::tr("ui.3d_mouse_circles_help").to_string(); + let pads = |ui: &mut egui::Ui| { + crate::space_mouse_calibration::dead_zone_pads(ui, calibration, raw, false, &stories); + ui_kit::text(ui, &help, ui_kit::Text::Hint("space_mouse_circles_help")); + }; + picture_and_boat(ui, boat, motion, reverse, true, |ui| crate::space_mouse_model::live(ui, calibration, raw), pads); + } else if device.is_some() { + // Not calibrated: the test model alone. + ui_kit::divider(ui, "space_mouse_picture"); + picture_and_boat(ui, boat, motion, reverse, false, |_| {}, |_| {}); + } + ui_kit::settings_form(ui, FORM_LABEL_WIDTH, |ui| { + hint_under_controls(ui, crate::i18n::tr_label("Move and rotate the view together; sketches use pan and zoom only."), "space_mouse"); + }); + ui_kit::divider(ui, "space_mouse_buttons"); + space_mouse_buttons(app, ui); + }); +} + +/// A hint in the control column of the forms above and below it, as the form rows' controls are, +/// not at the section's left edge. +fn hint_under_controls(ui: &mut egui::Ui, text: &str, key: &str) { + let indent = FORM_LABEL_WIDTH.min(ui.available_width() * 0.6) + Tokens::get().space_14; + ui.horizontal_top(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + ui.add_space(indent); + ui_kit::text(ui, text, ui_kit::Text::Hint(key)); + }); +} + +/// The width of the "driven by" choice. +const AXIS_DRIVEN_WIDTH: f32 = 140.0; + +/// Where the motion that drives view axis `axis` sits, as a share of its full travel (calibrated +/// when the device has a calibration, else the raw reading over full scale), and the share of that +/// travel which reads as no motion on each side, for the live meter in its row. +fn axis_meter(calibration: Option<&solidcraft_space_mouse::Calibration>, raw: [i16; 6], source: [usize; 6], axis: usize) -> (f32, [f32; 2]) { + let from = source.get(axis).copied().unwrap_or(axis); + match calibration { + Some(calibration) => { + (calibration.travel(raw).get(from).copied().unwrap_or(0.0), calibration.dead_zone_shares().get(from).copied().unwrap_or_default()) + } + None => (f32::from(raw.get(from).copied().unwrap_or(0)) / f32::from(crate::space_mouse::FULL_SCALE), [0.0; 2]), + } +} + +/// Whether a reader is reachable (spacenavd's socket, or a 3D mouse hidraw lists) that has not yet +/// sent a record: the device is not confirmed until it does, so Preferences asks for a movement +/// instead of saying none was found. Nothing reachable at all is "none found". +fn waiting_for_a_record(drivers: Option) -> bool { + drivers.is_some_and(|present| present.spacenavd || (present.direct && !present.denied)) +} + +/// A 3D mouse is there but may not be opened, and nothing else is reachable (no spacenavd): asking for +/// a movement would wait forever, so Preferences says what is wrong instead. +fn found_but_not_allowed(drivers: Option) -> bool { + drivers.is_some_and(|present| present.denied && !present.spacenavd) +} + +/// Puts this section's settings back: the driver choice, the speed, each axis's invert and speed and +/// assignment, where the picture goes and whether it shows. Not the calibration (it has its own +/// Forget), the button mapping, the offered set, "never show again", "Navigate with the 3D mouse" or +/// the test model. +pub fn restore_space_mouse_defaults(app: &mut SolveApp) { + let d = Prefs::default(); + let p = &mut app.preferences; + p.space_mouse_driver = d.space_mouse_driver; + p.space_mouse_speed = d.space_mouse_speed; + p.space_mouse_reverse = d.space_mouse_reverse; + p.space_mouse_axis_speed = d.space_mouse_axis_speed; + p.space_mouse_axis_source = d.space_mouse_axis_source; + p.space_mouse_ghost_position = d.space_mouse_ghost_position; + p.space_mouse_in_view = d.space_mouse_in_view; +} + +/// The device's picture with the coordinate circles under it, and a box with a small model that +/// moves with the 3D mouse as the view does and a Reset button: to the right of the picture where +/// there is room, else under the circles. All stay inside the section at any width. +fn picture_and_boat( + ui: &mut egui::Ui, + boat: &mut Option, + motion: Option, + (reverse_zoom, free_orbit): (bool, bool), + has_picture: bool, + picture: impl FnOnce(&mut egui::Ui), + pads: impl FnOnce(&mut egui::Ui), +) { + const PICTURE_WIDTH: f32 = 200.0; + const BOAT_HEIGHT: f32 = 150.0; + const BOAT_MIN: f32 = 120.0; + const BOAT_MAX: f32 = 260.0; + let gap = Tokens::get().space_12; + let room = ui.available_width(); + let beside = has_picture && ui_kit::fits_steadily(ui, "boat_beside", PICTURE_WIDTH + gap + BOAT_MIN, room); + let width = if beside { (room - PICTURE_WIDTH - gap).min(BOAT_MAX) } else { room.min(BOAT_MAX) }; + let mut boat_and_reset = |ui: &mut egui::Ui| { + ui.vertical(|ui| { + crate::test_boat::show(ui, boat, motion, reverse_zoom, free_orbit, egui::vec2(width.max(0.0), BOAT_HEIGHT)); + if ui_kit::button(ui, crate::i18n::tr("ui.3d_mouse_reset_view"), ui_kit::Button::Normal).clicked() { + crate::test_boat::reset(boat); + } + }); + }; + // The coordinate circles stay under the device's picture, from its left edge; the test model is + // beside the picture where there is room, else under the circles. + if beside { + ui.horizontal(|ui| { + picture(ui); + boat_and_reset(ui); + }); + pads(ui); + } else { + picture(ui); + pads(ui); + boat_and_reset(ui); + } +} + +/// The 3D mouse's buttons, each with what it does: those that do something and any pressed since +/// the app started. A pressed one is marked in its row; Preferences doesn't run them. +fn space_mouse_buttons(app: &mut SolveApp, ui: &mut egui::Ui) { + let Some(device) = app.space_mouse.device.clone() else { + ui_kit::settings_form(ui, FORM_LABEL_WIDTH, |ui| { + hint_under_controls(ui, crate::i18n::tr("ui.3d_mouse_buttons_no_device"), "space_mouse_buttons"); + }); + return; + }; + let mut jobs = crate::space_mouse::button_jobs(app, &device); + let bit = |number: u8| number.checked_sub(1).and_then(|index| 1_u32.checked_shl(u32::from(index))).unwrap_or(0); + let mut numbers: std::collections::BTreeSet = jobs.keys().copied().collect(); + numbers.extend((1..=32).filter(|&number| app.space_mouse.seen & bit(number) != 0)); + let mut changed = false; + ui_kit::settings_form(ui, FORM_LABEL_WIDTH, |ui| { + hint_under_controls(ui, crate::i18n::tr("ui.3d_mouse_buttons_hint"), "space_mouse_buttons"); + for number in numbers { + let mut name = crate::i18n::format("ui.3d_mouse_button", &[("number", &number.to_string())]); + if app.space_mouse.buttons & bit(number) != 0 { + name.push_str(" ●"); + } + let current = jobs.get(&number).cloned(); + if let Some(choice) = ui_kit::row(ui, &name, |ui| button_job_picker(app, ui, number, current.as_ref())) { + changed = true; + match choice { + Some(job) => jobs.insert(number, job), + None => jobs.remove(&number), + }; + } + } + }); + if changed { + app.preferences.space_mouse_buttons.insert(device.clone(), jobs); + } + if app.preferences.space_mouse_buttons.contains_key(&device) { + ui_kit::settings_form(ui, FORM_LABEL_WIDTH, |ui| { + ui_kit::row(ui, "", |ui| { + if ui_kit::button(ui, crate::i18n::tr("ui.3d_mouse_buttons_reset"), ui_kit::Button::Normal).clicked() { + app.preferences.space_mouse_buttons.remove(&device); + } + }); + }); + } +} + +/// What a button job is called in its picker. +fn button_job_label(app: &SolveApp, job: Option<&crate::space_mouse::ButtonJob>) -> String { + use crate::space_mouse::{ButtonJob, ButtonKey}; + match job { + None => crate::i18n::tr("ui.3d_mouse_button_nothing").to_string(), + Some(ButtonJob::Settings) => crate::i18n::tr("ui.3d_mouse_button_settings").to_string(), + Some(ButtonJob::View(view)) => { + let mut name = view.clone(); + if let Some(first) = name.get_mut(0..1) { + first.make_ascii_uppercase(); + } + app.tr_label(&name).to_string() + } + Some(ButtonJob::Key(key)) => match key { + ButtonKey::Esc => "Esc", + ButtonKey::Shift => "Shift", + ButtonKey::Ctrl => "Ctrl", + ButtonKey::Alt => "Alt", + } + .to_string(), + Some(ButtonJob::Command(id)) => solidcraft_engine::find_command(id).map_or_else(|| id.clone(), |c| app.tr_label(c.label).to_string()), + } +} + +/// A job as the picker's value: what it is and, for a view, key or command, which. +fn button_job_value(job: Option<&crate::space_mouse::ButtonJob>) -> String { + use crate::space_mouse::ButtonJob; + match job { + None => "none".into(), + Some(ButtonJob::Settings) => "settings".into(), + Some(ButtonJob::View(view)) => format!("view:{view}"), + Some(ButtonJob::Key(key)) => format!("key:{key:?}"), + Some(ButtonJob::Command(id)) => format!("cmd:{id}"), + } +} + +/// A button's job, chosen from nothing, the 3D mouse settings, the views, the keys, then any +/// command, in the kit's searchable dropdown. `Some` with the new job when one is picked. +fn button_job_picker( + app: &SolveApp, + ui: &mut egui::Ui, + number: u8, + current: Option<&crate::space_mouse::ButtonJob>, +) -> Option> { + use crate::space_mouse::{ButtonJob, ButtonKey}; + let views = ["home", "fit", "top", "front", "right", "left", "back", "bottom"].map(|view| ButtonJob::View(view.into())); + let keys = [ButtonKey::Esc, ButtonKey::Shift, ButtonKey::Ctrl, ButtonKey::Alt].map(ButtonJob::Key); + let commands = solidcraft_engine::command_specs().into_iter().map(|spec| ButtonJob::Command(spec.id.into())); + let jobs: Vec> = + [None, Some(ButtonJob::Settings)].into_iter().chain(views.into_iter().chain(keys).chain(commands).map(Some)).collect(); + let entries: Vec<(String, String)> = jobs.iter().map(|job| (button_job_value(job.as_ref()), button_job_label(app, job.as_ref()))).collect(); + let options: Vec<_> = entries.iter().map(|(value, label)| ui_kit::SearchOption { value, label, handle: None }).collect(); + let filter_id = egui::Id::new(("sc_space_mouse_button_filter", number)); + let mut filter: String = ui.data_mut(|data| data.get_temp(filter_id).unwrap_or_default()); + let id = format!("sc_space_mouse_button_{number}"); + let chosen = ui_kit::searchable_dropdown(ui, &id, &button_job_label(app, current), &mut filter, &options, None, None); + ui.data_mut(|data| data.insert_temp(filter_id, filter)); + let chosen = chosen?; + jobs.into_iter().find(|job| button_job_value(job.as_ref()) == chosen) +} + /// The control channel's `ui.prefs`: `open` (a page name) opens the dialog there, `button` /// presses OK, Cancel, Apply or Restore Defaults, `stand_up: true` answers the stand-up offer /// (`false` leaves the file as it is, like "Leave it"), `theme_import` (a theme file's JSON or @@ -1342,6 +1919,43 @@ fn calibrations_that_read<'de, D: serde::Deserializer<'de>>( .collect()) } +/// The saved set of devices that read as text; anything else in it is dropped, not the whole set. +fn devices_that_read<'de, D: serde::Deserializer<'de>>(saved: D) -> Result, D::Error> { + let saved = Vec::::deserialize(saved)?; + Ok(saved.into_iter().filter_map(|device| device.as_str().map(str::to_owned)).collect()) +} + +/// The saved button jobs that still read. A job this version doesn't know (from a newer release) +/// drops that button only, with a log line, not its device's other buttons or the other devices. +fn button_jobs_that_read<'de, D: serde::Deserializer<'de>>( + saved: D, +) -> Result>, D::Error> { + let saved = std::collections::BTreeMap::::deserialize(saved)?; + Ok(saved + .into_iter() + .filter_map(|(device, jobs)| { + let jobs = match jobs { + serde_json::Value::Object(jobs) => jobs, + _ => { + log::warn!("preferences: the 3D mouse buttons for `{device}` can't be read; using the defaults"); + return None; + } + }; + let jobs = jobs + .into_iter() + .filter_map(|(number, job)| { + let read = number.parse::().ok().and_then(|number| Some((number, serde_json::from_value(job).ok()?))); + if read.is_none() { + log::warn!("preferences: button {number} of the 3D mouse `{device}` can't be read; leaving it empty"); + } + read + }) + .collect(); + Some((device, jobs)) + }) + .collect()) +} + /// Every theme the Theme list offers, as (id, name): the modes, the built-in theme files, then /// the imported ones. fn theme_choices(app: &SolveApp) -> Vec<(String, String)> { @@ -1489,6 +2103,11 @@ pub fn set(app: &mut SolveApp, p: &serde_json::Value) -> Result crate::theme_files::MAX_CUSTOM || new.custom_themes.iter().any(|t| t.colors.len() > crate::theme_files::MAX_COLORS) { return Err(format!("at most {} imported themes of {} colours each", crate::theme_files::MAX_CUSTOM, crate::theme_files::MAX_COLORS)); } + for theme in &new.custom_themes { + theme.validate_radii()?; + crate::theme_files::parse_icon_palette(&theme.icon_palette)?; + crate::theme_files::parse_borders(&theme.borders)?; + } if !crate::i18n::is_supported(&new.language) { return Err(format!("`language` must be one of: {}", crate::i18n::LOCALES.iter().map(|locale| locale.id).collect::>().join(", "))); } @@ -1550,6 +2169,118 @@ pub(crate) fn reveal_preference(h: &mut crate::scenario::Harness, handle: &str) #[cfg(test)] mod tests { + #[test] + fn the_ghost_position_defaults_round_trips_and_reads_tolerantly() { + use super::SpaceMouseGhost::{LeftViewport as Current, LeftWindow as Left, RightViewport as Right}; + assert_eq!(serde_json::from_value::(serde_json::json!({})).unwrap().space_mouse_ghost_position, Current); + for position in [Current, Left, Right] { + let prefs = super::Prefs { space_mouse_ghost_position: position, ..Default::default() }; + let again: super::Prefs = serde_json::from_value(serde_json::to_value(&prefs).unwrap()).unwrap(); + assert_eq!(again.space_mouse_ghost_position, position); + } + // A value from a newer build costs that setting only. + for (old, position) in [("current", Current), ("left", Left), ("right", Right)] { + let read = serde_json::from_value::(serde_json::json!({ "space_mouse_ghost_position": old })).unwrap(); + assert_eq!(read.space_mouse_ghost_position, position, "{old}"); + } + assert_eq!(serde_json::to_value(Left).unwrap(), "left_window"); + let read = super::lenient(&serde_json::json!({ "space_mouse_ghost_position": "top", "space_mouse_speed": 2.0 })); + assert_eq!((read.space_mouse_ghost_position, read.space_mouse_speed), (Current, 2.0)); + } + + #[test] + fn the_calibration_offer_record_round_trips_and_reads_tolerantly() { + let prefs: super::Prefs = + serde_json::from_value(serde_json::json!({ "space_mouse_offered": ["spacenavd", 7, null, "hid 046d:c62b"] })).unwrap(); + assert_eq!(prefs.space_mouse_offered, ["spacenavd".to_string(), "hid 046d:c62b".to_string()].into()); + let again: super::Prefs = serde_json::from_value(serde_json::to_value(&prefs).unwrap()).unwrap(); + assert_eq!(again.space_mouse_offered, prefs.space_mouse_offered); + // Files from before it existed read as nothing offered yet. + assert!(serde_json::from_value::(serde_json::json!({})).unwrap().space_mouse_offered.is_empty()); + } + + /// The 3D mouse section is laid out inside the panel it is in, at every panel width: nothing it + /// draws reaches past the right edge (the rows used to run off it and get clipped). + #[test] + fn the_3d_mouse_section_stays_inside_the_panel_at_every_width() { + for width in [420.0_f32, 560.0, 700.0, 900.0, 1200.0] { + let mut app = crate::SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); + app.space_mouse.device = Some("hid 046d:c62b".into()); + app.space_mouse.drivers = Some(crate::space_mouse::DriversPresent { spacenavd: true, direct: true, denied: false }); + app.preferences.space_mouse_in_view = true; + // Calibrated, so the device's picture and the test model sit side by side where there is room. + let axes = std::array::from_fn(|source| solidcraft_space_mouse::AxisCalibration { + source, + reversed: false, + rest: 0, + dead_zone: 0, + full: [350; 2], + }); + app.preferences.space_mouse_calibrations.insert( + "hid 046d:c62b".into(), + solidcraft_space_mouse::Calibration { dead_zone_share: 0.0, kind: solidcraft_space_mouse::Kind::ReturnToCenter, axes }, + ); + let ctx = crate::fonts::test_context(); + let panel = egui::Rect::from_min_size(egui::pos2(10.0, 10.0), egui::vec2(width, 1400.0)); + for frame in 0..4 { + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width + 40.0, 1500.0))), + focused: true, + ..Default::default() + }; + let mut output = ctx.run_ui(input, |ui| { + ui.scope_builder(egui::UiBuilder::new().max_rect(panel), |ui| { + super::space_mouse_section(&mut app, ui); + let wide: Vec<_> = crate::scenario::rects("").into_iter().filter(|(_, r)| r.right() > panel.right() + 0.5).collect(); + assert!( + ui.min_rect().right() <= panel.right() + 0.5, + "width {width}, frame {frame}: the section reaches {}; past the panel: {wide:?}", + ui.min_rect().right() + ); + }); + }); + output.textures_delta.clear(); + } + } + } + + /// The calibration window keeps its texts and sliders inside itself at the widths it can be + /// given, for both kinds of device (the notice card is clamped to the free width by construction). + #[test] + fn the_calibration_window_keeps_its_contents_inside() { + for width in [360.0_f32, 520.0, 900.0] { + for kind in [solidcraft_space_mouse::Kind::ReturnToCenter, solidcraft_space_mouse::Kind::Trackball] { + let mut app = crate::SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); + app.space_mouse.device = Some("hid 046d:c62b".into()); + crate::space_mouse_calibration::start(&mut app); + app.space_mouse.calibrating.as_mut().unwrap().1.choose(kind); + let ctx = crate::fonts::test_context(); + for _ in 0..6 { + crate::scenario::clear_handles(); + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 900.0))), + focused: true, + ..Default::default() + }; + let mut output = ctx.run_ui(input, |ui| crate::space_mouse_calibration::show(&mut app, ui.ctx())); + output.textures_delta.clear(); + } + let window = crate::scenario::rect_of("dialog:window").expect("the window is drawn"); + for (name, rect) in crate::scenario::rects("dialog:").into_iter().filter(|(name, _)| { + name.starts_with("dialog:slider") + || name.starts_with("dialog:widget") + || name.starts_with("dialog:note") + || name.starts_with("dialog:report") + }) { + assert!( + rect.right() <= window.right() + 0.5 && rect.left() >= window.left() - 0.5, + "width {width} {kind:?}: {name} {rect:?} is outside {window:?}" + ); + } + } + } + } + #[test] fn preferred_units_update_only_a_blank_unsaved_design() { for design in ["new", "saved", "content"] { @@ -1583,6 +2314,830 @@ mod tests { } } + /// What Preferences says of the 3D mouse before one is confirmed: a reachable reader that has sent + /// nothing yet asks for a movement; only nothing reachable says none was found. + #[test] + fn the_device_line_asks_for_a_movement_when_a_reader_is_reachable_and_only_then_says_none_found() { + use crate::space_mouse::DriversPresent; + let none = DriversPresent { spacenavd: false, direct: false, denied: false }; + assert!(!waiting_for_a_record(None) && !waiting_for_a_record(Some(none))); + assert!(waiting_for_a_record(Some(DriversPresent { spacenavd: true, direct: false, denied: false })), "a daemon, no record yet"); + assert!(waiting_for_a_record(Some(DriversPresent { spacenavd: false, direct: true, denied: false })), "a readable device, no record yet"); + assert!(waiting_for_a_record(Some(DriversPresent { spacenavd: true, direct: true, denied: false }))); + // The texts differ, and the first one is the Calibrate hint. + assert_ne!(crate::i18n::tr("ui.3d_mouse_none"), crate::i18n::tr("ui.3d_mouse_calibrate_wait")); + // A confirmed device is neither (the status names it). + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.app.space_mouse.device = Some("spacenavd".into()); + h.app.space_mouse.drivers = Some(DriversPresent { spacenavd: true, direct: false, denied: false }); + crate::prefs::open_at(&mut h.app, crate::prefs::PAGE_NAVIGATION); + h.frames(3); + assert!(h.app.prefs_window.open); + } + + /// Calibration starts while Preferences is open, and its window is on top of it; with no device + /// known there is nothing to start (the button is off and says why, see the hint key). + #[test] + fn calibration_opens_over_an_open_preferences_and_waits_for_a_device() { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + // After a driver change and before any reading: no device, so nothing starts. + h.app.space_mouse.device = None; + crate::prefs::open_at(&mut h.app, crate::prefs::PAGE_NAVIGATION); + h.frames(5); + assert!(crate::i18n::tr("ui.3d_mouse_calibrate_wait").starts_with("Move the 3D mouse")); + crate::space_mouse_calibration::start(&mut h.app); + h.frames(3); + assert!(h.app.space_mouse.calibrating.is_none() && h.app.prefs_window.open); + // The device is seen: the window opens, above Preferences, which stays open. + h.app.space_mouse.device = Some("hid 046d:c62b".into()); + h.frames(3); + crate::space_mouse_calibration::start(&mut h.app); + h.frames(5); + assert!(h.app.space_mouse.calibrating.is_some() && h.app.prefs_window.open); + let window = crate::scenario::rect_of("dialog:window").expect("the calibration window is the last window drawn"); + let top = h.ctx.layer_id_at(window.center()); + assert!(top.is_some_and(|layer| layer.id == egui::Id::new("sc_space_mouse_calibration")), "top is {top:?}"); + } + + /// Scrolls the open page until the handle is well inside the page body (not at its clipped edge), then clicks it (the harness has + /// no scroll step, so the wheel is sent the way a mouse would). + fn scroll_to_and_click(h: &mut crate::scenario::Harness, handle: &str) { + for step in 0..60 { + let body = crate::scenario::rect_of("prefs:page_body").expect("the page body"); + // Well inside the body while the page can still scroll, at its edge when it cannot. + let margin = if step < 30 { 40.0 } else { 0.0 }; + if let Some(at) = crate::scenario::handle_at(handle) + && body.shrink(margin).contains(egui::pos2(at.x, at.y)) + { + // The wheel scrolls smoothly: let it come to rest, then click where the handle is now. + h.frames(20); + let at = crate::scenario::handle_at(handle).unwrap_or(at); + if body.contains(egui::pos2(at.x, at.y)) { + h.step(&serde_json::json!({"click": {"handle": handle}})).unwrap(); + h.frames(5); + return; + } + } + h.app.synthetic.push(egui::Event::PointerMoved(body.center())); + h.app.synthetic.push(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -60.0), + modifiers: egui::Modifiers::NONE, + phase: egui::TouchPhase::Move, + }); + h.frames(2); + } + panic!("{handle} never came into view"); + } + + /// The page's rows share one label column, widened by the longest label in the form. The 3D + /// mouse section brought longer labels, which moved every control (Orbit around's among them) to + /// the right and turned its three buttons into a dropdown. Measured: each row's control left edge and + /// width with the section open and folded, at the window sizes the app is used at. + #[test] + fn folding_the_3d_mouse_section_does_not_move_the_controls_of_the_rows_above_it() { + let mut report = Vec::new(); + for width in [900, 1100, 1318, 1600, 1920] { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.app.space_mouse.device = Some("hid 046d:c62b".into()); + h.call("ui.resize", serde_json::json!({"width": width, "height": 900})); + crate::prefs::open_at(&mut h.app, crate::prefs::PAGE_NAVIGATION); + h.frames(6); + let rows = ["Orbit around", "Middle double-click", "View navigator"]; + let control = |name: &str| crate::scenario::rect_of(&format!("dialog:control:{name}")).map(|r| (r.left(), r.width())); + let buttons = || crate::scenario::rect_of("dialog:segments:Orbit around").is_some(); + let (open, open_form) = (rows.map(control), buttons()); + scroll_to_and_click(&mut h, "dialog:section:3D mouse"); + // Back to the top, where the rows above are on screen again. + h.app.synthetic.push(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, 5000.0), + modifiers: egui::Modifiers::NONE, + phase: egui::TouchPhase::Move, + }); + h.frames(6); + let (folded, folded_form) = (rows.map(control), buttons()); + eprintln!("window {width}: open {open:?} buttons {open_form}; folded {folded:?} buttons {folded_form}"); + if folded != open || folded_form != open_form { + report.push(format!("window {width}: open {open:?} buttons {open_form}, folded {folded:?} buttons {folded_form}")); + } + } + assert!(report.is_empty(), "{}", report.join("\n")); + } + + /// The meter in a view axis row shows the motion chosen in its "driven by" menu, calibrated or not. + #[test] + fn an_axis_meter_follows_the_motion_that_drives_it() { + let raw = [0, 0, 0, 175, 0, 0]; + let full = f32::from(crate::space_mouse::FULL_SCALE); + let mut source = crate::space_mouse::SAME_AXES; + assert_eq!(axis_meter(None, raw, source, 2).0, 0.0, "Zoom reads its own motion"); + crate::space_mouse::reassign(&mut source, 2, 3); + assert_eq!(axis_meter(None, raw, source, 2), (175.0 / full, [0.0; 2]), "Zoom now shows Rotation X"); + assert_eq!(axis_meter(None, raw, source, 3).0, 0.0, "and the row that gave Rotation X up shows what it took in exchange"); + let axes = + std::array::from_fn(|source| solidcraft_space_mouse::AxisCalibration { source, reversed: false, rest: 0, dead_zone: 0, full: [350; 2] }); + let calibration = solidcraft_space_mouse::Calibration { dead_zone_share: 0.2, kind: solidcraft_space_mouse::Kind::ReturnToCenter, axes }; + assert_eq!(axis_meter(Some(&calibration), raw, source, 2), (0.5, [0.2; 2]), "calibrated: share of travel, with the dead zone"); + } + + /// Restore defaults for the 3D mouse section puts back what it lists, and nothing else. + #[test] + fn restoring_the_3d_mouse_section_resets_its_settings_and_keeps_the_rest() { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + let axes = + std::array::from_fn(|source| solidcraft_space_mouse::AxisCalibration { source, reversed: false, rest: 0, dead_zone: 0, full: [350; 2] }); + let calibration = solidcraft_space_mouse::Calibration { dead_zone_share: 0.2, kind: solidcraft_space_mouse::Kind::ReturnToCenter, axes }; + let p = &mut h.app.preferences; + p.space_mouse_calibrations.insert("hid 046d:c62b".into(), calibration); + p.space_mouse_buttons.insert("hid 046d:c62b".into(), [(1, crate::space_mouse::ButtonJob::Settings)].into()); + p.space_mouse_offered.insert("hid 046d:c62b".into()); + p.space_mouse_offer_never = true; + p.space_mouse_enabled = false; + p.space_mouse_driver = SpaceMouseDriver::Direct; + p.space_mouse_speed = 2.5; + p.space_mouse_reverse = [true; 6]; + p.space_mouse_axis_speed = [0.25; 6]; + p.space_mouse_axis_source = [1, 0, 2, 3, 4, 5]; + p.space_mouse_ghost_position = SpaceMouseGhost::LeftWindow; + p.space_mouse_in_view = true; + let before = h.app.preferences.clone(); + let camera = h.app.cam; + restore_space_mouse_defaults(&mut h.app); + let d = Prefs::default(); + let p = &h.app.preferences; + assert_eq!( + (p.space_mouse_driver, p.space_mouse_speed, p.space_mouse_reverse), + (d.space_mouse_driver, d.space_mouse_speed, d.space_mouse_reverse) + ); + assert_eq!((p.space_mouse_axis_speed, p.space_mouse_axis_source), (d.space_mouse_axis_speed, d.space_mouse_axis_source)); + assert_eq!((p.space_mouse_ghost_position, p.space_mouse_in_view), (d.space_mouse_ghost_position, d.space_mouse_in_view)); + // Kept: the calibration (dead zone too), the buttons, the offered set, never, enabled, the rest of the page. + assert_eq!(p.space_mouse_calibrations, before.space_mouse_calibrations); + assert_eq!(p.space_mouse_buttons, before.space_mouse_buttons); + assert_eq!(p.space_mouse_offered, before.space_mouse_offered); + assert!(p.space_mouse_offer_never && !p.space_mouse_enabled); + assert_eq!(p.nav, before.nav); + assert_eq!(h.app.cam, camera); + // And it asks first: the real button only arms it (see cancelling_the_restore_question_changes_nothing). + assert!(!h.app.space_mouse.restore_pending); + } + + /// Opens Preferences on the Navigation page with the 3D mouse section open and these settings + /// changed from their defaults, as a person who changed them would find it. + fn changed_3d_mouse_settings(device: Option<&str>) -> crate::scenario::Harness { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.app.space_mouse.device = device.map(str::to_owned); + let axes = + std::array::from_fn(|source| solidcraft_space_mouse::AxisCalibration { source, reversed: false, rest: 0, dead_zone: 0, full: [350; 2] }); + let calibration = solidcraft_space_mouse::Calibration { dead_zone_share: 0.2, kind: solidcraft_space_mouse::Kind::ReturnToCenter, axes }; + let p = &mut h.app.preferences; + p.space_mouse_calibrations.insert("hid 046d:c62b".into(), calibration); + p.space_mouse_driver = SpaceMouseDriver::Direct; + p.space_mouse_speed = 2.5; + p.space_mouse_reverse = [true; 6]; + p.space_mouse_axis_speed = [0.25; 6]; + p.space_mouse_axis_source = [1, 0, 2, 3, 4, 5]; + p.space_mouse_ghost_position = SpaceMouseGhost::LeftWindow; + p.space_mouse_in_view = true; + h.call("ui.resize", serde_json::json!({"width": 1600, "height": 4000})); + crate::prefs::open_at(&mut h.app, crate::prefs::PAGE_NAVIGATION); + h.frames(36); + open_advanced(&mut h); + h + } + + /// Opens the section's "Advanced" disclosure, which starts closed. Once per harness: it toggles. + fn open_advanced(h: &mut crate::scenario::Harness) { + scroll_to_and_click(h, "dialog:section:Advanced"); + h.frames(10); + } + + /// The driver, dead zone, axis table and buttons are rarely needed, so they start folded away and + /// are not drawn until the reader opens "Advanced"; the everyday controls are there either way. + #[test] + fn the_advanced_3d_mouse_settings_start_folded_away() { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.app.space_mouse.device = Some("hid 046d:c62b".into()); + h.app.space_mouse.drivers = Some(crate::space_mouse::DriversPresent { spacenavd: true, direct: true, denied: false }); + h.call("ui.resize", serde_json::json!({"width": 1600, "height": 4000})); + crate::prefs::open_at(&mut h.app, crate::prefs::PAGE_NAVIGATION); + h.frames(36); + let drawn = |name: &str| crate::scenario::rect_of(&format!("dialog:row:{name}")).is_some(); + let (driver, axes) = (crate::i18n::tr("ui.3d_mouse_driver"), crate::i18n::tr("ui.3d_mouse_button").replace("{number}", "1")); + assert!(drawn("Calibration") && drawn("Overall speed"), "everyday controls are always there"); + assert!(!drawn(driver) && !drawn(&axes), "closed: no driver row, no buttons"); + assert!(crate::scenario::rect_of("dialog:section:Advanced").is_some()); + open_advanced(&mut h); + assert!(drawn(driver), "open: the driver row"); + assert!(crate::scenario::rect_of("dialog:button::Restore defaults").is_some(), "open: the restore button"); + open_advanced(&mut h); + assert!(!drawn(driver), "closed again"); + } + + /// The question is short, says what stays, and exists in every language. + #[test] + fn the_restore_question_is_short_and_says_what_stays() { + let english = crate::i18n::tr("ui.3d_mouse_restore_ask"); + assert_eq!(english, "Set the 3D mouse settings back to their defaults? Your calibration and button mapping stay."); + for catalog in crate::i18n::LOCALES { + let text = crate::i18n::Locale::for_language(catalog.id).text("ui.3d_mouse_restore_ask"); + assert!(text.chars().count() > 20 && text.chars().count() < 140 && text != "ui.3d_mouse_restore_ask", "{}", catalog.id); + } + } + + /// Cancel on the question leaves every setting as it was, and the question goes away. + #[test] + fn cancelling_the_restore_question_changes_nothing() { + let mut h = changed_3d_mouse_settings(Some("hid 046d:c62b")); + let before = h.app.preferences.clone(); + scroll_to_and_click(&mut h, "dialog:button::Restore defaults"); + assert!(h.app.space_mouse.restore_pending, "the first click only asks"); + assert_eq!(h.app.preferences, before, "asking changes nothing"); + scroll_to_and_click(&mut h, "dialog:button::Cancel"); + assert!(!h.app.space_mouse.restore_pending); + assert_eq!(h.app.preferences, before); + } + + /// Accepting works with no device connected, and a device connecting afterwards does not bring the + /// old values back (nothing is kept apart from the preferences, and the calibration stays). + #[test] + fn restoring_with_no_device_works_and_a_later_connection_brings_nothing_back() { + let mut h = changed_3d_mouse_settings(None); + let calibrations = h.app.preferences.space_mouse_calibrations.clone(); + scroll_to_and_click(&mut h, "dialog:button::Restore defaults"); + scroll_to_and_click(&mut h, "dialog:button::Restore defaults"); + assert!(!h.app.space_mouse.restore_pending); + let d = Prefs::default(); + let restored = |h: &crate::scenario::Harness| { + let p = &h.app.preferences; + (p.space_mouse_driver, p.space_mouse_speed, p.space_mouse_reverse, p.space_mouse_axis_speed, p.space_mouse_axis_source) + == (d.space_mouse_driver, d.space_mouse_speed, d.space_mouse_reverse, d.space_mouse_axis_speed, d.space_mouse_axis_source) + && (p.space_mouse_ghost_position, p.space_mouse_in_view) == (d.space_mouse_ghost_position, d.space_mouse_in_view) + }; + assert!(restored(&h), "restored with the device away"); + // The device connects (and its calibration applies): the defaults stay. + h.app.space_mouse.device = Some("hid 046d:c62b".into()); + h.frames(30); + assert!(restored(&h), "still the defaults after the device connected"); + assert_eq!(h.app.preferences.space_mouse_calibrations, calibrations, "its calibration is kept"); + } + + /// What a restore sets, and what the prefs file holds of it. + fn section_is_at_defaults(app: &SolveApp) -> bool { + let (p, d) = (&app.preferences, Prefs::default()); + (p.space_mouse_driver, p.space_mouse_speed, p.space_mouse_reverse, p.space_mouse_axis_speed, p.space_mouse_axis_source) + == (d.space_mouse_driver, d.space_mouse_speed, d.space_mouse_reverse, d.space_mouse_axis_speed, d.space_mouse_axis_source) + && (p.space_mouse_ghost_position, p.space_mouse_in_view) == (d.space_mouse_ghost_position, d.space_mouse_in_view) + } + + /// Restore, then closing the dialog with its own Cancel while the question is up: the question is not + /// still asked when the dialog opens again, and nothing was changed. + #[test] + fn closing_preferences_with_the_restore_question_up_does_not_leave_it_armed() { + let mut h = changed_3d_mouse_settings(Some("hid 046d:c62b")); + let before = h.app.preferences.clone(); + scroll_to_and_click(&mut h, "dialog:button::Restore defaults"); + assert!(h.app.space_mouse.restore_pending); + h.step(&serde_json::json!({"click": {"handle": "dialog:cancel"}})).unwrap(); + h.frames(6); + assert!(!h.app.prefs_window.open, "the dialog closed"); + crate::prefs::open_at(&mut h.app, crate::prefs::PAGE_NAVIGATION); + h.frames(6); + assert!(!h.app.space_mouse.restore_pending, "the question is not asked again on its own"); + assert_eq!(h.app.preferences, before); + } + + /// Confirming with the real buttons is what the prefs file holds afterwards: a new run that loads it + /// has the defaults for the section and the calibration still there. + #[test] + fn a_confirmed_restore_is_what_the_next_run_loads() { + let mut h = changed_3d_mouse_settings(Some("hid 046d:c62b")); + let calibrations = h.app.preferences.space_mouse_calibrations.clone(); + assert!(!section_is_at_defaults(&h.app)); + scroll_to_and_click(&mut h, "dialog:button::Restore defaults"); + scroll_to_and_click(&mut h, "dialog:button::Restore defaults"); + assert!(!h.app.space_mouse.restore_pending && section_is_at_defaults(&h.app)); + let saved = h.app.prefs(); + let mut next = crate::scenario::Harness::new(); + next.app.preferences.space_mouse_speed = 2.5; + assert!(!section_is_at_defaults(&next.app)); + next.app.load_prefs(&saved); + assert!(section_is_at_defaults(&next.app), "the defaults are what was saved"); + assert_eq!(next.app.preferences.space_mouse_calibrations, calibrations, "and the calibration with them"); + assert!(!next.app.space_mouse.restore_pending); + } + + /// The visible Calibrate button on the offer's card and the one in Preferences both open the wizard + /// for the device, at its first screen, with Preferences open. + #[test] + fn the_calibrate_buttons_on_the_card_and_in_preferences_start_the_same_wizard() { + let wizard = |h: &crate::scenario::Harness| { + h.app.space_mouse.calibrating.as_ref().map(|(device, wizard)| (device.clone(), format!("{:?} {:?}", wizard.stage(), wizard.kind()))) + }; + let harness = || { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.app.space_mouse.device = Some("spacenavd".into()); + h.call("ui.resize", serde_json::json!({"width": 1600, "height": 4000})); + h + }; + // The card's. + let mut card = harness(); + card.frames(120); + assert!(card.app.space_mouse.offer_since.is_some() && card.app.space_mouse.calibrating.is_none()); + card.step(&serde_json::json!({"widget": "Calibrate…"})).unwrap(); + card.frames(5); + // Preferences'. + let mut prefs = harness(); + crate::prefs::open_at(&mut prefs.app, crate::prefs::PAGE_NAVIGATION); + prefs.frames(6); + prefs.frames(30); + assert!(prefs.app.space_mouse.calibrating.is_none()); + scroll_to_and_click(&mut prefs, "dialog:button:Calibration:Calibrate…"); + assert!(card.app.prefs_window.open && prefs.app.prefs_window.open); + assert!(wizard(&card).is_some(), "the card's button started it"); + assert_eq!(wizard(&card), wizard(&prefs), "and Preferences' starts the same one"); + } + + /// Direct or Automatic chosen with the device present but unopenable: the daemon is read, and Preferences + /// says so next to the setting instead of leaving "Direct (hidraw)" to imply the device. Spacenavd + /// chosen, or the device itself read, or no device: no such note. + #[test] + fn preferences_says_when_the_daemon_is_read_instead_of_the_device_asked_for() { + use SpaceMouseDriver::{Automatic, Direct, Spacenavd}; + let note = crate::i18n::tr("ui.3d_mouse_driver_fallback").to_string(); + assert!(note.contains("spacenavd") && note.contains("could not be opened")); + for catalog in crate::i18n::LOCALES { + let text = crate::i18n::Locale::for_language(catalog.id).text("ui.3d_mouse_driver_fallback"); + assert!(text.chars().count() > 20 && text != "ui.3d_mouse_driver_fallback", "{}", catalog.id); + } + // (driver, device being read, device present) and whether the note shows. + let cases = [ + (Direct, "spacenavd", true, true), + (Automatic, "spacenavd", true, true), + (Spacenavd, "spacenavd", true, false), + (Direct, "hid 046d:c62b", true, false), + (Automatic, "hid 046d:c62b", true, false), + (Direct, "spacenavd", false, false), + ]; + for (driver, device, direct, shown) in cases { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.app.preferences.space_mouse_driver = driver; + h.app.space_mouse.device = Some(device.into()); + h.app.space_mouse.drivers = Some(crate::space_mouse::DriversPresent { spacenavd: true, direct, denied: false }); + h.call("ui.resize", serde_json::json!({"width": 1600, "height": 4000})); + crate::prefs::open_at(&mut h.app, crate::prefs::PAGE_NAVIGATION); + h.frames(6); + h.frames(30); + open_advanced(&mut h); + let up = h.widget(¬e).is_some(); + assert_eq!(up, shown, "{driver:?} reading {device}, device present {direct}"); + // Whatever it says, no calibration moves between keys. + assert!(h.app.preferences.space_mouse_calibrations.is_empty()); + } + } + + /// The section's buttons keep inside the page in every language, at the dialog's width (which the + /// window does not narrow): the arming Restore button, Reset axis assignment, Reset view under the + /// test model, and the buttons' own reset. + #[test] + fn the_3d_mouse_buttons_fit_the_page_in_every_language() { + let keys = ["ui.3d_mouse_restore", "ui.3d_mouse_reset_axes", "ui.3d_mouse_reset_view", "ui.3d_mouse_buttons_reset"]; + let mut too_wide = Vec::new(); + for catalog in crate::i18n::LOCALES { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.app.preferences.language = catalog.id.to_string(); + h.app.space_mouse.device = Some("hid 046d:c62b".into()); + let axes = std::array::from_fn(|source| solidcraft_space_mouse::AxisCalibration { + source, + reversed: false, + rest: 0, + dead_zone: 0, + full: [350; 2], + }); + h.app.preferences.space_mouse_calibrations.insert( + "hid 046d:c62b".into(), + solidcraft_space_mouse::Calibration { dead_zone_share: 0.0, kind: solidcraft_space_mouse::Kind::ReturnToCenter, axes }, + ); + h.app.preferences.space_mouse_buttons.insert("hid 046d:c62b".into(), [(1, crate::space_mouse::ButtonJob::Settings)].into()); + // The axis assignment is changed, so its reset button is enabled and drawn. + h.app.preferences.space_mouse_axis_source = [1, 0, 2, 3, 4, 5]; + h.call("ui.resize", serde_json::json!({"width": 1600, "height": 4000})); + crate::prefs::open_at(&mut h.app, crate::prefs::PAGE_NAVIGATION); + h.frames(6); + scroll_to_and_click(&mut h, &format!("dialog:section:{}", crate::i18n::Locale::for_language(catalog.id).text("Advanced"))); + h.frames(10); + // The restore question is up, so its whole text can be checked too. + h.app.space_mouse.restore_pending = true; + // The frame's language is only set while it runs, so the texts come from the catalog itself. + let text = |key: &str| crate::i18n::Locale::for_language(catalog.id).text(key).to_string(); + h.frames(30); + let body = crate::scenario::rect_of("prefs:page_body").expect("the page body"); + // The question: all of it is laid out (its box is as tall as its wrapped text needs) and the + // buttons start below it. + let question = text("ui.3d_mouse_restore_ask"); + match crate::scenario::rect_of("dialog:hint:space_mouse_restore_ask") { + Some(note) => { + let t = crate::theme::Tokens::get(); + let needed = h.ctx.fonts_mut(|fonts| { + fonts.layout(question.clone(), egui::FontId::proportional(t.font_small), egui::Color32::WHITE, note.width()).size().y + }); + if note.height() + 0.5 < needed { + too_wide.push(format!("{} question is {:.0} px tall, its text needs {needed:.0}", catalog.id, note.height())); + } + if note.left() < body.left() - 0.5 || note.right() > body.right() + 0.5 { + too_wide.push(format!("{} question {:.0}..{:.0} outside the page", catalog.id, note.left(), note.right())); + } + let cancel = crate::scenario::rect_of(&format!("dialog:button::{}", text("ui.cancel"))); + if cancel.is_some_and(|cancel| cancel.top() < note.bottom() - 0.5) { + too_wide.push(format!("{} the buttons overlap the question", catalog.id)); + } + } + None => too_wide.push(format!("{} question not drawn", catalog.id)), + } + for key in keys { + let label = text(key); + let Some(rect) = crate::scenario::rect_of(&format!("dialog:button::{label}")) else { + too_wide.push(format!("{} {key}: not drawn", catalog.id)); + continue; + }; + if rect.right() > body.right() + 0.5 || rect.left() < body.left() - 0.5 { + too_wide.push(format!( + "{} {key} \"{label}\": {:.0}..{:.0} outside the page {:.0}..{:.0}", + catalog.id, + rect.left(), + rect.right(), + body.left(), + body.right() + )); + } + } + } + assert!(too_wide.is_empty(), "{}", too_wide.join("\n")); + } + + /// The widest of the card's button labels stays well inside the narrowest card (346 px wide at a 760 px + /// window, less its padding) in every language, so a button never has to be wider than its card. + #[test] + fn the_offers_buttons_fit_the_narrowest_card_in_every_language() { + let ctx = crate::fonts::test_context(); + ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear(); + let font = egui::FontId::proportional(crate::theme::Tokens::get().font_label); + let mut wide = Vec::new(); + for catalog in crate::i18n::LOCALES { + use crate::space_mouse_offer::Action; + for key in [Action::Swap, Action::Calibrate, Action::Never, Action::Close].map(|action| action.label()) { + let label = crate::i18n::Locale::for_language(catalog.id).text(key).to_string(); + let width = ctx.fonts_mut(|fonts| fonts.layout_no_wrap(label.clone(), font.clone(), egui::Color32::WHITE).size().x); + if width > 250.0 { + wide.push(format!("{} {key} \"{label}\" is {width:.0} px", catalog.id)); + } + } + } + assert!(wide.is_empty(), "{}", wide.join("\n")); + } + + /// The circles' tooltips come from the one function the rows' labels come from, after a + /// reassignment, for both kinds of driver mapping (and none), and an inverted axis reads the same. + #[test] + fn the_circles_tooltips_say_what_the_rows_say_after_a_reassignment() { + use crate::space_mouse::{DriverMapping, SAME_AXES, reassign, row_label, stories}; + let shares = [[0.1_f32, 0.2]; 6]; + let mut inverted = DriverMapping::object_mode(); + for rule in &mut inverted.axes { + rule.reversed = !rule.reversed; + } + let mappings = [Some(DriverMapping::default()), Some(DriverMapping::object_mode()), Some(inverted), None]; + for mapping in mappings { + let mut source = SAME_AXES; + reassign(&mut source, 2, 1); + let said = stories("en", mapping, source); + let say = |row: usize| crate::i18n::Locale::for_language("en").format(row_label(mapping, row), &[("n", &(row + 1).to_string())]); + // Motion 1 is read by row 2 now, motion 2 by row 1; the others by their own rows. + assert_eq!(said[1], say(2), "{mapping:?}"); + assert_eq!(said[2], say(1), "{mapping:?}"); + assert_eq!(said[0], say(0)); + let tip = crate::space_mouse_calibration::pad_tooltip(&shares, (0, 1), &said); + assert_eq!(tip, format!("X −10 % +20 % → {}\nY −10 % +20 % → {}", say(0), say(2)), "{mapping:?}"); + // Invert is not in the story: the same words with every axis inverted. + let mut flipped = mapping; + if let Some(mapping) = flipped.as_mut() { + mapping.axes.iter_mut().for_each(|rule| rule.reversed = !rule.reversed); + } + assert_eq!(stories("en", flipped, source), said); + } + // A motion nobody reads says nothing, so no arrow. + let none = stories("en", Some(DriverMapping::default()), [0, 0, 0, 0, 0, 0]); + assert!(none[3].is_empty()); + assert_eq!(crate::space_mouse_calibration::pad_tooltip(&shares, (3, 4), &none), "Rx −10 % +20 %\nRy −10 % +20 %"); + } + + /// The circles say what the view does with their axes in the rows' own words, and keep saying it + /// after an axis is reassigned. + #[test] + fn the_circles_and_the_rows_tell_the_same_story_after_a_reassignment() { + use crate::space_mouse::{DriverMapping, SAME_AXES, motion_story, reassign, row_label}; + let mapping = Some(DriverMapping::default()); + // Unchanged: each motion is read by the row of its own number. + for motion in 0..6 { + assert_eq!(motion_story(mapping, SAME_AXES, motion), Some(row_label(mapping, motion))); + } + // Row 2 (lift) now reads motion 1 (push): the motion's story is that row's label, and row 1 reads motion 2. + let mut source = SAME_AXES; + reassign(&mut source, 2, 1); + assert_eq!(motion_story(mapping, source, 1), Some(row_label(mapping, 2))); + assert_eq!(motion_story(mapping, source, 2), Some(row_label(mapping, 1))); + assert_ne!(motion_story(mapping, source, 1), motion_story(mapping, SAME_AXES, 1)); + // Not one motion per row: a motion nobody reads has no story. + assert_eq!(motion_story(mapping, [0, 0, 0, 0, 0, 0], 3), None); + assert!(crate::i18n::tr("ui.3d_mouse_circles_help").contains("test model")); + } + + /// The "wiggle it" hint is a visible line as well as a tooltip, only while a reader is reachable and + /// silent; the section's own header carries no badge any more. + #[test] + fn a_visible_line_says_what_the_3d_mouse_waits_for() { + assert_eq!(crate::i18n::tr("ui.3d_mouse_waiting"), "Waiting for Movement"); + assert_eq!(crate::i18n::tr("ui.3d_mouse_reset_view"), "Reset view"); + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.app.space_mouse.device = None; + h.app.space_mouse.drivers = Some(crate::space_mouse::DriversPresent { spacenavd: true, direct: false, denied: false }); + h.call("ui.resize", serde_json::json!({"width": 1600, "height": 4000})); + crate::prefs::open_at(&mut h.app, crate::prefs::PAGE_NAVIGATION); + h.frames(5); + assert!(crate::scenario::rect_of("dialog:section:3D mouse").is_some(), "the section is drawn with the kit's header"); + assert!(crate::scenario::rect_of("dialog:section:3D mouse:count").is_none()); + assert!(crate::scenario::rect_of("dialog:hint:space_mouse_wiggle").is_some()); + } + + /// The boat's Reset view puts the boat back and nothing else: calibration, sensitivity, axis + /// assignment and the document's camera stay. Clicked as a person would. + #[test] + fn reset_view_is_scoped_to_the_test_model() { + let mut h = changed_3d_mouse_settings(Some("hid 046d:c62b")); + h.frames(10); + // The boat has been turned. + let mut boat = crate::test_boat::Boat::new(30.0); + boat.cam.yaw += 1.0; + h.app.space_mouse.boat = Some(boat); + let before = (h.app.cam, h.app.preferences.clone()); + scroll_to_and_click(&mut h, "dialog:button::Reset view"); + assert!( + h.app.space_mouse.boat.is_none() + || h.app.space_mouse.boat.as_ref().is_some_and(|boat| boat.cam.yaw == crate::test_boat::Boat::new(30.0).cam.yaw), + "the boat is back" + ); + assert_eq!(h.app.cam, before.0, "the document's camera"); + assert_eq!(h.app.preferences, before.1, "every setting, calibration included"); + assert_eq!(crate::i18n::tr("ui.3d_mouse_reset_view"), "Reset view"); + } + + /// The "wiggle it" hint exists in every catalog and is a visible line while no device is confirmed, + /// gone once there is one. + #[test] + fn the_wiggle_hint_has_text_and_shows_until_a_device_is_confirmed() { + assert_eq!(crate::i18n::tr("ui.3d_mouse_wiggle"), "If you have one plugged in, wiggle it."); + for catalog in crate::i18n::LOCALES { + let text = crate::i18n::Locale::for_language(catalog.id).text("ui.3d_mouse_wiggle"); + assert!(!text.is_empty() && text != "ui.3d_mouse_wiggle", "{}", catalog.id); + } + let mut h = changed_3d_mouse_settings(None); + h.app.space_mouse.drivers = Some(crate::space_mouse::DriversPresent { spacenavd: true, direct: false, denied: false }); + h.frames(10); + assert!(crate::scenario::rect_of("dialog:hint:space_mouse_wiggle").is_some(), "no device yet: the hint is a line"); + h.app.space_mouse.device = Some("spacenavd".into()); + h.frames(10); + assert!(crate::scenario::rect_of("dialog:hint:space_mouse_wiggle").is_none(), "confirmed: no hint"); + } + + /// A 3D mouse that is there but may not be read, with no spacenavd to ask instead: Preferences says so + /// in plain words and does not ask for a movement; with a daemon reachable it still waits for one. + #[test] + fn preferences_says_when_a_device_is_found_but_not_allowed() { + for catalog in crate::i18n::LOCALES { + for key in ["ui.3d_mouse_denied", "ui.3d_mouse_denied_short"] { + let text = crate::i18n::Locale::for_language(catalog.id).text(key); + assert!(text.chars().count() > 5 && text != key, "{} {key}", catalog.id); + } + } + assert!(crate::i18n::tr("ui.3d_mouse_denied").contains("udev")); + let cases = [ + // (spacenavd there, device found, denied) and whether the denied line shows. + (false, true, true, true), + (false, true, false, false), + (true, true, true, false), + (false, false, false, false), + ]; + for (spacenavd, direct, denied, shown) in cases { + let mut h = changed_3d_mouse_settings(None); + h.app.space_mouse.drivers = Some(crate::space_mouse::DriversPresent { spacenavd, direct, denied }); + h.frames(10); + let said = crate::scenario::rect_of("dialog:hint:space_mouse_denied").is_some(); + assert_eq!(said, shown, "spacenavd {spacenavd}, found {direct}, denied {denied}"); + let wiggle = crate::scenario::rect_of("dialog:hint:space_mouse_wiggle").is_some(); + assert_eq!(wiggle, !shown, "the hint to wiggle shows only where waiting can help"); + assert_eq!(super::waiting_for_a_record(h.app.space_mouse.drivers), spacenavd || (direct && !denied)); + } + } + + /// The dialog's Restore Defaults on the page that holds the 3D mouse section resets the page, and the + /// 3D mice already offered a calibration stay offered (like their calibrations and button mappings): + /// the offer does not come back for every device. + #[test] + fn the_dialogs_restore_defaults_keeps_the_offered_set_with_the_calibrations() { + let mut h = changed_3d_mouse_settings(Some("hid 046d:c62b")); + h.app.preferences.space_mouse_offered.insert("hid 046d:c62b".into()); + h.app.preferences.space_mouse_buttons.insert("hid 046d:c62b".into(), [(1, crate::space_mouse::ButtonJob::Settings)].into()); + let (offered, calibrations, buttons) = ( + h.app.preferences.space_mouse_offered.clone(), + h.app.preferences.space_mouse_calibrations.clone(), + h.app.preferences.space_mouse_buttons.clone(), + ); + h.call("ui.prefs", serde_json::json!({"button": "defaults"})); + h.frames(3); + assert!(h.app.preferences.space_mouse_speed == Prefs::default().space_mouse_speed, "the page was reset"); + assert_eq!(h.app.preferences.space_mouse_offered, offered); + assert_eq!(h.app.preferences.space_mouse_calibrations, calibrations); + assert_eq!(h.app.preferences.space_mouse_buttons, buttons); + } + + /// The first screen of the calibration (which kind of device) fits at the window's widths: + /// both pictures are inside it, side by side or one under the other. + #[test] + fn the_calibration_choose_screen_fits_at_every_width() { + for width in [300.0_f32, 360.0, 520.0, 900.0] { + let mut app = crate::SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); + app.space_mouse.device = Some("hid 046d:c62b".into()); + crate::space_mouse_calibration::start(&mut app); + let ctx = crate::fonts::test_context(); + for _ in 0..6 { + crate::scenario::clear_handles(); + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 900.0))), + focused: true, + ..Default::default() + }; + let mut output = ctx.run_ui(input, |ui| crate::space_mouse_calibration::show(&mut app, ui.ctx())); + output.textures_delta.clear(); + } + let window = crate::scenario::rect_of("dialog:window").expect("the window is drawn"); + let pictures = crate::scenario::rects("space_mouse:picture"); + assert_eq!(pictures.len(), 2, "width {width}: both kinds are offered"); + for (_, picture) in &pictures { + assert!(window.contains_rect(*picture), "width {width}: {picture:?} is outside {window:?}"); + } + assert!(!pictures[0].1.intersects(pictures[1].1), "width {width}: the pictures overlap"); + } + } + + /// The six readings in the calibration window are a clean grid whatever the values: each name + /// and its number are in their own column, never wrap or overlap, each number is centred under + /// its name, and the block is the same height for every value, so the window does not jitter. + #[test] + fn the_calibration_readings_are_a_clean_grid_of_constant_height() { + let sets: [[i16; 6]; 6] = [[0; 6], [-1; 6], [350; 6], [-350; 6], [i16::MAX; 6], [i16::MIN; 6]]; + for width in [360.0_f32, 520.0, 900.0] { + let mut heights = Vec::new(); + for values in sets { + let mut app = crate::SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); + app.space_mouse.device = Some("hid 046d:c62b".into()); + app.space_mouse.raw = values.into(); + crate::space_mouse_calibration::start(&mut app); + let ctx = crate::fonts::test_context(); + for _ in 0..4 { + crate::scenario::clear_handles(); + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 900.0))), + focused: true, + ..Default::default() + }; + let mut output = ctx.run_ui(input, |ui| crate::space_mouse_calibration::show(&mut app, ui.ctx())); + output.textures_delta.clear(); + } + let rect = |kind: &str, i: usize| crate::scenario::rect_of(&format!("space_mouse:reading_{kind}:{i}")).expect("drawn"); + let all: Vec = (0..6).flat_map(|i| [rect("name", i), rect("value", i)]).collect(); + for (a, first) in all.iter().enumerate() { + for second in &all[a + 1..] { + assert!(!first.intersects(*second), "width {width} {values:?}: {first:?} overlaps {second:?}"); + } + } + for i in 0..6 { + let (name, value) = (rect("name", i), rect("value", i)); + assert!((name.center().x - value.center().x).abs() <= 1.0, "width {width} {values:?}: number {i} is not under its name"); + assert!(value.top() >= name.bottom(), "number {i} is not on the line below"); + assert_eq!(name.top(), rect("name", 0).top(), "the names share a baseline"); + assert_eq!(value.top(), rect("value", 0).top(), "the numbers share a baseline"); + assert!(value.width() <= 48.0, "{values:?}: number {i} is wider than its column"); + } + heights.push(crate::scenario::rect_of("space_mouse:reading_block").expect("block").height()); + } + assert!(heights.windows(2).all(|pair| pair[0] == pair[1]), "width {width}: the block's height depends on the values: {heights:?}"); + } + } + + /// On the last screen, with the pictures and pads, the window is the same size for any live reading. + #[test] + fn the_calibration_window_keeps_its_size_while_the_cap_moves() { + let mut windows = Vec::new(); + for values in [[0_i16; 6], [-1; 6], [350; 6], [-350; 6], [i16::MAX; 6], [i16::MIN; 6], [159, -5, 0, 32, -17, 5]] { + let mut app = crate::SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); + app.space_mouse.device = Some("hid 046d:c62b".into()); + crate::space_mouse_calibration::start(&mut app); + { + let wizard = &mut app.space_mouse.calibrating.as_mut().unwrap().1; + wizard.choose(solidcraft_space_mouse::Kind::ReturnToCenter); + let hold = |wizard: &mut solidcraft_space_mouse::Wizard, reading: [i16; 6], seconds: f64| { + for _ in 0..(seconds / 0.02).round() as usize { + wizard.feed(reading, 0.02); + } + }; + hold(wizard, [0; 6], 1.6); + hold(wizard, [0; 6], 2.6); + for step in solidcraft_space_mouse::STEPS { + let mut raw = [0_i16; 6]; + raw[step.axis] = if step.positive { 300 } else { -300 }; + hold(wizard, raw, 0.5); + hold(wizard, [0; 6], 0.1); + } + assert!(matches!(wizard.result(), Some(Ok(_))), "the calibration reached its result screen"); + } + app.space_mouse.raw = values.into(); + let ctx = crate::fonts::test_context(); + for _ in 0..4 { + crate::scenario::clear_handles(); + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 900.0))), + focused: true, + ..Default::default() + }; + let mut output = ctx.run_ui(input, |ui| crate::space_mouse_calibration::show(&mut app, ui.ctx())); + output.textures_delta.clear(); + } + windows.push(crate::scenario::rect_of("dialog:window").expect("the window is drawn")); + } + assert!(windows.windows(2).all(|pair| pair[0] == pair[1]), "the window changes with the readings: {windows:?}"); + } + + /// One button job this build can't read (a newer release's) costs that button, not the lot. + #[test] + fn one_unreadable_button_job_keeps_the_others() { + use crate::space_mouse::{ButtonJob, ButtonKey}; + let saved = serde_json::json!({ "space_mouse_buttons": { + "hid 046d:c62b": { "1": { "key": "esc" }, "2": { "teleport": 3 }, "x": "settings", "3": "settings" }, + "hid 256f:c63a": { "4": { "view": "top" } }, + "spacenavd": 7, + } }); + let prefs: super::Prefs = serde_json::from_value(saved).expect("reads"); + let jobs = &prefs.space_mouse_buttons; + assert_eq!(jobs["hid 046d:c62b"], [(1, ButtonJob::Key(ButtonKey::Esc)), (3, ButtonJob::Settings)].into()); + assert_eq!(jobs["hid 256f:c63a"], [(4, ButtonJob::View("top".into()))].into()); + assert!(!jobs.contains_key("spacenavd")); + } + + /// A calibration as saved: the same text loads and is usable on 32 and 64 bits, and one saved + /// with the old `usize::MAX` (64-bit native) still reads as having no source on 64 bits. + #[test] + fn a_saved_calibration_with_no_source_reads_the_same_on_every_width() { + use solidcraft_space_mouse::{Calibration, NO_SOURCE}; + let axis = |source: &str, full: u32| format!(r#"{{"source":{source},"reversed":false,"rest":0,"dead_zone":3,"full":[{full},{full}]}}"#); + let json = |absent: &str| { + let axes = [axis("0", 300), axis("1", 300), axis("2", 300), axis(absent, 1), axis(absent, 1), axis("3", 300)].join(","); + format!(r#"{{"dead_zone_share":0.0,"kind":"Trackball","axes":[{axes}]}}"#) + }; + let fixed: Calibration = serde_json::from_str(&json("65535")).unwrap(); + assert_eq!(fixed.axes.map(|axis| axis.source), [0, 1, 2, NO_SOURCE, NO_SOURCE, 3]); + assert!(fixed.is_usable()); + assert_eq!(serde_json::to_string(&fixed.axes[3].source).unwrap(), "65535"); + #[cfg(target_pointer_width = "64")] + assert_eq!(serde_json::from_str::(&json("18446744073709551615")).unwrap(), fixed); + } + + #[test] + fn changing_preferences_page_cancels_shortcut_capture() { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.call("ui.prefs", json!({"open":"Keyboard Shortcuts"})); + h.step(&json!({"click":{"handle":"keymap:filter"}})).unwrap(); + h.step(&json!({"text":"solid.extrude"})).unwrap(); + h.step(&json!({"click":{"handle":"keymap:capture:solid.extrude"}})).unwrap(); + h.step(&json!({"key":"M","pressed":true})).unwrap(); + h.step(&json!({"click":{"handle":"prefs:page:General"}})).unwrap(); + assert!(!crate::keymap::capturing(&h.app)); + assert_eq!(crate::keymap::effective(&h.app, "solid.extrude").as_deref(), Some("E")); + h.frames(65); + assert_eq!(crate::keymap::effective(&h.app, "solid.extrude").as_deref(), Some("E")); + } + #[test] fn display_quality_cards_show_increasing_facet_detail() { let mut app = super::SolveApp::new(Default::default(), Default::default()); @@ -1727,16 +3282,26 @@ mod tests { solidcraft_engine::render::Rgb(hard.viewport_top.r(), hard.viewport_top.g(), hard.viewport_top.b()) ); - let file = json!({"format": "solidcraft-theme/1", "name": "Paper", "base": "light", "colors": {"panel": "#fffdf0", "accent": "#aa3300"}}); + let file = json!({"format": "solidcraft-theme/1", "name": "Paper", "base": "light", "colors": {"panel": "#fffdf0", "accent": "#aa3300"}, "borders":{"primary":{"width":2,"color":"#ffffff"},"tertiary":{"width":0,"color":"#000000"}}, "radii": {"radius_sm": 0, "control_radius": 0, "radius_xl": 16}}); let out = h.call("ui.prefs", json!({"theme_import": file, "theme_export": true})); assert_eq!(out["result"]["theme_imported"], "paper", "{out}"); assert_eq!((out["result"]["theme"].as_str(), out["result"]["dark"].as_bool()), (Some("paper"), Some(false))); let exported = &out["result"]["theme_file"]; assert_eq!((exported["name"].as_str(), exported["base"].as_str()), (Some("Paper"), Some("light"))); assert_eq!(exported["colors"]["panel"], "#fffdf0"); + assert_eq!(exported["radii"]["control_radius"], 0); + assert_eq!(exported["radii"]["radius_xl"], 16); + assert_eq!(exported["radii"]["radius_md"], 6); + assert_eq!(exported["borders"]["primary"]["width"], 2.0); assert_eq!(exported["colors"]["text"].as_str().map(str::len), Some(7), "an export is complete: {exported}"); h.frames(2); assert_eq!((Tokens::get().panel, Tokens::get().text), (egui::Color32::from_rgb(0xff, 0xfd, 0xf0), Tokens::light().text)); + assert_eq!((Tokens::get().control_radius, Tokens::get().radius_xl), (0, 16)); + let invalid = h.call( + "ui.prefs", + json!({"theme_import": {"format": "solidcraft-theme/1", "name": "Bad", "base": "dark", "radii": {"control_radius": -1}}}), + ); + assert!(invalid["error"].as_str().is_some_and(|e| e.contains("radii.control_radius"))); let out = h.call( "ui.prefs", json!({"theme_import": "{\"format\":\"solidcraft-theme/1\",\"name\":\"x\",\"base\":\"dark\",\"colors\":{\"nope\":\"#000000\"}}"}), @@ -1748,6 +3313,8 @@ mod tests { let mut other = crate::SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); other.load_prefs(&h.app.prefs()); assert_eq!((other.preferences.theme.as_str(), other.preferences.custom_themes.len()), ("paper", 1)); + assert_eq!(other.preferences.custom_themes[0].radii, h.app.preferences.custom_themes[0].radii); + assert_eq!(other.preferences.custom_themes[0].borders, h.app.preferences.custom_themes[0].borders); let out = h.call("ui.prefs", json!({"theme_remove": "paper"})); assert_eq!((out["result"]["theme"].as_str(), out["result"]["dark"].as_bool()), (Some("light"), Some(false)), "{out}"); @@ -1762,6 +3329,7 @@ mod tests { assert!(h.app.preferences.custom_themes.is_empty(), "Cancel takes the import back"); h.frames(2); assert_eq!(Tokens::get().panel, Tokens::dark().panel); + assert_eq!((Tokens::get().control_radius, Tokens::get().radius_xl), (6, 10)); } /// Picking a font closes the font list, not the Preferences window's body: that folded the @@ -1926,6 +3494,19 @@ mod tests { assert_eq!(app.preferences.msaa, Prefs::default().msaa); } + /// "Let AI agents connect" is on for a new install and for preferences saved before it existed; + /// an explicit off stays off. + #[test] + fn agent_control_is_on_unless_saved_off() { + assert!(Prefs::default().agent_control); + let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); + app.preferences.agent_control = false; + app.load_prefs(&serde_json::json!({ "preferences": { "msaa": 4 } }).to_string()); + assert!(app.preferences.agent_control); + app.load_prefs(&serde_json::json!({ "preferences": { "agent_control": false } }).to_string()); + assert!(!app.preferences.agent_control); + } + #[test] fn length_fields_read_bare_numbers_in_design_units_and_honour_explicit_units() { let inch = unit_scale("in").0; diff --git a/crates/ui-egui/src/prefs/design.rs b/crates/ui-egui/src/prefs/design.rs index 40acab12c2..895cb5fba5 100644 --- a/crates/ui-egui/src/prefs/design.rs +++ b/crates/ui-egui/src/prefs/design.rs @@ -51,5 +51,23 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) { ); setting::offer_stand_up(app, ui); ui_kit::text(ui, app.tr("prefs.stand_up_note"), ui_kit::Text::Hint("stand_up")); + setting::extrude_auto_to_object(app, ui); + }); + // The Hole command's own settings, under its name. + ui_kit::settings_section(ui, "create_hole", crate::i18n::tr_label("Create Hole"), |ui| { + setting::hole_snaps(app, ui); + ui_kit::text( + ui, + crate::i18n::tr_label("A Single hole snaps to the middle of its face and to the centres of its round edges"), + ui_kit::Text::Hint("hole_snaps"), + ); + setting::hole_moves(app, ui); + ui_kit::text( + ui, + crate::i18n::tr_label( + "A placed Single hole has a handle to drag it, and picking its face again moves the hole there instead of taking it out", + ), + ui_kit::Text::Hint("hole_moves"), + ); }); } diff --git a/crates/ui-egui/src/prefs/graphics.rs b/crates/ui-egui/src/prefs/graphics.rs index 606b42754c..1ab40ec263 100644 --- a/crates/ui-egui/src/prefs/graphics.rs +++ b/crates/ui-egui/src/prefs/graphics.rs @@ -123,6 +123,7 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) { ui_kit::checkbox(left, crate::i18n::tr_label("Grid"), &mut app.ui.show_grid); ui_kit::checkbox(left, crate::i18n::tr_label("Perspective view"), &mut app.ui.perspective); setting::show_frame_rate(app, left).on_hover_text(app.tr("settings.frame_rate_note")); + setting::reference_geometry_through_bodies(app, left).on_hover_text(app.tr("settings.reference_geometry_through_bodies_help")); } if let Some(right) = columns.get_mut(1) { ui_kit::checkbox(right, crate::i18n::tr_label("Ground shadow"), &mut app.ui.ground_shadow); diff --git a/crates/ui-egui/src/prefs/navigation.rs b/crates/ui-egui/src/prefs/navigation.rs index 0aef83d89a..c69a79b9ce 100644 --- a/crates/ui-egui/src/prefs/navigation.rs +++ b/crates/ui-egui/src/prefs/navigation.rs @@ -119,11 +119,8 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { setting::smooth_wheel_zoom(app, ui); let touchpad = app.preferences.nav == TOUCHPAD_NAV; ui.add_enabled_ui(!touchpad, |ui| { - ui_kit::checkbox(ui, crate::i18n::tr_label("Two-finger scroll pans"), &mut app.preferences.trackpad_pan).on_hover_text(if touchpad { - crate::i18n::tr("nav.touchpad.scroll") - } else { - crate::i18n::tr_label("Trackpad gestures pan (off: they zoom like the wheel); mouse wheel zooms, thumb wheel pans sideways, pinch always zooms") - }); + ui_kit::checkbox(ui, crate::i18n::tr_label("Two-finger scroll pans"), &mut app.preferences.trackpad_pan) + .on_hover_text(if touchpad { crate::i18n::tr("nav.touchpad.scroll") } else { crate::i18n::tr("ui.trackpad_pan_tip") }); }); setting::radial_context_menu(app, ui); }); @@ -170,172 +167,7 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { // The existing ring editor is part of the real product and remains available. crate::mouse_gesture::nav_rows(app, ui, t); }); - ui_kit::settings_section(ui, "devices", app.tr("prefs.devices"), |ui| { - ui.horizontal_wrapped(|ui| { - setting::space_mouse_enabled(app, ui); - let device = app.space_mouse.device.as_ref(); - let saved = device.is_some_and(|device| app.preferences.space_mouse_calibrations.contains_key(device)); - let status = match device { - None => app.tr("ui.space_mouse_none").to_owned(), - Some(device) => format!("{} · {device}", app.tr(if saved { "ui.space_mouse_calibrated" } else { "ui.space_mouse_not_calibrated" })), - }; - ui_kit::settings_badge(ui, &status, !saved); - }); - let mapping = app.space_mouse.mapping; - let rows: Vec<(String, bool)> = (0..6) - .map(|row| { - let label = crate::space_mouse::row_label(mapping, row); - let text = crate::i18n::Locale::for_language(&app.preferences.language).format(label, &[("n", &(row + 1).to_string())]); - (text, label == "ui.space_mouse_view_off") - }) - .collect(); - let driven_by = app.tr("ui.space_mouse_driven_by").to_owned(); - let off_tip = app.tr("ui.space_mouse_view_off_tip").to_owned(); - let motions: Vec = crate::space_mouse::MOTIONS.iter().map(|motion| app.tr(motion).to_owned()).collect(); - let calibration = app.space_mouse.device.as_ref().and_then(|device| app.preferences.space_mouse_calibrations.get(device)); - let raw: [i16; 6] = app.space_mouse.raw.into(); - let travel = calibration.map(|calibration| calibration.travel(raw)); - let zones = calibration.map(|calibration| calibration.dead_zone_shares()); - ui_kit::settings_axis_header(ui); - for (axis, (name, off)) in rows.iter().enumerate() { - ui.push_id(("space_mouse_axis", axis), |ui| { - let from = app.preferences.space_mouse_axis_source.get(axis).copied().unwrap_or(axis); - let value = travel - .and_then(|travel| travel.get(from).copied()) - .unwrap_or_else(|| f32::from(raw.get(from).copied().unwrap_or(0)) / f32::from(crate::space_mouse::FULL_SCALE)); - let zone = zones.and_then(|zones| zones.get(from).copied()).unwrap_or([20.0 / f32::from(crate::space_mouse::FULL_SCALE); 2]); - let enabled = app.preferences.space_mouse_enabled; - let preferences = &mut app.preferences; - let source = &mut preferences.space_mouse_axis_source; - let reverse = preferences.space_mouse_reverse.get_mut(axis); - let speed = preferences.space_mouse_axis_speed.get_mut(axis); - ui_kit::settings_axis_row( - ui, - name, - value, - zone, - |ui| { - ui.add_enabled_ui(enabled && !off, |ui| { - if let Some(reverse) = reverse { - ui_kit::checkbox(ui, crate::i18n::tr_label("Invert"), reverse); - } - }); - }, - |ui| { - ui.add_enabled_ui(enabled && !off, |ui| { - if let Some(speed) = speed { - ui_kit::settings_axis_gain(ui, speed); - } - }); - }, - |ui| { - ui.add_enabled_ui(enabled, |ui| { - let current = source.get(axis).copied().unwrap_or(axis); - let mut chosen = current; - ui_kit::dropdown_menu(ui, ("sc_space_mouse_source", axis), motions.get(current).map_or("", String::as_str), |ui| { - for (from, name) in motions.iter().enumerate() { - if ui_kit::list_picker(ui, name, name, chosen == from).clicked() { - chosen = from; - } - } - }) - .on_hover_text(if *off { &off_tip } else { &driven_by }); - if chosen != current { - crate::space_mouse::reassign(source, axis, chosen); - } - }); - }, - ); - }); - } - ui_kit::settings_form(ui, 100.0, |ui| { - ui_kit::row(ui, app.tr("prefs.overall_speed"), |ui| { - ui.add_enabled_ui(app.preferences.space_mouse_enabled, |ui| { - ui_kit::slider( - ui, - &mut app.preferences.space_mouse_speed, - 0.25..=3.0, - ui_kit::Slider { step: Some(0.05), format: |value| format!("{value:.2}×"), ..Default::default() }, - ); - }); - }); - }); - ui_kit::text(ui, app.tr("prefs.dead_zone_note"), ui_kit::Text::Hint("space_mouse_zone")); - // Only where the reader has seen the choice: Linux, with spacenavd and the device both there. - if let Some(present) = app.space_mouse.drivers.filter(|present| present.spacenavd || present.direct) { - let [label, auto, direct, only_spacenavd, only_direct] = - ["driver", "driver_auto", "driver_direct", "driver_only_spacenavd", "driver_only_direct"] - .map(|key| app.tr(&format!("ui.space_mouse_{key}")).to_owned()); - let driver = &mut app.preferences.space_mouse_driver; - ui_kit::row(ui, &label, |ui| match (present.spacenavd, present.direct) { - (true, true) => { - let name = |driver| match driver { - SpaceMouseDriver::Automatic => auto.as_str(), - SpaceMouseDriver::Spacenavd => "spacenavd", - SpaceMouseDriver::Direct => direct.as_str(), - }; - ui_kit::dropdown_menu(ui, "sc_space_mouse_driver", name(*driver), |ui| { - for choice in SpaceMouseDriver::ALL { - if ui_kit::list_picker(ui, name(choice), name(choice), *driver == choice).clicked() { - *driver = choice; - } - } - }); - } - (true, false) => { - ui_kit::text(ui, &only_spacenavd, ui_kit::Text::Hint("space_mouse_driver")); - } - (false, _) => { - ui_kit::text(ui, &only_direct, ui_kit::Text::Hint("space_mouse_driver")); - } - }); - } - ui.horizontal_wrapped(|ui| { - let device = app.space_mouse.device.clone(); - let saved = device.as_ref().is_some_and(|device| app.preferences.space_mouse_calibrations.contains_key(device)); - ui.add_enabled_ui(app.preferences.space_mouse_enabled && device.is_some(), |ui| { - if ui_kit::button(ui, app.tr("ui.space_mouse_calibrate"), ui_kit::Button::Normal) - .on_hover_text(app.tr("ui.space_mouse_calibrate_tip")) - .clicked() - { - crate::space_mouse_calibration::start(app); - } - }); - ui.add_enabled_ui(app.preferences.space_mouse_enabled && saved, |ui| { - if ui_kit::button(ui, app.tr("ui.space_mouse_forget"), ui_kit::Button::Ghost).clicked() - && let Some(device) = &device - { - app.preferences.space_mouse_calibrations.remove(device); - } - }); - let reassigned = app.preferences.space_mouse_axis_source != crate::space_mouse::SAME_AXES; - ui.add_enabled_ui(app.preferences.space_mouse_enabled && reassigned, |ui| { - if ui_kit::button(ui, app.tr("ui.space_mouse_reset_axes"), ui_kit::Button::Ghost).clicked() { - app.preferences.space_mouse_axis_source = crate::space_mouse::SAME_AXES; - } - }); - }); - let enabled = app.preferences.space_mouse_enabled; - let (label, tip) = (app.tr("ui.space_mouse_dead_zone").to_owned(), app.tr("ui.space_mouse_dead_zone_tip").to_owned()); - let (device, raw) = (app.space_mouse.device.clone(), app.space_mouse.raw.into()); - if let Some(calibration) = device.and_then(|device| app.preferences.space_mouse_calibrations.get_mut(&device)) { - ui_kit::row(ui, &label, |ui| { - ui.add_enabled_ui(enabled, |ui| { - ui_kit::slider( - ui, - &mut calibration.dead_zone_share, - 0.0..=solidcraft_space_mouse::MAX_DEAD_ZONE_SHARE, - ui_kit::Slider { step: Some(0.01), format: |v| format!("{:.0} %", v * 100.0), ..Default::default() }, - ) - .on_hover_text(tip); - }); - }); - crate::space_mouse_calibration::dead_zone_pads(ui, calibration, raw); - } - ui_kit::text( - ui, - crate::i18n::tr_label("Move and rotate the view together; sketches use pan and zoom only."), - ui_kit::Text::Hint("space_mouse"), - ); - }); + // The 3D mouse is a section like the ones above: the driver, calibration, axes, picture and buttons + // (`prefs::space_mouse_section`). + space_mouse_section(app, ui); } diff --git a/crates/ui-egui/src/preselect_tests.rs b/crates/ui-egui/src/preselect_tests.rs index c3d040ab1d..e40d1046f7 100644 --- a/crates/ui-egui/src/preselect_tests.rs +++ b/crates/ui-egui/src/preselect_tests.rs @@ -404,7 +404,7 @@ fn cylinder_emboss_target_face_and_edit_keep_the_anchored_operation() { #[test] fn cylinder_emboss_edit_retains_second_source_and_author_paths_and_all_profiles() { let mut h = crate::scenario::Harness::new(); - let source = h.app.run("component.create", json!({"name":"Source"})).unwrap()["component"].as_u64().unwrap(); + let source = h.app.run("component.create", json!({"name":"Source","grounded":false})).unwrap()["component"].as_u64().unwrap(); h.app.run("solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"})).unwrap(); h.app.run("occurrence.move", json!({"occurrence":"Source:1","axis":[0,0,1],"angle":"90 deg","translate":[50,0,0]})).unwrap(); let source_second = h.app.run("occurrence.copy", json!({"component":source,"translate":[80,0,0]})).unwrap()["occurrence"].as_u64().unwrap(); @@ -499,7 +499,11 @@ fn cylinder_emboss_edit_retains_second_source_and_author_paths_and_all_profiles( fn cylinder_emboss_profile_guide_uses_target_or_unique_foreign_sketch_frame() { for target_owned in [true, false] { let mut h = crate::scenario::Harness::new(); - let source = if target_owned { h.app.run("component.create", json!({"name":"Source"})).unwrap()["component"].as_u64().unwrap() } else { 0 }; + let source = if target_owned { + h.app.run("component.create", json!({"name":"Source","grounded":false})).unwrap()["component"].as_u64().unwrap() + } else { + 0 + }; h.app.run("solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"})).unwrap(); let (profile_owner, expected, target, face) = if target_owned { h.app.run("occurrence.move", json!({"occurrence":"Source:1","axis":[0,0,1],"angle":"90 deg","translate":[50,0,0]})).unwrap(); @@ -507,7 +511,7 @@ fn cylinder_emboss_profile_guide_uses_target_or_unique_foreign_sketch_frame() { let target = h.app.session.occurrence_bodies(&[second], false).unwrap().remove(0); (source, Vec3::new(120.0, 0.0, 0.0), target, json!([120, 0, 7.5])) } else { - let profile = h.app.run("component.create", json!({"name":"Profile"})).unwrap()["component"].as_u64().unwrap(); + let profile = h.app.run("component.create", json!({"name":"Profile","grounded":false})).unwrap()["component"].as_u64().unwrap(); h.app.run("occurrence.move", json!({"occurrence":"Profile:1","axis":[0,0,1],"angle":"90 deg","translate":[50,20,0]})).unwrap(); (profile, Vec3::new(0.0, 10.0, 0.0), "Sleeve".to_string(), json!([0, 10, 7.5])) }; @@ -1477,8 +1481,9 @@ fn newer_options_reach_their_commands() { app.dialog = None; let top = face(&app, Vec3::new(20.0, 15.0, 20.0)); let mut d = start(&mut app, vec![top], "solid.hole"); - d.inputs.push(crate::selection::SelInput::new("To Object", crate::selection::FACES | crate::selection::VERTICES, false)); - d.inputs[1].items = vec![face(&app, Vec3::new(20.0, 15.0, 0.0))]; + let mut to = crate::selection::SelInput::new("To Object", crate::selection::FACES | crate::selection::VERTICES, false); + to.items = vec![face(&app, Vec3::new(20.0, 15.0, 0.0))]; + d.inputs.push(to); let p = apply_commands(&app, &d).unwrap().remove(0).1; assert!(p["to"].is_array() && p.get("depth").is_none(), "{p}"); } diff --git a/crates/ui-egui/src/prompt_cards_tests.rs b/crates/ui-egui/src/prompt_cards_tests.rs index cf49051836..4ef29ac286 100644 --- a/crates/ui-egui/src/prompt_cards_tests.rs +++ b/crates/ui-egui/src/prompt_cards_tests.rs @@ -156,6 +156,7 @@ fn capture_question_offers_capture_revert_and_cancel() { let body = h.app.session.model.state().bodies[0].name.clone(); h.app.run("component.from_bodies", json!({"bodies": [body]})).unwrap(); let occ = h.app.session.doc.occurrences[0].id; + h.app.run("occurrence.ground", json!({"occurrence": occ, "grounded": false})).unwrap(); h.app.run("occurrence.move", json!({"occurrence": occ, "translate": [10, 0, 0], "capture": false})).unwrap(); h.app.start("sketch.create"); card_is_drawn(&mut h); @@ -184,6 +185,7 @@ fn occurrence_move_card_edits_distances_and_captures_or_reverts() { let body = h.app.session.model.state().bodies[0].name.clone(); h.app.run("component.from_bodies", json!({"bodies": [body]})).unwrap(); let occ = h.app.session.doc.occurrences[0].id; + h.app.run("occurrence.ground", json!({"occurrence": occ, "grounded": false})).unwrap(); h.app.tree.occurrence_move = Some(crate::browser::OccMove::new(occ)); card_is_drawn(&mut h); for k in 0..3 { diff --git a/crates/ui-egui/src/render_workspace.rs b/crates/ui-egui/src/render_workspace.rs index 4b106a02bb..82b2343f97 100644 --- a/crates/ui-egui/src/render_workspace.rs +++ b/crates/ui-egui/src/render_workspace.rs @@ -362,7 +362,7 @@ pub fn panel(app: &mut SolveApp, ui: &mut egui::Ui) { app.render_workspace.view.draft = app.session.doc.render.setups.get(&app.render_workspace.view.name).cloned().unwrap_or_default(); app.render_workspace.revision = app.session.revision; } - Container::new(ui.ctx(), Surface::Panel) + let response = Container::new(ui.ctx(), Surface::Panel) .edge("sc_render_settings", Edge::Right) .exact_size(285.0) .resizable(false) @@ -448,6 +448,8 @@ pub fn panel(app: &mut SolveApp, ui: &mut egui::Ui) { }); }); }); + let frame = Container::new(ui.ctx(), Surface::Panel).edge_frame(Tokens::get().panel).inner_margin(10); + Container::paint_inset(ui.painter(), response.response.rect - frame.outer_margin, frame); } fn image_controls(app: &mut SolveApp, ui: &mut egui::Ui) { diff --git a/crates/ui-egui/src/revolve_edit_tests.rs b/crates/ui-egui/src/revolve_edit_tests.rs index 1f939fcd0a..bd589143e9 100644 --- a/crates/ui-egui/src/revolve_edit_tests.rs +++ b/crates/ui-egui/src/revolve_edit_tests.rs @@ -94,7 +94,7 @@ fn to_object_noop_retains_dormant_secondary_angle_and_downstream_dimension() { #[test] fn rotated_owner_to_point_remains_exact_on_noop_edit() { let mut h = Harness::new(); - let owner = h.app.run("component.create", json!({"name":"Owner"})).unwrap(); + let owner = h.app.run("component.create", json!({"name":"Owner","grounded":false})).unwrap(); let mut h = make_params(h, json!({"origin":[0,0,0],"dir":[0,2,0]}), json!({"to":[0,2,12],"angle":"45 deg"})); h.app.run("occurrence.move", json!({"occurrence":owner["occurrence"],"translate":[100,0,20],"axis":[0,0,1],"angle":"17 deg"})).unwrap(); h.app.run("component.create", json!({"name":"Other","parent":0})).unwrap(); @@ -192,7 +192,7 @@ fn revolve_angle_edit_preserves_axis_downstream_binding_and_one_undo() { #[test] fn rotated_owner_explicit_axis_is_lossless_with_another_component_active() { let mut h = Harness::new(); - let owner = h.app.run("component.create", json!({"name":"Owner"})).unwrap(); + let owner = h.app.run("component.create", json!({"name":"Owner","grounded":false})).unwrap(); let mut h = make(h, json!({"origin":[0,0,0],"dir":[0,2,0]})); h.app.run("occurrence.move", json!({"occurrence":owner["occurrence"],"translate":[100,0,20],"axis":[0,0,1],"angle":"17 deg"})).unwrap(); h.app.run("component.create", json!({"name":"Other","parent":0})).unwrap(); @@ -214,7 +214,7 @@ fn rotated_owner_explicit_axis_is_lossless_with_another_component_active() { fn world_and_sketch_axis_handles_follow_authoring_occurrence() { for selected in [json!("Y"), json!("y")] { let mut h = Harness::new(); - let owner = h.app.run("component.create", json!({"name":"Owner"})).unwrap(); + let owner = h.app.run("component.create", json!({"name":"Owner","grounded":false})).unwrap(); let mut h = make(h, selected); h.app.run("occurrence.move", json!({"occurrence":owner["occurrence"],"translate":[100,0,20],"axis":[0,0,1],"angle":"17 deg"})).unwrap(); let original = axis(&h); diff --git a/crates/ui-egui/src/scenario.rs b/crates/ui-egui/src/scenario.rs index 5e0170f30c..1e9bb49cc4 100644 --- a/crates/ui-egui/src/scenario.rs +++ b/crates/ui-egui/src/scenario.rs @@ -3,7 +3,7 @@ //! drags, toolbar starts) the way a person would use it, with checks on the result. //! //! A scenario is a JSON list of steps: -//! - `{"start": "solid.extrude"}`: a toolbar click; `{"key": "E", "shift"?, "cmd"?}`; `{"text": "20"}` +//! - `{"start": "solid.extrude"}`: a toolbar click; `{"key": "E", "shift"?, "cmd"?, "ctrl"?, "alt"?, "pressed"?}`; `{"text": "20"}` //! - `{"widget": "Revolute", "near"?: "Type"}`: click a dialog field, choice or button by its //! text (with `near`: the one in that label's row) //! - `{"click": AT, "double"?, "shift"?, "ctrl"?, "button"?}`, `{"move": AT, "shift"?, "alt"?, "ctrl"?, "cmd"?}`, @@ -31,6 +31,7 @@ //! "close"}`: the app dies (or closes) and a new one starts on the same folders //! - `{"script": "examples/…/x.json"}`: run a command script through the engine; `"$DIR"` in a //! `call` step's string parameters is the scenario's folder +//! - `{"frames": 20}`: advance up to 600 frames at the harness clock (60 Hz) //! - `{"note": "…"}`: a comment; `{"pending": "why"}`: the scenario waits for a fix (its test //! is ignored) //! - `{"expect": {…}}`: checks, see [`check`]; `{"until": {…}}` waits (frames) until they pass @@ -415,7 +416,22 @@ impl Harness { if let Some(id) = s.get("start").and_then(Value::as_str) { self.call("ui.start", json!({ "command": id })) } else if let Some(k) = s.get("key").and_then(Value::as_str) { - self.call("ui.key", json!({"key": k, "shift": flag("shift"), "cmd": flag("cmd")})) + { + let mut params = json!({"key": k, "shift": flag("shift"), "cmd": flag("cmd"), "alt": flag("alt")}); + if let Some(ctrl) = s.get("ctrl") { + params["ctrl"] = ctrl.clone(); + } + if let Some(pressed) = s.get("pressed") { + params["pressed"] = pressed.clone(); + } + self.call("ui.key", params) + } + } else if let Some(n) = s.get("frames").and_then(Value::as_u64) { + if n > 600 { + return Err("frames must be at most 600".into()); + } + self.frames(n as usize); + return Ok(()); } else if let Some(t) = s.get("text").and_then(Value::as_str) { self.call("ui.text", json!({ "text": t })) } else if let Some(at) = s.get("click") { @@ -1075,6 +1091,14 @@ pub fn check(h: &mut Harness, e: &Value) -> Result<(), String> { return Err(format!("browser selection: {}", r["result"])); } } + "shortcuts" => { + for (id, want) in v.as_object().ok_or("shortcuts takes a command-to-key object")? { + let got = h.call("ui.shortcut", json!({"command": id})); + if got["result"]["key"] != *want { + return Err(format!("shortcut {id}: got {}, want {want}", got["result"]["key"])); + } + } + } "query" => { // {command, params?, path: [keys or indices], value}: a command's result at path. let cmd = v["command"].as_str().ok_or("query needs `command`")?; diff --git a/crates/ui-egui/src/section_browser_tests.rs b/crates/ui-egui/src/section_browser_tests.rs index 746a2187c3..c060bf11f9 100644 --- a/crates/ui-egui/src/section_browser_tests.rs +++ b/crates/ui-egui/src/section_browser_tests.rs @@ -173,3 +173,32 @@ fn the_hatch_option_carries_through_the_section() { let d = h.app.dialog.as_ref().map(|d| &d.kind); assert!(matches!(d, Some(crate::dialogs::Kind::Section { hatch: false, .. })), "{d:?}"); } + +/// With a section on and no plane picked, the dialog says OK turns the section off: Cancel keeps +/// it and OK removes it. With no section on, OK still asks for a plane (#2410). +#[test] +fn ok_with_no_plane_picked_turns_the_section_off() { + let mut h = harness(); + h.app.run("solid.box", json!({"length":40,"width":30,"height":20})).unwrap(); + h.app.run("inspect.section", json!({"plane": "XZ", "offset": 10})).unwrap(); + h.app.session.selection.clear(); + h.app.start("inspect.section"); + h.frames(3); + assert!(section_dialog(&h).2.is_empty(), "no plane picked"); + assert!(crate::scenario::handle_at("dialog:hint:section_off").is_some(), "the dialog says OK turns the section off"); + h.step(&json!({"widget":"Cancel"})).unwrap(); + h.frames(3); + assert!(h.app.dialog.is_none() && h.app.session.section.is_some(), "Cancel keeps the section"); + + h.app.start("inspect.section"); + ok(&mut h); + assert_eq!(h.app.session.section, None, "OK with no plane picked turns the section off"); + assert!(crate::scenario::handle_at("row:Section Analysis").is_none(), "the browser no longer lists it"); + + h.app.start("inspect.section"); + h.frames(3); + assert!(crate::scenario::handle_at("dialog:hint:section_off").is_none(), "no section to turn off"); + h.step(&json!({"widget":"OK"})).unwrap(); + h.frames(3); + assert_eq!(h.app.dialog.as_ref().and_then(|d| d.error.as_deref()), Some("select a plane or planar face first")); +} diff --git a/crates/ui-egui/src/select_other.rs b/crates/ui-egui/src/select_other.rs index 070418267e..00e310274c 100644 --- a/crates/ui-egui/src/select_other.rs +++ b/crates/ui-egui/src/select_other.rs @@ -539,7 +539,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { crate::scenario::publish_handle(&format!("select_other:{i}"), r.center()); if resp.hovered() { hovered = Some(i); - ui.painter().rect_filled(r.shrink2(vec2(2.0, 1.0)), 4.0, t.hover); + ui.painter().rect_filled(r.shrink2(vec2(2.0, 1.0)), t.radius_sm, t.hover); } crate::icons::paint_icon( ui.painter(), @@ -547,13 +547,19 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { row.icon, f32::from(t.icon.a()) / 255.0, ); - ui.painter().text( - pos2(r.left() + 32.0, r.center().y), - Align2::LEFT_CENTER, - &row.label, - FontId::proportional(crate::theme::Tokens::get().font_button), - t.text, - ); + // Names can be wider than the list. Keep one line inside the row and + // expose the full name on hover, as the browser tree does. + let text_rect = Rect::from_min_max(pos2(r.left() + 32.0, r.top()), pos2(r.right() - 8.0, r.bottom())); + let mut job = + egui::text::LayoutJob::simple(row.label.clone(), FontId::proportional(t.font_button), t.text, text_rect.width()); + job.wrap.max_rows = 1; + job.wrap.break_anywhere = true; + job.wrap.overflow_character = Some('\u{2026}'); + let galley = ui.painter().layout_job(job); + let elided = galley.elided; + let at = pos2(text_rect.left(), r.center().y - galley.size().y / 2.0); + ui.painter().with_clip_rect(ui.clip_rect().intersect(text_rect)).galley(at, galley, t.text); + let resp = if elided { resp.on_hover_text(&row.label) } else { resp }; if resp.clicked() { picked = Some(i); } diff --git a/crates/ui-egui/src/select_other_tests.rs b/crates/ui-egui/src/select_other_tests.rs index 23b2e8cf02..69366eb256 100644 --- a/crates/ui-egui/src/select_other_tests.rs +++ b/crates/ui-egui/src/select_other_tests.rs @@ -135,6 +135,44 @@ fn overlapping_bodies_list_both_and_the_hidden_one_can_be_picked() { assert_eq!(h.app.session.selection, [Sel::Body { name: "Inner".into() }]); } +#[test] +fn long_names_stay_inside_the_popup_and_remain_available_on_hover() { + let mut h = Harness::new(); + h.app.ui.show_origin = false; + h.call("ui.resize", json!({"width":1000,"height":700})); + let name = "Long assembly component ".repeat(12); + h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":name})).unwrap(); + h.call("ui.view", json!({"view":"top"})); + h.call("ui.view", json!({"view":"fit"})); + h.step(&json!({"hold": {"world": [5, 5, 10]}})).unwrap(); + let label = format!("Body · {name}"); + let row = labels(&h).iter().position(|r| r == &label).unwrap(); + let mut output = h.ctx.run_ui(egui::RawInput { screen_rect: Some(h.ctx.content_rect()), focused: true, ..Default::default() }, |ui| { + h.app.logic(ui.ctx()); + h.app.ui(ui); + }); + output.textures_delta.clear(); + let text = output + .shapes + .iter() + .find_map(|s| match &s.shape { + egui::Shape::Text(t) if t.galley.job.text == label => Some(t), + _ => None, + }) + .expect("the body label is painted"); + let popup = h.ctx.memory(|m| m.area_rect(egui::Id::new("sc_select_other"))).unwrap(); + let text_rect = egui::Rect::from_min_size(text.pos, text.galley.size()); + assert!(popup.contains_rect(text_rect), "{text_rect:?} spills outside {popup:?}"); + assert!(h.ctx.content_rect().contains_rect(popup)); + assert!(text.galley.elided, "long names end in an ellipsis"); + + h.step(&json!({"move": {"handle": format!("select_other:{row}")}})).unwrap(); + h.frames(90); + assert!(h.widget(&label).is_some(), "the tooltip exposes the full label"); + h.step(&json!({"click": {"handle": format!("select_other:{row}")}})).unwrap(); + assert_eq!(h.app.session.selection, [Sel::Body { name }], "the shortened label still selects the original body"); +} + #[test] fn a_dialog_selection_input_takes_its_pick_from_the_list() { let mut h = Harness::new(); diff --git a/crates/ui-egui/src/selection.rs b/crates/ui-egui/src/selection.rs index 7cf35660d4..92dd0dfa82 100644 --- a/crates/ui-egui/src/selection.rs +++ b/crates/ui-egui/src/selection.rs @@ -40,6 +40,9 @@ pub const FEATURES: Accept = 1024; pub const SKETCH_POINTS: Accept = 2048; /// The origin point, picked as a vertex of no body (in the viewport or the browser's Origin). pub const ORIGIN: Accept = 4096; +/// Curves of any sketch, picked with their sketch (`ExternalSketchCurve`): curve ids are a +/// sketch's own, so two sketches can both have a `w2` (frame member lines). +pub const SKETCH_LINES: Accept = 8192; /// One selection input of a command dialog ("Profiles", "Edges", "Plane", ...). #[derive(Clone, Debug, PartialEq)] @@ -78,6 +81,7 @@ impl SelInput { Hit::Plane { name, .. } if a & PLANES != 0 => Some(Sel::Plane { name: name.clone() }), Hit::Axis { name } if a & AXES != 0 => Some(Sel::Axis { name: name.clone() }), Hit::SketchCurve { id, straight: true, .. } if a & AXES != 0 => Some(Sel::SketchCurve { id: id.clone() }), + Hit::SketchCurve { sketch, id, .. } if a & SKETCH_LINES != 0 => Some(Sel::ExternalSketchCurve { sketch: *sketch, id: id.clone() }), Hit::SketchCurve { id, .. } if a & CURVES != 0 => Some(Sel::SketchCurve { id: id.clone() }), Hit::SketchPoint { id, .. } if a & SKETCH_POINTS != 0 => Some(Sel::SketchPoint { id: id.clone() }), Hit::SketchPoint { sketch, id, .. } if a & POINTS != 0 => Some(Sel::SketchPoint { id: format!("{sketch}:{id}") }), diff --git a/crates/ui-egui/src/shell.rs b/crates/ui-egui/src/shell.rs index 1bf91d86cf..f1048c486a 100644 --- a/crates/ui-egui/src/shell.rs +++ b/crates/ui-egui/src/shell.rs @@ -31,7 +31,7 @@ pub(crate) const SHELL_GAP: f32 = 7.0; /// The viewport's top corners are rounded concentrically with the cards: the card margin plus the /// card radius. pub(crate) fn corner_radius() -> f32 { - SHELL_GAP + f32::from(Tokens::get().radius_lg) + Tokens::outset_radius(f32::from(Tokens::get().radius_lg), SHELL_GAP) } /// Fill the two concave corners where the application bar flows into the viewport's top edge, so diff --git a/crates/ui-egui/src/shortcut_box.rs b/crates/ui-egui/src/shortcut_box.rs index 7f672672b7..8660046e77 100644 --- a/crates/ui-egui/src/shortcut_box.rs +++ b/crates/ui-egui/src/shortcut_box.rs @@ -480,7 +480,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { let mut item = ui_kit::MenuItem::new(id, label, icon); item.enabled = enabled.get(index).copied().unwrap_or(false); item.highlighted = index == app.sbox.index; - let shortcut = crate::keymap::effective(app, id).unwrap_or_default(); + let shortcut = crate::keymap::display_for(app, id).unwrap_or_default(); item.shortcut = &shortcut; let path = place.map_or_else(|| id.to_string(), |(tab, panel)| format!("{tab} › {panel}")); let response = item.search_row(ui, &query, &path, None); diff --git a/crates/ui-egui/src/sketch_dims.rs b/crates/ui-egui/src/sketch_dims.rs index c172d6aacb..571d7a0b3c 100644 --- a/crates/ui-egui/src/sketch_dims.rs +++ b/crates/ui-egui/src/sketch_dims.rs @@ -41,12 +41,15 @@ pub fn boxes_for(cmd: &str, n: usize) -> Vec { ("sketch.rectangle.two_point" | "sketch.rectangle.center", 1) => vec![b("Width", Length), b("Height", Length)], ("sketch.rectangle.rounded", 1) => vec![b("Width", Length), b("Height", Length), b("Radius", Length)], ("sketch.circle.center", 1) => vec![b("Diameter", Length)], - ("sketch.arc.center_point", 1) => vec![b("Radius", Length)], - ("sketch.arc.center_point", 2) => vec![b("Sweep", Angle)], + // The two points are a diameter's ends (#3278). + ("sketch.circle.two_point", 1) => vec![b("Diameter", Length)], + ("sketch.arc.center_point" | "sketch.slot.arc_center", 1) => vec![b("Radius", Length)], + ("sketch.arc.center_point" | "sketch.slot.arc_center", 2) => vec![b("Sweep", Angle)], ("sketch.polygon.inscribed" | "sketch.polygon.circumscribed", 1) => vec![b("Radius", Length), b("Sides", Unitless)], - ("sketch.slot.center_to_center" | "sketch.slot.overall", 1) => vec![b("Length", Length)], + ("sketch.slot.center_to_center" | "sketch.slot.overall" | "sketch.slot.center_point", 1) => vec![b("Length", Length)], ("sketch.conic", 2) => vec![b("Rho", Unitless)], - ("sketch.slot.center_to_center" | "sketch.slot.overall", 2) => vec![b("Width", Length)], + ("sketch.slot.center_to_center" | "sketch.slot.overall" | "sketch.slot.center_point", 2) + | ("sketch.slot.arc_center" | "sketch.slot.arc_three_point", 3) => vec![b("Width", Length)], _ => Vec::new(), } } @@ -85,8 +88,10 @@ pub fn effective(app: &SolveApp, t: &Tool, h: Vec2) -> Vec2 { Vec2::new(a.x + lk("Width").map_or(d.x, |w| w * 0.5 * sign(d.x)), a.y + lk("Height").map_or(d.y, |hh| hh * 0.5 * sign(d.y))) } ("sketch.circle.center", 1) => a + dir_or_x(h - a) * lk("Diameter").map_or((h - a).len(), |d| d * 0.5), - ("sketch.arc.center_point", 1) => a + dir_or_x(h - a) * lk("Radius").unwrap_or((h - a).len()), - ("sketch.arc.center_point", 2) => { + ("sketch.circle.two_point", 1) => a + dir_or_x(h - a) * lk("Diameter").unwrap_or((h - a).len()), + ("sketch.slot.center_point", 1) => a + dir_or_x(h - a) * lk("Length").map_or((h - a).len(), |l| l * 0.5), + ("sketch.arc.center_point" | "sketch.slot.arc_center", 1) => a + dir_or_x(h - a) * lk("Radius").unwrap_or((h - a).len()), + ("sketch.arc.center_point" | "sketch.slot.arc_center", 2) => { let c = t.pts.first().map(|p| p.0).unwrap_or(a); let r = (a - c).len(); let a0 = (a - c).angle(); @@ -94,7 +99,7 @@ pub fn effective(app: &SolveApp, t: &Tool, h: Vec2) -> Vec2 { c + Vec2::from_angle(a0 + sweep) * r } ("sketch.polygon.inscribed" | "sketch.polygon.circumscribed", 1) => a + dir_or_x(h - a) * lk("Radius").unwrap_or((h - a).len()), - ("sketch.slot.center_to_center" | "sketch.slot.overall", 2) => match lk("Width") { + ("sketch.slot.center_to_center" | "sketch.slot.overall" | "sketch.slot.center_point", 2) => match lk("Width") { Some(w) => { let c0 = t.pts.first().map(|p| p.0).unwrap_or(a); let dir = dir_or_x(a - c0); @@ -104,6 +109,12 @@ pub fn effective(app: &SolveApp, t: &Tool, h: Vec2) -> Vec2 { } None => h, }, + ("sketch.slot.arc_center", 3) => { + let Some(c) = t.pts.first().map(|p| p.0) else { return h }; + let Some(start) = t.pts.get(1).map(|p| p.0) else { return h }; + lk("Width").map_or(h, |w| c + dir_or_x(h - c) * (c.dist(start) + w * 0.5)) + } + ("sketch.slot.arc_three_point", 3) => lk("Width").map_or(h, |w| a + dir_or_x(h - a) * (w * 0.5)), _ => h, } } @@ -113,7 +124,20 @@ pub fn effective(app: &SolveApp, t: &Tool, h: Vec2) -> Vec2 { /// sketch geometry stays there, and one on a curve slides along it to where the axis crosses. pub fn placed(app: &SolveApp, t: &Tool, p: &(Vec2, Option)) -> (Vec2, Option) { let line = t.cmd == "sketch.line"; - if t.dims.iter().any(|b| b.locked) || (line && p.1.is_none()) { + // A typed length or angle wins over every snap. + if t.dims.iter().any(|b| b.locked) { + return (effective(app, t, p.0), None); + } + // A line snapped through a circle's centre stays on that radial line. The axis snap would + // pull a near-horizontal diameter off the centre. + if line + && let Some(i) = crate::inference::current() + && i.kind == crate::inference::Kind::Center + && i.at.dist(p.0) < 1e-9 + { + return p.clone(); + } + if line && p.1.is_none() { return (effective(app, t, p.0), None); } if line && let Some(q) = on_curve_axis(app, t, p) { @@ -193,6 +217,7 @@ fn live(app: &SolveApp, t: &Tool, e: Vec2, px: f64) -> Vec<(f64, Vec2)> { let n = d.perp(); vec![(v.len(), (a + e) * 0.5 + n * off), (v.angle(), a + d * (v.len() * 0.3).min(60.0 * px) - n * off)] } + ("sketch.slot.center_point", 1) => vec![(2.0 * (e - a).len(), (a + e) * 0.5 + dir_or_x(e - a).perp() * off)], ("sketch.rectangle.two_point", 1) => { let (sx, sy) = (sign(e.x - a.x), sign(e.y - a.y)); vec![ @@ -216,10 +241,11 @@ fn live(app: &SolveApp, t: &Tool, e: Vec2, px: f64) -> Vec<(f64, Vec2)> { vec![(2.0 * d.x.abs(), Vec2::new(a.x, a.y - d.y - sy * off)), (2.0 * d.y.abs(), Vec2::new(a.x + d.x + sx * 2.0 * off, a.y))] } ("sketch.circle.center", 1) => vec![(2.0 * (e - a).len(), (a + e) * 0.5 + dir_or_x(e - a).perp() * off)], - ("sketch.arc.center_point", 1) | ("sketch.polygon.inscribed" | "sketch.polygon.circumscribed", 1) => { + ("sketch.circle.two_point", 1) => vec![((e - a).len(), (a + e) * 0.5 + dir_or_x(e - a).perp() * off)], + ("sketch.arc.center_point" | "sketch.slot.arc_center", 1) | ("sketch.polygon.inscribed" | "sketch.polygon.circumscribed", 1) => { vec![((e - a).len(), (a + e) * 0.5), (6.0, a + (e - a) * 0.5 + (e - a).perp() * 0.3)] } - ("sketch.arc.center_point", 2) => { + ("sketch.arc.center_point" | "sketch.slot.arc_center", 2) => { let a0 = (a - first).angle(); // Out along the radius, clear of the cursor: a box on it would take the hover and // the click meant for a point there (#2201). @@ -235,12 +261,17 @@ fn live(app: &SolveApp, t: &Tool, e: Vec2, px: f64) -> Vec<(f64, Vec2)> { let d = e - mid; vec![(rho, mid + d * rho + dir_or_x(d).perp() * off)] } - ("sketch.slot.center_to_center" | "sketch.slot.overall", 2) => { + ("sketch.slot.center_to_center" | "sketch.slot.overall" | "sketch.slot.center_point", 2) => { let dir = dir_or_x(a - first); let side = if dir.cross(e - first) < 0.0 { -1.0 } else { 1.0 }; // Beyond the cursor, away from the centre line, as for the arc (#2201). vec![(2.0 * dir.cross(e - first).abs(), e + dir.perp() * (side * 2.5 * off))] } + ("sketch.slot.arc_center", 3) => { + let Some(start) = t.pts.get(1).map(|p| p.0) else { return Vec::new() }; + vec![(2.0 * (e.dist(first) - start.dist(first)).abs(), e + dir_or_x(e - first) * (2.5 * off))] + } + ("sketch.slot.arc_three_point", 3) => vec![(2.0 * e.dist(a), e + dir_or_x(e - a) * (2.5 * off))], _ => Vec::new(), } } @@ -262,9 +293,15 @@ fn fmt(app: &SolveApp, label: &str, kind: ValueKind, v: f64) -> String { /// Make the tool's boxes match its stage (new boxes when a point was placed). pub fn sync(t: &mut Tool) { if t.dims_stage != t.pts.len() { - if t.dims_stage == 1 - && t.pts.len() == 2 - && matches!(t.cmd, "sketch.arc.center_point" | "sketch.slot.center_to_center" | "sketch.slot.overall") + if t.pts.len() == t.dims_stage.saturating_add(1) + && matches!( + t.cmd, + "sketch.arc.center_point" + | "sketch.slot.center_to_center" + | "sketch.slot.overall" + | "sketch.slot.center_point" + | "sketch.slot.arc_center" + ) { t.dim_history.extend(t.dims.iter().filter(|dimension| dimension.locked).cloned()); } else if t.pts.len() <= t.dims_stage || t.cmd == "sketch.line" { @@ -315,6 +352,9 @@ pub fn validate(app: &SolveApp, t: &Tool) -> Result<(), String> { } match dimension.label { "Angle" => {} + "Sweep" if t.cmd == "sketch.slot.arc_center" && value <= 0.0 => { + return Err("Arc slot Sweep must be greater than zero and less than 360 degrees".into()); + } "Sweep" if value.abs() > 1e-9 && value.abs() < std::f64::consts::TAU - 1e-9 => {} "Sweep" => return Err("Sweep must lie strictly between -360 and 360 degrees, and cannot be zero".into()), "Sides" if (3.0..=200.0).contains(&value) && value.fract() == 0.0 => {} diff --git a/crates/ui-egui/src/sketch_dims_tests.rs b/crates/ui-egui/src/sketch_dims_tests.rs index b0fd1d543e..3c075a00a9 100644 --- a/crates/ui-egui/src/sketch_dims_tests.rs +++ b/crates/ui-egui/src/sketch_dims_tests.rs @@ -154,6 +154,24 @@ fn clicking_a_parameter_suggestion_fills_the_length_box() { assert!(length.is_some_and(|l| (l - 24.0).abs() < 1e-6), "{length:?}"); } +/// #3278: after the 2-point circle's first point, a Diameter box takes the keyboard; the typed +/// diameter sets the second point along the cursor's direction, and Enter draws that circle. +#[test] +fn two_point_circle_takes_a_typed_diameter() { + let mut h = drawing("sketch.circle.two_point"); + assert_eq!(h.app.tool.as_ref().unwrap().dims.iter().map(|b| b.label).collect::>(), ["Diameter"]); + type_value(&mut h, "30"); + h.step(&json!({"key": "Enter"})).unwrap(); + let state = h.app.session.world_state(); + let sk = &state.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch; + assert_eq!(sk.curves.len(), 1, "{:?}", h.app.status); + assert!(sk.radius(0).is_some_and(|r| (r - 15.0).abs() < 1e-6), "{:?}", sk.radius(0)); + let c = sk.center(0).unwrap(); + // From the first point (the origin) toward the cursor at (20, 10). + let dir = solidcraft_engine::geom::Vec2::new(20.0, 10.0).normalized().unwrap(); + assert!((c - dir * 15.0).len() < 1e-6, "{c:?}"); +} + #[test] fn inch_live_lengths_use_design_units_and_typed_four_digits_commit_in_inches() { let mut h = Harness::new(); diff --git a/crates/ui-egui/src/sketch_display_preview_tests.rs b/crates/ui-egui/src/sketch_display_preview_tests.rs index 06e54483b4..d133e2c5c6 100644 --- a/crates/ui-egui/src/sketch_display_preview_tests.rs +++ b/crates/ui-egui/src/sketch_display_preview_tests.rs @@ -205,9 +205,10 @@ fn slot_preview_matches_every_committed_side_and_cap() { .unwrap_or_else(|| panic!("{command}: no slot outline before the width click")); let ctx = egui::Context::default(); let proj = projection(2.0); - let output = ctx.run_ui(egui::RawInput { screen_rect: Some(proj.rect), ..Default::default() }, |ui| { + let mut output = ctx.run_ui(egui::RawInput { screen_rect: Some(proj.rect), ..Default::default() }, |ui| { super::paint_preview_curve(preview.clone(), &Plane::XY, ui.painter(), &proj, egui::Stroke::new(1.5, egui::Color32::WHITE), 0); }); + output.textures_delta.clear(); assert_eq!( output.shapes.iter().filter(|shape| matches!(shape.shape, egui::Shape::Path(_))).count(), 4, @@ -248,7 +249,8 @@ fn typed_small_slot_width_preview_matches_the_final_click() { let ctx = egui::Context::default(); let proj = projection(2_000_000.0); let input = || egui::RawInput { screen_rect: Some(proj.rect), ..Default::default() }; - let output = ctx.run_ui(input(), |ui| super::preview(&h.app, &t, ui.painter(), &proj)); + let mut output = ctx.run_ui(input(), |ui| super::preview(&h.app, &t, ui.painter(), &proj)); + output.textures_delta.clear(); let paths = |output: egui::FullOutput| { output .shapes @@ -270,9 +272,10 @@ fn typed_small_slot_width_preview_matches_the_final_click() { assert_eq!(committed.curves.len(), 4, "{command}: final click commits the typed width"); let solidcraft_engine::geom::Seg2::Arc { radius, .. } = committed.segs(1)[0] else { panic!("missing slot cap") }; assert!((radius - 0.00005).abs() < 1e-10, "{command}: the typed width determines the cap radius"); - let output = ctx.run_ui(input(), |ui| { + let mut output = ctx.run_ui(input(), |ui| { super::paint_preview_curve(committed.clone(), &Plane::XY, ui.painter(), &proj, egui::Stroke::new(1.5, egui::Color32::WHITE), 0); }); + output.textures_delta.clear(); let expected = paths(output); assert!(!expected.is_empty(), "{command}: the zoomed cap must be visible"); assert_eq!(preview.len(), expected.len(), "{command}: preview and committed paths"); diff --git a/crates/ui-egui/src/sketch_guidance_ui_tests.rs b/crates/ui-egui/src/sketch_guidance_ui_tests.rs index 6512ee6d59..86441347f5 100644 --- a/crates/ui-egui/src/sketch_guidance_ui_tests.rs +++ b/crates/ui-egui/src/sketch_guidance_ui_tests.rs @@ -205,7 +205,7 @@ fn sketch_preselected_points_toggle_once_and_empty_shift_box_keeps_reference() { #[test] fn sketch_modify_previews_use_the_active_occurrences_world_placement() { let mut h = Harness::new(); - let part = h.app.run("component.create", json!({"name":"Part"})).unwrap(); + let part = h.app.run("component.create", json!({"name":"Part","grounded":false})).unwrap(); h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); let sid = h.app.session.active_sketch.unwrap(); h.app.run("sketch.line", json!({"points":[[-30,0],[-20,0]]})).unwrap(); diff --git a/crates/ui-egui/src/sketch_modify_ui.rs b/crates/ui-egui/src/sketch_modify_ui.rs index be2263c98d..ae21eaf6e9 100644 --- a/crates/ui-egui/src/sketch_modify_ui.rs +++ b/crates/ui-egui/src/sketch_modify_ui.rs @@ -26,6 +26,7 @@ use egui::Pos2; use serde_json::{Map, Value, json}; use solidcraft_engine::Sel; +use solidcraft_engine::doc::Document; use solidcraft_engine::doc::expr::Kind as ValueKind; use solidcraft_engine::geom::{Vec2, Vec3}; use solidcraft_engine::sketch::PatternAlignment; @@ -330,6 +331,20 @@ fn number(ui: &mut egui::Ui, label: &str, value: &mut String) -> bool { ui_kit::row(ui, label, |ui| ui_kit::field(ui, value)) } +/// A pattern quantity row with step arrows. They keep the count from `min` up to `max` +/// copies, evaluated against the design's parameters as `whole` evaluates it on OK. +fn quantity(ui: &mut egui::Ui, doc: &Document, label: &str, value: &mut String, min: i64, max: i64) -> bool { + let eval = |text: &str| doc.eval(text.trim(), ValueKind::Unitless).ok(); + ui_kit::count_row(ui, label, value, &ui_kit::Count { min, max: max.max(min), eval: &eval }) +} + +/// How many copies the other direction of a rectangular pattern leaves this one, so that +/// both together stay within `MAX_COPIES`. All of them while the other count is not a number. +fn copies_left(doc: &Document, other: &str) -> i64 { + let other = doc.eval(other.trim(), ValueKind::Unitless).ok().filter(|v| v.is_finite() && *v >= 1.0).map_or(1.0, f64::round); + (MAX_COPIES / other).floor() as i64 +} + fn icons(ui: &mut egui::Ui, label: &str, labels: &[&'static str], icons: &[&'static str], selected: &mut usize) { ui_kit::row(ui, label, |ui| { let choices: Vec<_> = @@ -356,7 +371,8 @@ fn pattern_hint(ui: &mut egui::Ui) { } /// Dense rows after the selection inputs; true when an expression field accepts Enter. -pub fn rows(ui: &mut egui::Ui, k: &mut Mod) -> bool { +/// `doc` evaluates the pattern counts the step arrows change. +pub fn rows(ui: &mut egui::Ui, k: &mut Mod, doc: &Document) -> bool { let mut enter = false; match k { Mod::Scale { factor } => enter |= number(ui, "Factor", factor), @@ -381,12 +397,13 @@ pub fn rows(ui: &mut egui::Ui, k: &mut Mod) -> bool { ui_kit::options(ui, "move_options", &mut [("Create Copy", copy)]); } Mod::PatternRect { count, spacing, count2, spacing2, alignment: a, alignment2, reversed, reversed2 } => { - for (key, title, count, spacing, align, reverse, quantity, distance, align_label) in [ - ("direction1", "Direction 1", count, spacing, a, reversed, "Quantity", "Spacing", "Alignment"), - ("direction2", "Direction 2", count2, spacing2, alignment2, reversed2, "Quantity 2", "Spacing 2", "Alignment 2"), + let (left1, left2) = (copies_left(doc, count2), copies_left(doc, count)); + for (key, title, count, spacing, align, reverse, label, distance, align_label, left) in [ + ("direction1", "Direction 1", count, spacing, a, reversed, "Quantity", "Spacing", "Alignment", left1), + ("direction2", "Direction 2", count2, spacing2, alignment2, reversed2, "Quantity 2", "Spacing 2", "Alignment 2", left2), ] { ui_kit::section(ui, key, title, true, |ui| { - enter |= number(ui, quantity, count); + enter |= quantity(ui, doc, label, count, 1, left); enter |= number(ui, distance, spacing); alignment(ui, align_label, align, reverse); }); @@ -394,7 +411,7 @@ pub fn rows(ui: &mut egui::Ui, k: &mut Mod) -> bool { pattern_hint(ui); } Mod::PatternCirc { count, angle, alignment: a, reversed } => { - enter |= number(ui, "Quantity", count); + enter |= quantity(ui, doc, "Quantity", count, 2, MAX_COPIES as i64); enter |= number(ui, "Total angle", angle); alignment(ui, "Alignment", a, reversed); pattern_hint(ui); diff --git a/crates/ui-egui/src/sketch_tools.rs b/crates/ui-egui/src/sketch_tools.rs index 8e88876fc1..117fd95615 100644 --- a/crates/ui-egui/src/sketch_tools.rs +++ b/crates/ui-egui/src/sketch_tools.rs @@ -205,7 +205,7 @@ fn continuity_legend(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { let size = egui::vec2(pad + swatch + 8.0 + text_width + pad, rows.len() as f32 * row + 2.0 * pad); let available = crate::shell::bottom_overlay_rect(app, proj.rect); let frame = egui::Rect::from_min_size(available.left_bottom() + egui::vec2(16.0, -(size.y + 16.0)), size); - painter.rect_filled(frame, 4.0, tk.panel.gamma_multiply(0.9)); + painter.rect_filled(frame, tk.radius_sm, tk.panel.gamma_multiply(0.9)); crate::scenario::publish_rect("continuity_legend", frame); for (i, (label, col)) in rows.iter().enumerate() { let y = frame.top() + pad + row * (i as f32 + 0.5); @@ -252,7 +252,7 @@ fn marks(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { egui::FontId::proportional(crate::theme::Tokens::get().font_body), tk.text, ); - painter.rect_filled(r.expand(3.0), 3.0, tk.panel.gamma_multiply(0.85)); + painter.rect_filled(r.expand(3.0), tk.radius_sm.saturating_sub(1), tk.panel.gamma_multiply(0.85)); painter.text( q + egui::vec2(12.0, -12.0), egui::Align2::LEFT_BOTTOM, @@ -411,7 +411,8 @@ pub fn refine_snap( } None => PREV.with(Cell::get), }; - // Line tool: the tangent point of a circle or arc (before plain "on the curve"). + // Line tool: the tangent point of a circle or arc, then a line through its centre + // (before plain "on the curve", which would leave a chord free to miss the centre). let line_tool = app.tool.as_ref().is_none_or(|t| t.cmd == "sketch.line"); if !mid_only && best.is_none() @@ -421,6 +422,15 @@ pub fn refine_snap( { best = Some((i.dist, i.label(), i.at)); } + if !mid_only + && best.is_none() + && line_tool + && let Some(prev) = prev + && let Some(i) = crate::inference::through_center(sk, prev, lp, tol) + { + best = Some((i.dist, i.label(), i.at)); + mid_of = i.arg; + } // On a curve (coincident with it). if !mid_only && best.is_none() { for (i, c) in sk.curves.iter().enumerate() { @@ -462,6 +472,8 @@ pub fn refine_snap( let arg = match l { "Mid" => mid_of.map(|m| format!("mid:{m}")), "On" => mid_of.map(|m| format!("on:{m}")), + // Already a point argument (`.center` or `on::x,y`), or none for a free point. + "Ctr" => mid_of, _ => None, }; (p, arg) @@ -996,16 +1008,22 @@ pub(crate) fn place_point(app: &mut SolveApp, tool: &mut Tool, p: (Vec2, Option< app.set_status(error, true); return; } + let p = crate::sketch_dims::placed(app, tool, &p); tool.pts.push(p); if n > 0 && tool.pts.len() >= n { - run_points(app, tool); - tool.pts.clear(); + if run_points(app, tool) { + tool.pts.clear(); + crate::sketch_dims::reset(tool); + } else { + tool.pts.pop(); + } } } -fn run_points(app: &mut SolveApp, t: &Tool) { +fn run_points(app: &mut SolveApp, t: &Tool) -> bool { let p = &t.pts; let width = |a: Vec2, b: Vec2, c: Vec2| -> f64 { 2.0 * (b - a).normalized().map(|d| d.cross(c - a).abs()).unwrap_or(1.0) }; + let slot_width = |fallback: f64| crate::sketch_dims::locked_value(app, t, "Width").unwrap_or_else(|| fallback.max(1e-3)); let params = match (t.cmd, p.as_slice()) { ("sketch.line.midpoint", [m, e]) => json!({"mid": arg(m), "end": xy(e.0)}), ("sketch.arc.tangent", [s, e]) => json!({"start": arg(s), "end": xy(e.0)}), @@ -1015,28 +1033,28 @@ fn run_points(app: &mut SolveApp, t: &Tool) { let rho = crate::sketch_dims::locked_value(app, t, "Rho").unwrap_or(0.5); json!({"start": arg(a), "end": arg(b), "apex": arg(x), "rho": rho}) } - ("sketch.slot.center_point", [c, e, w]) => json!({"center": xy(c.0), "end": xy(e.0), "width": width(c.0, e.0, w.0).max(1e-3)}), + ("sketch.slot.center_point", [c, e, w]) => json!({"center": xy(c.0), "end": xy(e.0), "width": slot_width(width(c.0, e.0, w.0))}), ("sketch.slot.arc_three_point", [a, th, b, w]) => { - json!({"start": xy(a.0), "through": xy(th.0), "end": xy(b.0), "width": (2.0 * w.0.dist(b.0)).max(1e-3)}) + json!({"start": xy(a.0), "through": xy(th.0), "end": xy(b.0), "width": slot_width(2.0 * w.0.dist(b.0))}) } ("sketch.slot.arc_center", [c, a, b, w]) => { let r = c.0.dist(a.0); - json!({"center": xy(c.0), "start": xy(a.0), "end": xy(b.0), "width": (2.0 * (w.0.dist(c.0) - r).abs()).max(1e-3)}) + json!({"center": xy(c.0), "start": xy(a.0), "end": xy(b.0), "width": slot_width(2.0 * (w.0.dist(c.0) - r).abs())}) } ("sketch.spline.fit_point" | "sketch.spline.control_point" | "sketch.spline.control_point_5", pts) if pts.len() >= 2 => { json!({"points": pts.iter().map(arg).collect::>()}) } ("sketch.blend_curve", [a, b]) => match (&a.1, &b.1) { (Some(x), Some(y)) => json!({"a": x, "b": y}), - _ => return, + _ => return false, }, ("sketch.text", [at]) => { crate::sketch_text::place(app, at.0, round_nice(app.cam.half_height() * 0.1)); - return; + return true; } - _ => return, + _ => return false, }; - let _ = app.run(t.cmd, params); + crate::tools::draw(app, t, params).is_ok() } fn run_entities(app: &mut SolveApp, t: &Tool) { @@ -1170,8 +1188,14 @@ pub fn preview(app: &SolveApp, t: &Tool, painter: &egui::Painter, proj: &Proj) { crate::tools::paint_sketch_path(&[ss.plane.to_world(last.0), hw], mode, painter, proj, stroke); } } - if let Some((h, _)) = t.hover.as_ref() - && let Some(curve) = crate::tools::preview_curve(t, *h, None, crate::sketch_dims::locked_value(app, t, "Rho"), None) + if let Some(hover) = t.hover.as_ref() + && let Some(curve) = crate::tools::preview_curve( + t, + crate::sketch_dims::shown(app, t, hover), + None, + crate::sketch_dims::locked_value(app, t, "Rho"), + crate::sketch_dims::locked_value(app, t, "Width"), + ) { crate::tools::paint_preview_curve(curve, &ss.plane, painter, proj, stroke, crate::sketch_palette::mode(app)); } @@ -1305,8 +1329,13 @@ fn glyphs(app: &SolveApp, painter: &egui::Painter, proj: &Proj, hover: Option, + /// The To Object target the distance was last set from. + pub target: Option, + /// Picks off the profile plane become the To Object target (Preferences › Design). + pub auto_to_object: bool, } /// One-side direction for a new pick: 0 (along the normal) or 1 (reversed). `ray` is the view diff --git a/crates/ui-egui/src/space_mouse.rs b/crates/ui-egui/src/space_mouse.rs index 46fec0c167..b5d2f13935 100644 --- a/crates/ui-egui/src/space_mouse.rs +++ b/crates/ui-egui/src/space_mouse.rs @@ -51,6 +51,44 @@ pub struct Input { pub raw: Axes, /// The guided calibration while its window is open, and the device it is for. pub calibrating: Option<(String, solidcraft_space_mouse::Wizard)>, + /// Drops readings that have stopped changing close to the dead zone. + pub settle: solidcraft_space_mouse::Settle, + /// The buttons down as of the last frame, one bit each (bit N for button N + 1). + pub buttons: u32, + /// Every button pressed since the app started, so Preferences can list the ones the device has. + pub seen: u32, + /// The modifiers the buttons hold down, as of the last frame. + pub held: egui::Modifiers, + /// The modifiers the keyboard holds, as the window last reported them (without `held`). + pub keyboard: egui::Modifiers, + /// The device `seen` is about, and the device and calibration `settle` is about. + pub read_device: Option, + /// The device `buttons` are of, as the reader said with them. + pub buttons_device: Option, + pub settled_for: Option, + /// The device the calibration notice is up for and when it was shown or last pointed at + /// (egui time), and the devices it went away from for this run (). + pub offer_since: Option<(String, f64)>, + pub offer_hidden: std::collections::BTreeSet, + /// When the offer was last looked at (egui time): a long gap is time away, not time shown. + pub offer_seen: f64, + /// The device last written to the log (one line per connect), `None` for none. + pub logged_device: Option, + /// "Restore defaults" was clicked and waits for its answer. + pub restore_pending: bool, + /// The last look at the buttons had no state yet (`Buttons::pending`). + pub buttons_pending: bool, + /// The reader's connection the last look at the buttons came from (`Buttons::generation`). + pub buttons_generation: u32, + /// Nothing arrived since the last frame: the reading is the last value kept (spacenavd sends only on + /// change, a HID device goes quiet), not a report or record just received, whatever its value. The + /// calibration window needs the difference. + pub retained: bool, + /// The motion `navigate_frame` handed the view this frame (calibrated, reassigned, inverted and + /// scaled), for the test model on the Preferences page to move with; none when the view got none. + pub motion: Option, + /// The test model's view on that page, and when it was last drawn (`test_boat`). + pub boat: Option, /// The driver mapping navigation used last frame, which says what each axis does to the view. pub mapping: Option, /// Which of the Linux readers exist, as the app's reader last saw them; none where there is @@ -58,11 +96,226 @@ pub struct Input { pub drivers: Option, } -/// What the Linux reader found: the spacenavd socket and a direct (hidraw) 3D mouse. +/// What the Linux reader found: the spacenavd socket and a direct (hidraw) 3D mouse, and whether that +/// device was found but could not be opened (no permission), the last time it tried. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct DriversPresent { pub spacenavd: bool, pub direct: bool, + pub denied: bool, +} + +/// What the reader saw of the buttons, taken in one look: the device they came from, those down +/// now (bit N for button N + 1) and those that went down since the last look, even when they +/// came up again before it. The taps are a set of buttons, not a queue of presses: two taps of one +/// button between two looks reach the app as one, and run its job once. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Buttons { + pub device: Option, + pub down: u32, + pub taps: u32, + /// The reader has not yet seen the state of this device's buttons (a HID device's first report that + /// carries them is still to come), so `down` is not known yet. The first look after that is a + /// baseline, not a press. + pub pending: bool, + /// Which connection of the reader these buttons come from; a different one than the last look means + /// the reader connected again in between, so what is down now is held, not pressed. + pub generation: u32, +} + +/// A key a 3D mouse button can stand in for. +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ButtonKey { + Esc, + Shift, + Ctrl, + Alt, +} + +/// What a 3D mouse button does. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ButtonJob { + /// Opens Preferences at Navigation, where the 3D mouse's settings are. + Settings, + /// A view as `SolveApp::animate_view` takes it: `fit`, `home` or a standard view (`top`, …). + View(String), + /// Stands in for a key: Esc when pressed, Shift, Ctrl and Alt for as long as it's held. + Key(ButtonKey), + /// Runs a command, as its keyboard shortcut would. + Command(String), +} + +/// What each of `device`'s buttons does, by number from 1: what the user set, else for a device +/// we have measured, its printed keys' jobs. +pub fn button_jobs(app: &SolveApp, device: &str) -> std::collections::BTreeMap { + app.preferences.space_mouse_buttons.get(device).cloned().unwrap_or_else(|| default_button_jobs(device)) +} + +/// The printed keys' jobs on a device we have measured (`solidcraft_space_mouse::buttons`), from +/// its key (`hid 046d:c62b`, `web 046d:c62b`). Only a key with vendor and product ids gets them, +/// which in practice is a raw HID device: the `spacenavd` and `3dxware` keys carry no ids, and +/// spacenavd numbers its buttons from 0, not 1 as the table here does. Keys with no job here (Roll, +/// the rotation lock) start empty. +pub fn default_button_jobs(device: &str) -> std::collections::BTreeMap { + use solidcraft_space_mouse::buttons::{Action, defaults}; + let ids = device.split_once(' ').and_then(|(_, ids)| ids.split_once(':')); + let table = ids.and_then(|(vendor, product)| Some((u16::from_str_radix(vendor, 16).ok()?, u16::from_str_radix(product, 16).ok()?))); + let Some(table) = table.and_then(|(vendor, product)| defaults(vendor, product)) else { return Default::default() }; + table + .iter() + .enumerate() + .filter_map(|(index, action)| { + let job = match (*action)? { + Action::Menu => ButtonJob::Settings, + Action::Fit => ButtonJob::View("fit".into()), + Action::ViewTop => ButtonJob::View("top".into()), + Action::ViewRight => ButtonJob::View("right".into()), + Action::ViewFront => ButtonJob::View("front".into()), + Action::Esc => ButtonJob::Key(ButtonKey::Esc), + Action::Shift => ButtonJob::Key(ButtonKey::Shift), + Action::Ctrl => ButtonJob::Key(ButtonKey::Ctrl), + Action::Alt => ButtonJob::Key(ButtonKey::Alt), + Action::Roll | Action::RotationLock => return None, + }; + Some((u8::try_from(index + 1).ok()?, job)) + }) + .collect() +} + +/// Whether a button may do anything now: only where navigation and the keyboard shortcuts would +/// work (`navigate_frame`, `toolbar::shortcuts`), so a press in the background, with a picker open +/// or while a field takes the keyboard doesn't move the view or run a command. +fn buttons_active(app: &SolveApp, ctx: &egui::Context, focused: bool) -> bool { + app.preferences.space_mouse_enabled + && focused + && !app.prefs_window.open + && app.space_mouse.calibrating.is_none() + && !app.home.open + && !app.file_picker_busy() + && !app.file_load.busy() + // The keyboard shortcuts wait for a report draft too (`SolveApp::logic`). + && app.issue_report.draft.is_none() + && !crate::keymap::capturing(app) + && !egui::Popup::is_any_open(ctx) + && !ctx.egui_wants_keyboard_input() + && !crate::dim_view::editing() + && !crate::dialogs_save_mesh::popup_open(ctx) + && crate::toolbar::design_shown(app) +} + +/// The 3D mouse's buttons this frame (`buttons`, bit N for button N + 1), before the app sees the +/// frame's input: each button just pressed does its job, and those standing in for Shift, Ctrl or +/// Alt hold them while down. Nothing runs where navigation wouldn't (`buttons_active`), so pressing +/// a button in Preferences finds it in the list instead, and a press made then isn't run later. +pub fn buttons_hook(app: &mut SolveApp, ctx: &egui::Context, seen: Buttons, raw: &mut egui::RawInput) { + // The device the buttons came from, not the one the last frame ended with: a switch of device + // (or a first one) starts the button history afresh at what is down now, so a button already down + // when the device changes (held through a switch of reader) is not a press on the new device. A + // press made on it is in `taps`, so it still runs the new device's own mapping. + // The same for the first look at the buttons' state after a look that had none to give (the reader + // had connected but the device had not yet reported them): what is down in it is held, not pressed. + if app.space_mouse.buttons_device != seen.device + || app.space_mouse.buttons_generation != seen.generation + || (app.space_mouse.buttons_pending && !seen.pending) + { + app.space_mouse.buttons_device = seen.device.clone(); + app.space_mouse.buttons = seen.down; + } + app.space_mouse.buttons_pending = seen.pending; + app.space_mouse.buttons_generation = seen.generation; + let buttons = seen.down; + let pressed = (buttons & !app.space_mouse.buttons) | seen.taps; + app.space_mouse.buttons = buttons; + app.space_mouse.seen |= buttons; + // The keyboard's own modifiers, from what the window was told before anything is added to it. + // egui drops its modifiers when the window loses focus, so those go too. + let mut focused = ctx.input(|i| i.focused); + for event in &raw.events { + match event { + egui::Event::ModifiersChanged(modifiers) => app.space_mouse.keyboard = *modifiers, + egui::Event::WindowFocused(now) => { + focused = *now; + if !now { + app.space_mouse.keyboard = egui::Modifiers::NONE; + } + } + _ => {} + } + } + let keyboard = app.space_mouse.keyboard; + let mut held = egui::Modifiers::NONE; + let device = seen.device.clone().filter(|_| buttons_active(app, ctx, focused)); + let jobs = device.map(|device| button_jobs(app, &device)).unwrap_or_default(); + let before = std::mem::replace(&mut app.space_mouse.held, egui::Modifiers::NONE); + for (number, job) in jobs { + let Some(bit) = number.checked_sub(1).and_then(|index| 1_u32.checked_shl(u32::from(index))) else { continue }; + if (buttons | seen.taps) & bit == 0 { + continue; + } + match job { + ButtonJob::Key(ButtonKey::Shift) if buttons & bit != 0 => held.shift = true, + ButtonJob::Key(ButtonKey::Alt) if buttons & bit != 0 => held.alt = true, + ButtonJob::Key(ButtonKey::Ctrl) if buttons & bit != 0 => { + held.ctrl = true; + // Ctrl is the command key everywhere but on a Mac. + held.command |= !cfg!(target_os = "macos"); + } + _ => {} + } + if pressed & bit != 0 { + run_button_job(app, &job, keyboard, raw); + } + } + app.space_mouse.held = held; + let add = |modifiers: &mut egui::Modifiers| { + modifiers.shift |= held.shift; + modifiers.alt |= held.alt; + modifiers.ctrl |= held.ctrl; + modifiers.command |= held.command; + }; + // egui keeps modifiers from frame to frame and takes new ones from `ModifiersChanged`: the + // keyboard's get the held ones added, and a change in what's held is one of its own. + let mut changed = false; + for event in &mut raw.events { + match event { + egui::Event::ModifiersChanged(modifiers) => { + add(modifiers); + changed = true; + } + egui::Event::PointerButton { modifiers, .. } | egui::Event::Key { modifiers, .. } | egui::Event::MouseWheel { modifiers, .. } => { + add(modifiers); + } + _ => {} + } + } + if !changed && held != before { + let mut modifiers = keyboard; + add(&mut modifiers); + raw.events.insert(0, egui::Event::ModifiersChanged(modifiers)); + } +} + +// `modifiers` are the keyboard's own; the Esc event pushed here still gets the modifiers other buttons hold +// down, because `buttons_hook` adds them to every key event of the frame after all the jobs have run. +fn run_button_job(app: &mut SolveApp, job: &ButtonJob, modifiers: egui::Modifiers, raw: &mut egui::RawInput) { + match job { + // Navigation, where the 3D mouse's section is. + ButtonJob::Settings => crate::prefs::open_at(app, crate::prefs::PAGE_NAVIGATION), + ButtonJob::View(view) => app.animate_view(view), + ButtonJob::Key(ButtonKey::Esc) => { + for pressed in [true, false] { + raw.events.push(egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed, repeat: false, modifiers }); + } + } + ButtonJob::Key(_) => {} + ButtonJob::Command(id) => { + if solidcraft_engine::find_command(id).is_some_and(|c| c.info(&app.session).enabled) { + app.start(id); + } + } + } } /// Seconds after the cap last moved that wheel events are still taken for 3DxWare's: one can be @@ -189,22 +442,22 @@ pub fn calibrated(axes: Axes, calibration: Option) -> Axes { /// by. They name no kind of device, since calibration defined them: Translation X is whatever the /// user did for "slide right". pub const MOTIONS: [&str; 6] = [ - "ui.space_mouse_motion_translation_x", - "ui.space_mouse_motion_translation_y", - "ui.space_mouse_motion_translation_z", - "ui.space_mouse_motion_rotation_x", - "ui.space_mouse_motion_rotation_y", - "ui.space_mouse_motion_rotation_z", + "ui.3d_mouse_motion_translation_x", + "ui.3d_mouse_motion_translation_y", + "ui.3d_mouse_motion_translation_z", + "ui.3d_mouse_motion_rotation_x", + "ui.3d_mouse_motion_rotation_y", + "ui.3d_mouse_motion_rotation_z", ]; /// What each logical axis does to the view, as `navigation::device_camera` applies it. const VIEW_AXES: [&str; 6] = [ - "ui.space_mouse_view_pan_sideways", - "ui.space_mouse_view_zoom", - "ui.space_mouse_view_pan_up_down", - "ui.space_mouse_view_orbit_up_down", - "ui.space_mouse_view_orbit_sideways", - "ui.space_mouse_view_roll", + "ui.3d_mouse_view_pan_sideways", + "ui.3d_mouse_view_zoom", + "ui.3d_mouse_view_pan_up_down", + "ui.3d_mouse_view_orbit_up_down", + "ui.3d_mouse_view_orbit_sideways", + "ui.3d_mouse_view_roll", ]; /// The label of axis row `row`: what it does to the view through `mapping`, the mapping @@ -214,12 +467,32 @@ const VIEW_AXES: [&str; 6] = [ /// Two rows a driver profile sends to the same output both get its label; that's what they do. pub fn row_label(mapping: Option, row: usize) -> &'static str { match mapping.and_then(|mapping| mapping.axes.get(row).copied()) { - Some(rule) if rule.enabled => VIEW_AXES.get(rule.output).copied().unwrap_or("ui.space_mouse_view_off"), - Some(_) => "ui.space_mouse_view_off", - None => "ui.space_mouse_view_unmapped", + Some(rule) if rule.enabled => VIEW_AXES.get(rule.output).copied().unwrap_or("ui.3d_mouse_view_off"), + Some(_) => "ui.3d_mouse_view_off", + None => "ui.3d_mouse_view_unmapped", } } +/// What the view does with calibrated motion `motion` under the axis assignment `source`: the label +/// of the row that reads it, the same label the rows show (and so the same after an invert or a +/// reassignment). `None` for a motion no row reads (an assignment that is not one motion per row). +pub fn motion_story(mapping: Option, source: [usize; 6], motion: usize) -> Option<&'static str> { + source.iter().position(|&s| s == motion).map(|row| row_label(mapping, row)) +} + +/// What the circles' tooltips say each calibrated motion does, in `language`: `motion_story`, worded +/// as the rows are (a motion nothing reads says nothing). It names the view axis the motion drives, +/// not a direction, so an inverted axis reads the same; the page says the circles are before invert. +pub(crate) fn stories(language: &str, mapping: Option, source: [usize; 6]) -> [String; 6] { + std::array::from_fn(|motion| { + let row = source.iter().position(|&s| s == motion); + motion_story(mapping, source, motion) + .zip(row) + .map(|(label, row)| crate::i18n::Locale::for_language(language).format(label, &[("n", &(row + 1).to_string())])) + .unwrap_or_default() + }) +} + /// Which of the cap's motions drives each of its axes (`Prefs::space_mouse_axis_source`): row /// `r` reads motion `source[r]`. Each motion drives exactly one row. pub const SAME_AXES: [usize; 6] = [0, 1, 2, 3, 4, 5]; @@ -248,6 +521,18 @@ pub fn drives_each_once(source: [usize; 6]) -> bool { source.iter().all(|&from| seen.get_mut(from).is_some_and(|seen| !std::mem::replace(seen, true))) } +/// How long the calibration window waits before the next frame: 30 a second while the reading +/// changes, 10 while it holds still (the wizard goes by elapsed time, so a still reading needs only +/// enough frames for its timers), and 4 while the window is not in use. Every frame redraws the 3D +/// view behind the window as well, so this is most of what the window costs. +pub(crate) fn calibration_repaint_after(reading_changed: bool, focused: bool) -> Duration { + Duration::from_millis(match (focused, reading_changed) { + (false, _) => 250, + (true, true) => 33, + (true, false) => 100, + }) +} + /// Hands one frame of 3D mouse input, `seconds` after the previous frame, to the page showing /// the camera. Nothing happens with navigation switched off, on the start page or while a file /// picker is open, or while the reading is inside the dead zone (after calibration, which takes @@ -256,20 +541,42 @@ pub fn drives_each_once(source: [usize; 6]) -> bool { /// reading goes through the device's saved calibration first, when it has one; while the /// calibration window is open, the reading goes to it instead and the view stays put. pub fn navigate_frame(app: &mut SolveApp, ctx: &egui::Context, mapping: DriverMapping, axes: Axes, seconds: f64) -> bool { + let changed = app.space_mouse.raw != axes; app.space_mouse.raw = axes; + app.space_mouse.motion = None; app.space_mouse.mapping = Some(mapping); + if app.space_mouse.read_device != app.space_mouse.device { + // Another device: its buttons and its resting hand are not the last one's. + app.space_mouse.read_device = app.space_mouse.device.clone(); + app.space_mouse.seen = 0; + app.space_mouse.settle = Default::default(); + } + let calibration = calibration(app); + if app.space_mouse.settled_for != calibration { + app.space_mouse.settled_for = calibration; + app.space_mouse.settle = Default::default(); + } if let Some((device, wizard)) = &mut app.space_mouse.calibrating { if app.space_mouse.device.as_ref() == Some(device) { - wizard.feed(axes.into(), seconds); - ctx.request_repaint_after(Duration::from_millis(16)); + wizard.feed_reading(axes.into(), seconds, !app.space_mouse.retained); + ctx.request_repaint_after(calibration_repaint_after(changed, ctx.input(|i| i.focused))); return false; } // Another device is being read now, or none: what was recorded isn't its. app.space_mouse.calibrating = None; } - // Calibrated first, so the calibration puts the device's axes in order, then the user's choice - // of which motion drives which axis. Invert and speed then go with the axis, not the motion. - let axes = reassigned(calibrated(axes, calibration(app)), app.preferences.space_mouse_axis_source); + let axes = calibrated(axes, calibration); + // A hand leaning on a cap, holding it just past the dead zone, doesn't creep the view. A + // trackball's slow roll is steady and meant, and a device without a calibration is read as it reports. + let axes: Axes = match calibration { + Some(calibration) if calibration.kind == solidcraft_space_mouse::Kind::ReturnToCenter => { + app.space_mouse.settle.filter(axes.into(), seconds).into() + } + _ => axes, + }; + // Then the user's choice of which motion drives which axis (invert and speed then go with the + // axis, not the motion). After the settle: that works on the device's own calibrated axes. + let axes = reassigned(axes, app.preferences.space_mouse_axis_source); // Only the window with focus: with two windows open, the cap moves the one being used, not both. if !app.preferences.space_mouse_enabled || app.home.open || app.file_picker_busy() || !axes.moving() || !ctx.input(|i| i.focused) { return false; @@ -281,10 +588,12 @@ pub fn navigate_frame(app: &mut SolveApp, ctx: &egui::Context, mapping: DriverMa app.begin_external_navigation(); // A long gap (the first frame after a pause) moves as far as a 50 ms one. let step = if seconds.is_finite() { seconds.min(0.05) } else { 0.0 }; - app.viewport.device_motion = Some(DeviceMotion { + let handed = DeviceMotion { motion: mapping.with_user_axes(app.preferences.space_mouse_reverse, app.preferences.space_mouse_axis_speed).apply(axes), seconds: step * speed.clamp(0.1, 5.0), - }); + }; + app.space_mouse.motion = Some(handed); + app.viewport.device_motion = Some(handed); ctx.request_repaint_after(Duration::from_millis(16)); true } @@ -326,6 +635,32 @@ mod tests { handed } + /// The calibration window is told whether a reading is a report the device still sends or a value a + /// daemon keeps: a ball that slows to a lower speed and keeps sending it is rolling (fresh), while the + /// same readings as a kept value end the step. + #[test] + fn the_calibration_window_is_told_whether_the_reading_is_fresh() { + use solidcraft_space_mouse::{Kind, Stage, Wizard}; + for (retained, step_done) in [(false, false), (true, true)] { + let mut app = app(); + app.space_mouse.device = Some("hid 046d:c62b".into()); + app.space_mouse.retained = retained; + app.space_mouse.calibrating = Some(("hid 046d:c62b".into(), Wizard::new(Kind::Trackball))); + let ctx = egui::Context::default(); + ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear(); + let mut feed = |axes: Axes, frames: usize| { + for _ in 0..frames { + frame(&mut app, &ctx, DriverMapping::default(), axes, 0.02); + } + }; + feed(Axes::default(), 400); + feed(Axes { translation: [40, 3, 0], rotation: [0; 3] }, 30); + feed(Axes { translation: [10, 0, 0], rotation: [0; 3] }, 35); + let stage = app.space_mouse.calibrating.as_ref().map(|(_, wizard)| wizard.stage()); + assert_eq!(stage == Some(Stage::Step(1)), step_done, "retained {retained}: {stage:?}"); + } + } + fn raw(axes: Axes) -> Motion { DriverMapping::default().apply(axes) } @@ -336,6 +671,325 @@ mod tests { app } + #[test] + fn the_calibration_window_repaints_fast_only_while_the_reading_moves() { + use std::time::Duration; + assert_eq!(calibration_repaint_after(true, true), Duration::from_millis(33)); + assert_eq!(calibration_repaint_after(false, true), Duration::from_millis(100), "a still reading: 10 frames a second"); + assert_eq!(calibration_repaint_after(true, false), Duration::from_millis(250)); + assert_eq!(calibration_repaint_after(false, false), Duration::from_millis(250)); + } + + #[test] + fn a_measured_device_starts_with_its_printed_keys() { + let jobs = default_button_jobs("hid 046d:c62b"); + assert_eq!(jobs.get(&1), Some(&ButtonJob::Settings)); + assert_eq!(jobs.get(&3), Some(&ButtonJob::View("top".into()))); + assert_eq!(jobs.get(&25), Some(&ButtonJob::Key(ButtonKey::Shift))); + // Roll and the rotation lock have nothing to do here yet; the numbered keys start empty. + assert_eq!((jobs.get(&9), jobs.get(&27), jobs.get(&13)), (None, None, None)); + // Unmeasured, spacenavd (its own numbers) and nonsense keys start empty. + for device in ["hid 256f:c63a", "spacenavd", "web 046d", "hid zz:c62b", ""] { + assert!(default_button_jobs(device).is_empty(), "{device}"); + } + } + + /// One frame of the window: the hook sees the frame's input, then egui runs it, as the app does. + fn press(app: &mut SolveApp, ctx: &egui::Context, buttons: u32, events: Vec) -> egui::RawInput { + let mut raw = egui::RawInput { focused: true, events, ..Default::default() }; + let device = app.space_mouse.device.clone(); + // As the reader reports them: a button newly down since its own last look is a tap, and a reader + // that has just connected to this device starts from nothing down. + let before = if app.space_mouse.buttons_device == device { app.space_mouse.buttons } else { 0 }; + buttons_hook(app, ctx, Buttons { device, down: buttons, taps: buttons & !before, pending: false, generation: 0 }, &mut raw); + ctx.run_ui(raw.clone(), |_| {}).textures_delta.clear(); + raw + } + + fn escapes(raw: &egui::RawInput) -> usize { + raw.events.iter().filter(|e| matches!(e, egui::Event::Key { key: egui::Key::Escape, pressed: true, .. })).count() + } + + /// One frame with the reader's own snapshot (`taps` included). + fn press_seen(app: &mut SolveApp, ctx: &egui::Context, seen: Buttons) -> egui::RawInput { + let mut raw = egui::RawInput { focused: true, ..Default::default() }; + buttons_hook(app, ctx, seen, &mut raw); + ctx.run_ui(raw.clone(), |_| {}).textures_delta.clear(); + raw + } + + const ESC: u32 = 1 << 22; + const SHIFT: u32 = 1 << 24; + + #[test] + fn a_button_does_its_job_once_per_press_and_holds_its_modifier() { + let mut app = app(); + app.space_mouse.device = Some("hid 046d:c62b".into()); + let ctx = window(true); + let shifted = || ctx.input(|i| i.modifiers.shift); + // Esc (button 23) presses Escape once, not again while it's held. + assert_eq!(escapes(&press(&mut app, &ctx, ESC, vec![])), 1); + assert_eq!(escapes(&press(&mut app, &ctx, ESC, vec![])), 0); + // Shift (button 25) holds Shift for as long as it's down. + press(&mut app, &ctx, SHIFT, vec![]); + assert!(shifted()); + press(&mut app, &ctx, SHIFT, vec![]); + assert!(shifted()); + press(&mut app, &ctx, 0, vec![]); + assert!(!shifted()); + // Menu (button 1) opens Preferences; there, buttons only light up their rows. + press(&mut app, &ctx, 1, vec![]); + assert!(app.prefs_window.open); + press(&mut app, &ctx, 0, vec![]); + assert_eq!(escapes(&press(&mut app, &ctx, ESC, vec![])), 0); + assert_eq!(app.space_mouse.seen & ESC, ESC); + // A job the user set replaces the defaults. + app.prefs_window.open = false; + press(&mut app, &ctx, 0, vec![]); + app.preferences.space_mouse_buttons.insert("hid 046d:c62b".into(), [(23, ButtonJob::Key(ButtonKey::Alt))].into()); + let raw = press(&mut app, &ctx, ESC, vec![]); + assert_eq!((escapes(&raw), ctx.input(|i| i.modifiers.alt)), (0, true)); + } + + /// The keyboard and a button hold the same modifier: it stays down until both let go, whichever + /// lets go first, for each of Shift, Ctrl and Alt. + #[test] + fn mapped_buttons_do_nothing_behind_an_issue_report_draft() { + let device = "hid 046d:c62b"; + let mut app = app(); + app.space_mouse.device = Some(device.into()); + app.preferences.space_mouse_buttons.insert( + device.into(), + [ + (1, ButtonJob::Settings), + (2, ButtonJob::Key(ButtonKey::Esc)), + (3, ButtonJob::Key(ButtonKey::Shift)), + (4, ButtonJob::View("top".into())), + (5, ButtonJob::Command("edit.undo".into())), + ] + .into(), + ); + let ctx = window(true); + // A document change that Undo would take back. + app.run("document.metadata.set", serde_json::json!({"values": {"author": "Jane"}})).unwrap(); + app.issue_report.draft = Some(crate::issue_report::draft_for(&app, None, None)); + let (cam, undo, dialog) = (app.cam, app.session.undo.len(), app.dialog.is_some()); + assert!(undo > 0 && app.session.doc.metadata.author() == Some("Jane")); + let raw = press(&mut app, &ctx, 0b11111, vec![]); + assert_eq!(app.session.doc.metadata.author(), Some("Jane"), "the mapped Undo did not run"); + assert_eq!(escapes(&raw), 0, "no Esc"); + assert!(!app.prefs_window.open, "no Preferences"); + assert!(!ctx.input(|i| i.modifiers.shift), "no held Shift"); + assert_eq!((app.cam, app.session.undo.len(), app.dialog.is_some()), (cam, undo, dialog)); + // Once the draft is closed the same buttons work again (a fresh press). + app.issue_report.draft = None; + press(&mut app, &ctx, 0, vec![]); + press(&mut app, &ctx, 0b11111, vec![]); + assert!(app.prefs_window.open); + assert_eq!(app.session.doc.metadata.author(), None, "and the mapped Undo runs"); + } + + #[test] + fn an_esc_button_pressed_while_a_shift_button_is_held_goes_out_shifted() { + let device = "hid 046d:c62b"; + let mut app = app(); + app.space_mouse.device = Some(device.into()); + app.preferences + .space_mouse_buttons + .insert(device.into(), [(1, ButtonJob::Key(ButtonKey::Shift)), (2, ButtonJob::Key(ButtonKey::Esc))].into()); + let ctx = window(true); + press(&mut app, &ctx, 0b01, vec![]); + let raw = press(&mut app, &ctx, 0b11, vec![]); + let shifted = + raw.events.iter().any(|e| matches!(e, egui::Event::Key { key: egui::Key::Escape, pressed: true, modifiers, .. } if modifiers.shift)); + assert!(shifted, "{:?}", raw.events); + } + + #[test] + fn a_tap_between_two_frames_runs_its_job_once() { + let mut app = app(); + app.space_mouse.device = Some("hid 046d:c62b".into()); + let ctx = window(true); + // Down and up before the hook looked: nothing is held, the press is in `taps`. + let tap = Buttons { device: Some("hid 046d:c62b".into()), down: 0, taps: ESC, pending: false, generation: 0 }; + assert_eq!(escapes(&press_seen(&mut app, &ctx, tap)), 1); + assert_eq!(app.space_mouse.buttons, 0); + let nothing = Buttons { device: Some("hid 046d:c62b".into()), down: 0, taps: 0, pending: false, generation: 0 }; + assert_eq!(escapes(&press_seen(&mut app, &ctx, nothing)), 0, "and not again"); + } + + #[test] + fn a_press_on_a_new_device_runs_that_devices_mapping_whatever_the_frame_before_had() { + let (a, b) = ("hid 046d:c62b", "hid 256f:c63a"); + let device = |name: Option<&str>, down: u32| Buttons { device: name.map(Into::into), down, taps: 0, pending: false, generation: 0 }; + let setup = || { + let mut app = app(); + app.preferences.space_mouse_buttons.insert(a.into(), [(1, ButtonJob::Key(ButtonKey::Shift))].into()); + app.preferences.space_mouse_buttons.insert(b.into(), [(1, ButtonJob::Key(ButtonKey::Esc))].into()); + (app, window(true)) + }; + // A -> B with button 1 still down from before: A's Shift lets go, and the held button is not a + // press on B. A fresh press on B (it arrives as a tap) runs B's Esc once. + let (mut app, ctx) = setup(); + app.space_mouse.device = Some(a.into()); + press_seen(&mut app, &ctx, device(Some(a), 1)); + assert!(app.space_mouse.held.shift); + assert_eq!(escapes(&press_seen(&mut app, &ctx, device(Some(b), 1))), 0, "held through the switch is not a press"); + assert!(!app.space_mouse.held.shift, "A's hold does not carry over"); + assert_eq!(escapes(&press_seen(&mut app, &ctx, device(Some(b), 1))), 0, "and holding is not pressing"); + press_seen(&mut app, &ctx, device(Some(b), 0)); + let tap = Buttons { device: Some(b.into()), down: 1, taps: 1, pending: false, generation: 0 }; + assert_eq!(escapes(&press_seen(&mut app, &ctx, tap.clone())), 1, "a press on B runs B's job"); + // None -> B: the first press is not swallowed by there having been no device (it is a tap). + let (mut app, ctx) = setup(); + app.space_mouse.device = None; + press_seen(&mut app, &ctx, device(None, 0)); + assert_eq!(escapes(&press_seen(&mut app, &ctx, tap)), 1); + // The device appears (reader connected, no state yet), a frame is drawn, and only then the first + // report shows button 1 still down: held, not pressed. + let (mut app, ctx) = setup(); + app.space_mouse.device = None; + press_seen(&mut app, &ctx, device(None, 0)); + let waiting = Buttons { device: Some(b.into()), down: 0, taps: 0, pending: true, generation: 0 }; + assert_eq!(escapes(&press_seen(&mut app, &ctx, waiting)), 0); + assert_eq!(escapes(&press_seen(&mut app, &ctx, device(Some(b), 1))), 0, "the first state is a baseline"); + press_seen(&mut app, &ctx, device(Some(b), 0)); + assert_eq!(escapes(&press_seen(&mut app, &ctx, Buttons { device: Some(b.into()), down: 1, taps: 1, pending: false, generation: 0 })), 1); + // And a button already down when the first device appears is not a press. + let (mut app, ctx) = setup(); + press_seen(&mut app, &ctx, device(None, 0)); + assert_eq!(escapes(&press_seen(&mut app, &ctx, device(Some(b), 1))), 0); + } + + #[test] + fn a_modifier_the_keyboard_and_a_button_share_stays_down_until_both_let_go() { + for key in [ButtonKey::Shift, ButtonKey::Ctrl, ButtonKey::Alt] { + let down = |ctx: &egui::Context| { + let m = ctx.input(|i| i.modifiers); + match key { + ButtonKey::Shift => m.shift, + ButtonKey::Ctrl => m.ctrl, + _ => m.alt, + } + }; + let keyboard = |on: bool| { + let mut m = egui::Modifiers::NONE; + match key { + ButtonKey::Shift => m.shift = on, + ButtonKey::Ctrl => m.ctrl = on, + _ => m.alt = on, + } + vec![egui::Event::ModifiersChanged(m)] + }; + for button_first in [true, false] { + let mut app = app(); + app.space_mouse.device = Some("hid 046d:c62b".into()); + app.preferences.space_mouse_buttons.insert("hid 046d:c62b".into(), [(1, ButtonJob::Key(key))].into()); + let ctx = window(true); + press(&mut app, &ctx, 0, keyboard(true)); + press(&mut app, &ctx, 1, vec![]); + assert!(down(&ctx), "{key:?} both"); + if button_first { + press(&mut app, &ctx, 0, vec![]); + assert!(down(&ctx), "{key:?} the keyboard still holds it"); + press(&mut app, &ctx, 0, keyboard(false)); + } else { + press(&mut app, &ctx, 1, keyboard(false)); + assert!(down(&ctx), "{key:?} the button still holds it"); + press(&mut app, &ctx, 0, vec![]); + } + assert!(!down(&ctx), "{key:?} neither"); + } + } + } + + #[test] + fn buttons_do_nothing_where_navigation_would_not() { + let mut app = app(); + app.space_mouse.device = Some("hid 046d:c62b".into()); + app.preferences + .space_mouse_buttons + .insert("hid 046d:c62b".into(), [(1, ButtonJob::Key(ButtonKey::Esc)), (2, ButtonJob::Key(ButtonKey::Shift))].into()); + let (focused, unfocused) = (window(true), window(false)); + // A press while the window is in the background does nothing, and isn't run on refocus. + assert_eq!(escapes(&press(&mut app, &unfocused, 1, vec![])), 0); + assert_eq!(escapes(&press(&mut app, &focused, 1, vec![])), 0, "still held: not a new press"); + press(&mut app, &focused, 0, vec![]); + assert_eq!(escapes(&press(&mut app, &focused, 1, vec![])), 1, "a press that is its own"); + // A held modifier button lets go in the background, and takes hold again on refocus. + press(&mut app, &focused, 2, vec![]); + assert!(focused.input(|i| i.modifiers.shift)); + press(&mut app, &unfocused, 2, vec![]); + assert!(!unfocused.input(|i| i.modifiers.shift)); + // A file picker being open is no place for one either. + press(&mut app, &focused, 0, vec![]); + let slot: crate::picker_test_support::Slot = Default::default(); + crate::picker_test_support::open(&mut app, &slot); + app.pick_open(|_, _| {}); + assert_eq!(escapes(&press(&mut app, &focused, 1, vec![])), 0); + app.file_picker = None; + press(&mut app, &focused, 0, vec![]); + assert_eq!(escapes(&press(&mut app, &focused, 1, vec![])), 1); + } + + #[test] + fn preferences_and_calibration_let_go_of_a_held_modifier_button() { + let mut app = app(); + app.space_mouse.device = Some("hid 046d:c62b".into()); + let ctx = window(true); + press(&mut app, &ctx, SHIFT, vec![]); + assert!(ctx.input(|i| i.modifiers.shift)); + app.prefs_window.open = true; + press(&mut app, &ctx, SHIFT, vec![]); + assert!(!ctx.input(|i| i.modifiers.shift), "Preferences open"); + app.prefs_window.open = false; + press(&mut app, &ctx, SHIFT, vec![]); + assert!(ctx.input(|i| i.modifiers.shift)); + crate::space_mouse_calibration::start(&mut app); + press(&mut app, &ctx, SHIFT, vec![]); + assert!(!ctx.input(|i| i.modifiers.shift), "calibrating"); + } + + #[test] + fn losing_focus_clears_what_the_buttons_hold_and_the_keyboard_is_remembered_again_on_regain() { + let mut app = app(); + app.space_mouse.device = Some("hid 046d:c62b".into()); + let ctx = window(true); + press(&mut app, &ctx, SHIFT, vec![]); + assert!(app.space_mouse.held.shift); + press(&mut app, &ctx, SHIFT, vec![egui::Event::WindowFocused(false)]); + assert_eq!(app.space_mouse.held, egui::Modifiers::NONE); + assert!(!ctx.input(|i| i.modifiers.shift)); + // Back, the button still down: it holds Shift again, and a keyboard Shift lost with the + // focus stays lost until the window says it is down. + press(&mut app, &ctx, SHIFT, vec![egui::Event::WindowFocused(true)]); + assert!(ctx.input(|i| i.modifiers.shift)); + assert_eq!(escapes(&press(&mut app, &ctx, SHIFT, vec![])), 0); + } + + #[test] + fn wheel_events_carry_the_held_modifiers() { + let mut app = app(); + app.space_mouse.device = Some("hid 046d:c62b".into()); + let ctx = window(true); + let raw = press(&mut app, &ctx, SHIFT, vec![wheel(1.0)]); + assert!(raw.events.iter().any(|e| matches!(e, egui::Event::MouseWheel { modifiers, .. } if modifiers.shift))); + } + + #[test] + fn another_device_starts_with_no_buttons_seen_and_no_settling() { + let mut app = app(); + let ctx = window(true); + app.space_mouse.device = Some("hid 046d:c62b".into()); + frame(&mut app, &ctx, DriverMapping::default(), Axes::default(), 0.016); + app.space_mouse.seen = 0b101; + frame(&mut app, &ctx, DriverMapping::default(), Axes::default(), 0.016); + assert_eq!(app.space_mouse.seen, 0b101); + app.space_mouse.device = Some("hid 256f:c63a".into()); + frame(&mut app, &ctx, DriverMapping::default(), Axes::default(), 0.016); + assert_eq!(app.space_mouse.seen, 0); + } + #[test] fn per_axis_choices_flip_and_scale_one_physical_axis() { let mapping = DriverMapping::default().with_user_axes([false, true, false, false, false, false], [1.0, 1.0, 1.0, 0.5, f32::NAN, 1.0]); @@ -487,6 +1141,45 @@ mod tests { assert_eq!(DriverMapping::object_mode().apply(all), DriverMapping::default().apply(all)); } + /// A steady lean of 60 on X for two seconds, 20 ms a frame: whether the cap still moves the view at the end. + fn still_moving_after_a_steady_lean(calibration: Option) -> bool { + let mut app = app(); + app.space_mouse.device = Some("hid 046d:c62b".into()); + if let Some(calibration) = calibration { + app.preferences.space_mouse_calibrations.insert("hid 046d:c62b".into(), calibration); + } + let ctx = window(true); + let lean = Axes { translation: [60, 0, 0], rotation: [0; 3] }; + (0..100).map(|_| frame(&mut app, &ctx, DriverMapping::default(), lean, 0.02)).last().unwrap() + } + + #[test] + fn only_a_calibrated_cap_settles() { + let axes = std::array::from_fn(|source| AxisCalibration { source, reversed: false, rest: 0, dead_zone: 0, full: [350; 2] }); + let calibration = |kind| Calibration { dead_zone_share: 0.0, kind, axes }; + assert!(!still_moving_after_a_steady_lean(Some(calibration(solidcraft_space_mouse::Kind::ReturnToCenter))), "a leaning hand on a cap"); + assert!(still_moving_after_a_steady_lean(Some(calibration(solidcraft_space_mouse::Kind::Trackball))), "a slow steady roll"); + assert!(still_moving_after_a_steady_lean(None), "a device read as it reports"); + } + + #[test] + fn a_new_calibration_starts_settling_afresh() { + let mut app = app(); + app.space_mouse.device = Some("hid 046d:c62b".into()); + let ctx = window(true); + let axes = std::array::from_fn(|source| AxisCalibration { source, reversed: false, rest: 0, dead_zone: 0, full: [350; 2] }); + app.preferences + .space_mouse_calibrations + .insert("hid 046d:c62b".into(), Calibration { dead_zone_share: 0.0, kind: solidcraft_space_mouse::Kind::ReturnToCenter, axes }); + let lean = Axes { translation: [60, 0, 0], rotation: [0; 3] }; + for _ in 0..30 { + frame(&mut app, &ctx, DriverMapping::default(), lean, 0.02); + } + assert!(!frame(&mut app, &ctx, DriverMapping::default(), lean, 0.02), "settled"); + app.preferences.space_mouse_calibrations.get_mut("hid 046d:c62b").unwrap().dead_zone_share = 0.01; + assert!(frame(&mut app, &ctx, DriverMapping::default(), lean, 0.02), "a changed calibration is a fresh start"); + } + #[test] fn the_driver_choice_is_saved_and_an_older_file_reads_automatic() { use crate::prefs::SpaceMouseDriver; @@ -660,6 +1353,7 @@ mod tests { let axis = |source, reversed| solidcraft_space_mouse::AxisCalibration { source, reversed, rest: 0, dead_zone: 0, full: [3500; 2] }; solidcraft_space_mouse::Calibration { dead_zone_share: 0.0, + kind: Default::default(), axes: [axis(0, false), axis(4, true), axis(2, false), axis(3, false), axis(1, false), axis(5, false)], } } @@ -691,6 +1385,10 @@ mod tests { assert!(app.space_mouse.calibrating.is_none(), "nothing to calibrate without a device"); app.space_mouse.device = Some("hid 256f:c63a".into()); crate::space_mouse_calibration::start(&mut app); + // It asks what kind of device it is first. + let Some((_, wizard)) = &mut app.space_mouse.calibrating else { panic!("calibration didn't start") }; + assert_eq!(wizard.stage(), solidcraft_space_mouse::Stage::Choose); + wizard.choose(solidcraft_space_mouse::Kind::ReturnToCenter); let before = app.cam; assert!(!frame(&mut app, &ctx, DriverMapping::default(), PUSH, 0.02)); assert_eq!(app.cam, before); @@ -855,8 +1553,9 @@ mod tests { app }; // Mixed motion: a press and a push back together, on the device's own channels. - let mixed = Axes { translation: [0, 0, 1750], rotation: [0, -3500, 0] }; - let expected = Axes { translation: [0, 175, 350], rotation: [0; 3] }; + // A press at full travel, where the calibration's feather no longer shows, so the comparison is exact. + let mixed = Axes { translation: [0, 0, 3500], rotation: [0; 3] }; + let expected = Axes { translation: [0, 350, 0], rotation: [0; 3] }; let mut reference = app(); frame(&mut reference, &ctx, DriverMapping::default(), expected, 0.02); let mut swapped = setup([0, 2, 1, 3, 4, 5]); @@ -866,7 +1565,7 @@ mod tests { let saved = serde_json::to_value(&swapped.preferences).unwrap(); assert_eq!(crate::prefs::lenient(&saved).space_mouse_axis_source, [0, 2, 1, 3, 4, 5]); let mut unswapped_reference = app(); - frame(&mut unswapped_reference, &ctx, DriverMapping::default(), Axes { translation: [0, 350, 175], rotation: [0; 3] }, 0.02); + frame(&mut unswapped_reference, &ctx, DriverMapping::default(), Axes { translation: [0, 0, 350], rotation: [0; 3] }, 0.02); let mut reset = setup(SAME_AXES); frame(&mut reset, &ctx, DriverMapping::default(), mixed, 0.02); assert_eq!(reset.cam, unswapped_reference.cam); @@ -883,9 +1582,9 @@ mod tests { // An axis the mapping switches off says so. let mut off = DriverMapping::default(); off.axes[2].enabled = false; - assert_eq!(row_label(Some(off), 2), "ui.space_mouse_view_off"); + assert_eq!(row_label(Some(off), 2), "ui.3d_mouse_view_off"); // Without a mapping, rows claim no view effect and name none of the dropdown's motions. - assert_eq!(labels(None), ["ui.space_mouse_view_unmapped"; 6]); + assert_eq!(labels(None), ["ui.3d_mouse_view_unmapped"; 6]); } #[test] @@ -914,12 +1613,16 @@ mod tests { app.space_mouse.device = Some("hid 1234:5678".into()); app.preferences.space_mouse_axis_source = [0, 2, 1, 3, 4, 5]; crate::space_mouse_calibration::start(&mut app); + // The calibration asks what kind of device it is first. + app.space_mouse.calibrating.as_mut().unwrap().1.choose(solidcraft_space_mouse::Kind::ReturnToCenter); let feed = |app: &mut SolveApp, axes: Axes, seconds: f64| { for _ in 0..(seconds / 0.02).round() as usize { frame(app, &ctx, DriverMapping::default(), axes, 0.02); } }; feed(&mut app, Axes::default(), 1.6); + // And a hand resting on the cap, before the steps. + feed(&mut app, Axes::default(), 2.6); for step in solidcraft_space_mouse::STEPS { let mut raw = [0_i16; 6]; raw[step.axis] = if step.positive { 300 } else { -300 }; diff --git a/crates/ui-egui/src/space_mouse_calibration.rs b/crates/ui-egui/src/space_mouse_calibration.rs index 848cdf47e0..536c031161 100644 --- a/crates/ui-egui/src/space_mouse_calibration.rs +++ b/crates/ui-egui/src/space_mouse_calibration.rs @@ -1,28 +1,48 @@ -//! The 3D mouse calibration window, opened from Preferences › Navigation. It asks the user to -//! leave the cap still, then to move it along each axis both ways, moving on by itself after each +//! The 3D mouse calibration window, opened from Preferences › Navigation. It asks what kind of +//! device it is (Return to Center, or a trackball), then asks the user to leave it still and +//! to move it along each axis both ways, moving on by itself after each //! (`solidcraft_space_mouse::Wizard`), and saves what it found for the device it started with. //! The window closes with Preferences, and when another device (or none) is read instead. -use solidcraft_space_mouse::{Calibration, Problem, Stage, Wizard}; +use solidcraft_space_mouse::{Calibration, Kind, Problem, Stage, Wizard}; use crate::SolveApp; /// What each of `solidcraft_space_mouse::STEPS` asks for, in order. const STEP_PROMPTS: [&str; 12] = [ - "ui.space_mouse_step_slide_right", - "ui.space_mouse_step_slide_left", - "ui.space_mouse_step_pull", - "ui.space_mouse_step_push", - "ui.space_mouse_step_press", - "ui.space_mouse_step_lift", - "ui.space_mouse_step_tilt_back", - "ui.space_mouse_step_tilt_forward", - "ui.space_mouse_step_tilt_left", - "ui.space_mouse_step_tilt_right", - "ui.space_mouse_step_twist_clockwise", - "ui.space_mouse_step_twist_counterclockwise", + "ui.3d_mouse_step_slide_right", + "ui.3d_mouse_step_slide_left", + "ui.3d_mouse_step_pull", + "ui.3d_mouse_step_push", + "ui.3d_mouse_step_press", + "ui.3d_mouse_step_lift", + "ui.3d_mouse_step_tilt_toward", + "ui.3d_mouse_step_tilt_away", + "ui.3d_mouse_step_tilt_left", + "ui.3d_mouse_step_tilt_right", + "ui.3d_mouse_step_twist_clockwise", + "ui.3d_mouse_step_twist_counterclockwise", ]; +/// The same for a trackball: it has nothing to tilt, so those steps aren't asked. +const TRACKBALL_PROMPTS: [&str; 12] = [ + "ui.3d_mouse_step_roll_right", + "ui.3d_mouse_step_roll_left", + "ui.3d_mouse_step_roll_toward", + "ui.3d_mouse_step_roll_away", + "ui.3d_mouse_step_ring_clockwise", + "ui.3d_mouse_step_ring_counterclockwise", + "", + "", + "", + "", + "ui.3d_mouse_step_spin_clockwise", + "ui.3d_mouse_step_spin_counterclockwise", +]; + +/// Each kind of device, as the first step offers it. +const KINDS: [(Kind, &str); 2] = [(Kind::ReturnToCenter, "ui.3d_mouse_kind_cap"), (Kind::Trackball, "ui.3d_mouse_kind_trackball")]; + /// The dead zone, as a share of full travel, shown as a percentage. pub(crate) fn dead_zone_slider(ui: &mut egui::Ui, share: &mut f32) -> egui::Response { crate::ui_kit::slider( @@ -33,24 +53,42 @@ pub(crate) fn dead_zone_slider(ui: &mut egui::Ui, share: &mut f32) -> egui::Resp ) } +/// The tooltip of the circle for axes `(h, v)`: each axis's dead zone on its two sides and, where the +/// page says (not in the calibration window), the view axis it drives. +pub(crate) fn pad_tooltip(shares: &[[f32; 2]; 6], (h, v): (usize, usize), stories: &[String; 6]) -> String { + const NAMES: [&str; 6] = ["X", "Y", "Z", "Rx", "Ry", "Rz"]; + let line = |axis: usize| { + let percent = format!("{} −{:.0} % +{:.0} %", NAMES[axis], shares[axis][0] * 100.0, shares[axis][1] * 100.0); + if stories[axis].is_empty() { percent } else { format!("{percent} → {}", stories[axis]) } + }; + format!("{}\n{}", line(h), line(v)) +} + /// The calibration's dead zones the way a game controller tester shows a stick: per pair of axes, /// a circle for the full travel, the dead zone shaded inside it (each direction its own size), and /// a dot where the cap is now, before the dead zone, so it's plain when the view starts to move. -pub(crate) fn dead_zone_pads(ui: &mut egui::Ui, calibration: &Calibration, raw: [i16; 6]) { +pub(crate) fn dead_zone_pads(ui: &mut egui::Ui, calibration: &Calibration, raw: [i16; 6], centred: bool, stories: &[String; 6]) { const NAMES: [&str; 6] = ["X", "Y", "Z", "Rx", "Ry", "Rz"]; // (horizontal axis, its positive way on screen, vertical axis, its positive way down), so the // dot moves as the cap does: slid right and pulled toward you, twisted and pressed, tilted. const PADS: [(usize, f32, usize, f32); 3] = [(0, 1.0, 1, 1.0), (5, 1.0, 2, 1.0), (4, -1.0, 3, -1.0)]; const SIZE: f32 = 76.0; let (shares, travel, moving) = (calibration.dead_zone_shares(), calibration.travel(raw), calibration.apply(raw)); + const GAP: f32 = 12.0; let visuals = ui.visuals().clone(); ui.horizontal(|ui| { + // The three together with small gaps: centred in the calibration window, from the left edge + // (under the device's picture) in Preferences. + ui.spacing_mut().item_spacing.x = GAP; + if centred { + ui.add_space(((ui.available_width() - PADS.len() as f32 * SIZE - (PADS.len() - 1) as f32 * GAP) / 2.0).max(0.0)); + } for (h, h_way, v, v_way) in PADS { ui.vertical(|ui| { let (rect, response) = ui.allocate_exact_size(egui::vec2(SIZE, SIZE), egui::Sense::hover()); let painter = ui.painter_at(rect); let (centre, radius) = (rect.center(), SIZE / 2.0 - 2.0); - let faint = visuals.weak_text_color(); + let faint = crate::theme::Tokens::get().text_dim; // Which side of an axis's dead zone lies along a way on screen. let side = |axis: usize, way: f32, screen: f32| shares[axis][usize::from(way * screen > 0.0)]; let zone: Vec = (0..64) @@ -74,29 +112,66 @@ pub(crate) fn dead_zone_pads(ui: &mut egui::Ui, calibration: &Calibration, raw: let dot = if dot.length() > 1.0 { dot.normalized() } else { dot }; let live = moving[h] != 0 || moving[v] != 0; painter.circle_filled(centre + dot * radius, 3.5, if live { visuals.selection.stroke.color } else { faint }); - let percent = |axis: usize| format!("{} −{:.0} % +{:.0} %", NAMES[axis], shares[axis][0] * 100.0, shares[axis][1] * 100.0); - response.on_hover_text(format!("{}\n{}", percent(h), percent(v))); - ui.label(egui::RichText::new(format!("{} · {}", NAMES[h], NAMES[v])).small().color(faint)); + response.on_hover_text(pad_tooltip(&shares, (h, v), stories)); + // As wide as the circle, so the labels don't push the circles apart. + ui.allocate_ui(egui::vec2(SIZE, 0.0), |ui| { + crate::ui_kit::text(ui, &format!("{} · {}", NAMES[h], NAMES[v]), crate::ui_kit::Text::Note("space_mouse_pad")); + }); }); } }); } +/// The device's six raw axes as they come, so the jitter at rest is there to see: six equal +/// columns, each axis name centred on one line and its number centred under it, the group centred +/// like the dead-zone pads. Painted in one go so every name and number shares its column and baseline. +fn reading_grid(ui: &mut egui::Ui, raw: [i16; 6]) { + const NAMES: [&str; 6] = ["X", "Y", "Z", "Rx", "Ry", "Rz"]; + const CELL: f32 = 48.0; + let t = crate::theme::Tokens::get(); + let (name_font, number_font) = (egui::FontId::proportional(t.font_small), egui::FontId::monospace(t.font_small)); + let painter = ui.painter().clone(); + let line = |font: &egui::FontId| painter.layout_no_wrap("0".into(), font.clone(), t.text_dim).size().y; + let (name_height, number_height) = (line(&name_font), line(&number_font)); + let (area, _) = ui.allocate_exact_size(egui::vec2(ui.available_width(), name_height + number_height + t.space_4), egui::Sense::hover()); + crate::scenario::publish_rect("space_mouse:reading_block", area); + let left = area.left() + ((area.width() - CELL * 6.0) / 2.0).max(0.0); + for (i, (name, value)) in NAMES.into_iter().zip(raw).enumerate() { + let centre = left + (i as f32 + 0.5) * CELL; + let cell = |text: &str, font: &egui::FontId, colour: egui::Color32, top: f32, key: &str| { + let galley = painter.layout_no_wrap(text.to_owned(), font.clone(), colour); + let rect = egui::Rect::from_center_size(egui::pos2(centre, top + galley.size().y / 2.0), galley.size()); + painter.galley(rect.min, galley, colour); + crate::scenario::publish_rect(&format!("space_mouse:reading_{key}:{i}"), rect); + }; + cell(name, &name_font, t.text_dim, area.top(), "name"); + cell(&value.to_string(), &number_font, t.form_ink, area.top() + name_height + t.space_4, "value"); + } +} + /// Starts calibrating the device being read, if there is one. pub fn start(app: &mut SolveApp) { - app.space_mouse.calibrating = app.space_mouse.device.clone().map(|device| (device, Wizard::default())); + app.space_mouse.calibrating = app.space_mouse.device.clone().map(|device| (device, Wizard::choosing())); } pub fn show(app: &mut SolveApp, ctx: &egui::Context) { let Some((_, wizard)) = &app.space_mouse.calibrating else { return }; - let (stage, progress, result) = (wizard.stage(), wizard.progress(), wizard.result()); + let (stage, kind, progress, result) = (wizard.stage(), wizard.kind(), wizard.progress(), wizard.result()); + let (can_skip, mismatch, (step_number, steps)) = (wizard.can_skip(), wizard.mismatch(), wizard.counter()); let mut dead_zone = wizard.dead_zone_share(); let raw: [i16; 6] = app.space_mouse.raw.into(); + let prompts = match kind { + Kind::ReturnToCenter => &STEP_PROMPTS, + Kind::Trackball => &TRACKBALL_PROMPTS, + }; let prompt = match (stage, result) { - (Stage::Rest, _) => "ui.space_mouse_step_rest", - (Stage::Step(index), _) => STEP_PROMPTS.get(index).copied().unwrap_or_default(), - (Stage::Done, Some(Ok(_))) => "ui.space_mouse_calibration_done", - (Stage::Done, _) => "ui.space_mouse_calibration_no_response", + (Stage::Choose, _) => "ui.3d_mouse_kind_question", + (Stage::Rest, _) if kind == Kind::Trackball => "ui.3d_mouse_step_rest_trackball", + (Stage::Rest, _) => "ui.3d_mouse_step_rest", + (Stage::Touch, _) => "ui.3d_mouse_step_touch", + (Stage::Step(index), _) => prompts.get(index).copied().unwrap_or_default(), + (Stage::Done, Some(Ok(_))) => "ui.3d_mouse_calibration_done", + (Stage::Done, _) => "ui.3d_mouse_calibration_no_response", }; // The calibrated motion that barely moved, as Preferences names it. let weak = match result { @@ -104,47 +179,93 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { _ => None, }; let counter = match stage { - Stage::Rest => String::new(), - Stage::Step(index) => format!("{} / {}", index + 1, STEP_PROMPTS.len()), - Stage::Done => format!("{0} / {0}", STEP_PROMPTS.len()), + Stage::Choose | Stage::Rest | Stage::Touch => String::new(), + Stage::Step(_) | Stage::Done => format!("{step_number} / {steps}"), }; - let (mut back, mut over, mut cancel, mut save) = (false, false, false, false); - let closed = crate::ui_kit::Window::new(egui::Id::new("sc_space_mouse_calibration"), "settings", app.tr("ui.space_mouse_calibrate_title")) - .show_body(ctx, |ui| { - ui.label(app.tr(prompt)); - if let Some(axis) = weak { - ui.label(egui::RichText::new(app.tr(axis)).strong()); - } - ui.add_space(6.0); - crate::ui_kit::progress(ui, "space_mouse_calibration", progress as f32); - ui.label(counter); - if let Some(Ok(calibration)) = result { - ui.add_space(6.0); - dead_zone_pads(ui, &Calibration { dead_zone_share: dead_zone, ..calibration }, raw); - } - // The device's six raw axes as they come, so the jitter at rest is there to see. - let reading = raw.map(|value| format!("{value:>6}")).join(" "); - ui.label(egui::RichText::new(format!("{}: {reading}", app.tr("ui.space_mouse_reading"))).monospace()); - // A reassignment made in Preferences stays on top of whatever this records. - if app.preferences.space_mouse_axis_source != crate::space_mouse::SAME_AXES { - ui.label(egui::RichText::new(app.tr("ui.space_mouse_axes_reassigned")).small()); - } - ui.horizontal(|ui| { - ui.label(app.tr("ui.space_mouse_dead_zone")); - dead_zone_slider(ui, &mut dead_zone).on_hover_text(app.tr("ui.space_mouse_dead_zone_tip")); - }); - ui.add_space(6.0); - ui.horizontal(|ui| { - back = ui - .add_enabled_ui(stage != Stage::Rest, |ui| crate::ui_kit::button(ui, app.tr("ui.back"), crate::ui_kit::Button::Normal)) - .inner - .clicked(); - over = matches!(result, Some(Err(_))) && crate::ui_kit::button(ui, app.tr("ui.start_over"), crate::ui_kit::Button::Normal).clicked(); - cancel = crate::ui_kit::button(ui, app.tr("ui.cancel"), crate::ui_kit::Button::Normal).clicked(); - save = matches!(result, Some(Ok(_))) && crate::ui_kit::button(ui, app.tr("ui.save"), crate::ui_kit::Button::Normal).clicked(); - }); - }); - if cancel || closed { + let (mut back, mut over, mut skip) = (false, false, false); + let mut chosen = None; + let done = matches!(result, Some(Ok(_))); + let text = |ui: &mut egui::Ui, text: &str, key: &str| crate::ui_kit::text(ui, text, crate::ui_kit::Text::Report(key, false)); + let (closed, action) = crate::ui_kit::Window::new(egui::Id::new("sc_space_mouse_calibration"), "settings", app.tr("ui.3d_mouse_calibrate_title")) + .actions(if done { app.tr("ui.save") } else { "" }, app.tr("ui.cancel"), None) + .show_with_left( + ctx, + done, + |ui| { + text(ui, app.tr(prompt), "prompt"); + if let Some(axis) = weak { + crate::ui_kit::text(ui, app.tr(axis), crate::ui_kit::Text::Heading("weak_axis")); + } + // The device doesn't behave like the kind picked: say so, and offer the other. + if let Some(other) = mismatch { + let (hint, switch) = match other { + Kind::Trackball => ("ui.3d_mouse_looks_like_trackball", "ui.3d_mouse_switch_trackball"), + Kind::ReturnToCenter => ("ui.3d_mouse_looks_like_cap", "ui.3d_mouse_switch_cap"), + }; + crate::ui_kit::text(ui, app.tr(hint), crate::ui_kit::Text::Hint("space_mouse_kind_mismatch")); + if crate::ui_kit::button(ui, app.tr(switch), crate::ui_kit::Button::Normal).clicked() { + chosen = Some(other); + } + } + if stage == Stage::Choose { + // Side by side where they fit, one under the other where the window is narrow. + ui.horizontal_wrapped(|ui| { + for (kind, label) in KINDS { + ui.vertical(|ui| { + crate::space_mouse_model::rest(ui, kind); + if crate::ui_kit::button(ui, app.tr(label), crate::ui_kit::Button::Normal).clicked() { + chosen = Some(kind); + } + }); + } + }); + } else { + crate::ui_kit::progress(ui, "space_mouse_calibration", progress as f32); + crate::ui_kit::text(ui, &counter, crate::ui_kit::Text::Note("space_mouse_counter")); + // Each step shows the motion it asks for; once calibrated, the device's picture follows it. + let step = match stage { + Stage::Step(index) => solidcraft_space_mouse::STEPS.get(index).copied(), + _ => None, + }; + match (step, result) { + (Some(step), _) => crate::space_mouse_model::demo(ui, step, kind), + (_, Some(Ok(calibration))) => { + let calibration = Calibration { dead_zone_share: dead_zone, ..calibration }; + crate::space_mouse_model::live(ui, &calibration, raw); + dead_zone_pads(ui, &calibration, raw, true, &Default::default()); + } + _ => crate::space_mouse_model::rest(ui, kind), + } + } + // The device's six raw axes as they come, so the jitter at rest is there to see. + crate::ui_kit::text(ui, app.tr("ui.3d_mouse_reading"), crate::ui_kit::Text::Note("space_mouse_reading")); + reading_grid(ui, raw); + // A reassignment made in Preferences stays on top of whatever this records. + if app.preferences.space_mouse_axis_source != crate::space_mouse::SAME_AXES { + crate::ui_kit::text(ui, app.tr("ui.3d_mouse_axes_reassigned"), crate::ui_kit::Text::Note("space_mouse_axes_reassigned")); + } + crate::ui_kit::row(ui, app.tr("ui.3d_mouse_dead_zone"), |ui| { + dead_zone_slider(ui, &mut dead_zone).on_hover_text(app.tr("ui.3d_mouse_dead_zone_tip")); + }); + }, + |ui| { + ui.horizontal(|ui| { + back = ui + .add_enabled_ui(stage != Stage::Choose, |ui| crate::ui_kit::button(ui, app.tr("ui.back"), crate::ui_kit::Button::Normal)) + .inner + .clicked(); + skip = can_skip + && crate::ui_kit::button(ui, app.tr("ui.3d_mouse_skip"), crate::ui_kit::Button::Normal) + .on_hover_text(app.tr("ui.3d_mouse_skip_tip")) + .clicked(); + over = + matches!(result, Some(Err(_))) && crate::ui_kit::button(ui, app.tr("ui.start_over"), crate::ui_kit::Button::Normal).clicked(); + }); + }, + ); + let cancel = closed || action == crate::ui_kit::Action::Cancel; + let save = action == crate::ui_kit::Action::Ok; + if cancel { app.space_mouse.calibrating = None; } else if let Some((device, wizard)) = &mut app.space_mouse.calibrating { wizard.set_dead_zone_share(dead_zone); @@ -152,10 +273,13 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { if save && let Some(Ok(calibration)) = result { app.preferences.space_mouse_calibrations.insert(std::mem::take(device), calibration); app.space_mouse.calibrating = None; + } else if let Some(kind) = chosen { + wizard.choose(kind); } else if over { - // A new recording; the dead zone set so far stays. - *wizard = Wizard::default(); - wizard.set_dead_zone_share(dead_zone); + // A new recording of the same kind; the dead zone set so far stays. + wizard.choose(wizard.kind()); + } else if skip { + wizard.skip(); } else if back { wizard.back(); } diff --git a/crates/ui-egui/src/space_mouse_model.rs b/crates/ui-egui/src/space_mouse_model.rs new file mode 100644 index 0000000000..da4f489a02 --- /dev/null +++ b/crates/ui-egui/src/space_mouse_model.rs @@ -0,0 +1,546 @@ +//! A picture of a 3D mouse, seen as the user sits at it (from in front, a little above), whose +//! cap moves as the real one does. Each way of moving it has a marker that lights while it reads +//! as motion: the groove on the cap's front and the pointer on its top turn against the ticks on +//! the base (twist), the mast tilts away from the dashed upright (tilt), the cap leaves the ring +//! it rests in (slide), and the gauge beside it rises and falls (pull up, press down). +//! It's a generic cap on a round base, drawn in code, not any maker's device. + +use egui::{Pos2, Shape, Stroke}; +use solidcraft_space_mouse::{Calibration, Kind, Step}; + +#[cfg(test)] +#[path = "space_mouse_model_tests.rs"] +mod tests; + +/// A point in the device's space: right, away from the user, up. The base's radius is 1. +type P3 = [f32; 3]; + +/// How far down the view looks, in radians. +const PITCH: f32 = 0.52; +/// Full deflection, in base radii and radians. +const SLIDE: f32 = 0.18; +const LIFT: f32 = 0.09; +const TILT: f32 = 0.35; +const TWIST: f32 = 0.6; +const BASE_HEIGHT: f32 = 0.22; +/// The base under the cap, and under the trackball's scroll ring. +const BASE_RADIUS: f32 = 0.8; +/// The cap's bottom at rest; the gap under it shows the light, closing when pressed. +const CAP_BOTTOM: f32 = BASE_HEIGHT + 0.1; +const CAP_RADIUS: f32 = 0.5; +const CAP_HEIGHT: f32 = 0.62; +/// Where the gauge stands, and how far its pointer goes at full deflection. +const GAUGE: [f32; 2] = [1.05, 0.6]; +const GAUGE_TRAVEL: f32 = 0.3; + +/// The cap's position and turn for a reading on the canonical axes (±1 at full deflection): +/// slid right +X, pulled toward the user +Y, pressed down +Z, its top tilted toward the user +Rx +/// (the near edge down; the axes are right-handed with Y toward the user and Z down), tilted left +Ry, +/// twisted clockwise +Rz. +#[derive(Clone, Copy, Debug)] +pub(crate) struct Pose { + offset: P3, + /// Columns of the cap's rotation. + turn: [P3; 3], +} + +impl Pose { + pub(crate) fn new(travel: [f32; 6]) -> Self { + let t = travel.map(|v| if v.is_finite() { v.clamp(-1.5, 1.5) } else { 0.0 }); + // Twisting clockwise as seen from above is a negative turn about up; +Rx takes the top + // toward the user, tilting left takes it to the left. + let twist = rotate_z(-t[5] * TWIST); + let toward = rotate_x(-t[3] * TILT); + let left = rotate_y(-t[4] * TILT); + let turn = [0, 1, 2].map(|i| apply(&left, apply(&toward, apply(&twist, unit(i))))); + Self { offset: [t[0] * SLIDE, -t[1] * SLIDE, CAP_BOTTOM - t[2] * LIFT], turn } + } + + /// A point on the cap (from the centre of its bottom) in the device's space. + fn place(&self, p: P3) -> P3 { + add(apply(&self.turn, p), self.offset) + } +} + +/// Which way the user looks at the device. +fn eye() -> P3 { + [0.0, -PITCH.cos(), PITCH.sin()] +} + +/// The device's space on screen, as a share of `scale`, y down. +fn project(p: P3) -> [f32; 2] { + [p[0], -(p[1] * PITCH.sin() + p[2] * PITCH.cos())] +} + +/// How big the device is in `rect`, in points per base radius, and where a point in its space +/// lands there. +fn view(rect: egui::Rect) -> (f32, impl Fn(P3) -> Pos2 + Copy) { + let scale = (rect.width() / 2.9).min(rect.height() / 2.1); + let origin = rect.center() + egui::vec2(-0.15, 0.4) * scale; + (scale, move |p: P3| { + let [x, y] = project(p); + origin + egui::vec2(x, y) * scale + }) +} + +/// Where the gauge's pointer is for `pose`, as the three corners of its arrow. +fn gauge_pointer(at: &impl Fn(P3) -> Pos2, pose: &Pose) -> Vec { + let [gx, gh] = GAUGE; + let pointer = at([gx, 0.0, gh + (pose.offset[2] - CAP_BOTTOM) / LIFT * GAUGE_TRAVEL]); + vec![pointer + egui::vec2(-4.0, 0.0), pointer + egui::vec2(5.0, -5.0), pointer + egui::vec2(5.0, 5.0)] +} + +/// Draws the device in `rect` for `pose`; `moving` lights each canonical axis's marker. +pub(crate) fn paint(ui: &egui::Ui, rect: egui::Rect, pose: &Pose, moving: [bool; 6]) { + let painter = ui.painter_at(rect); + let visuals = ui.visuals(); + let (_, at) = view(rect); + let (faint, accent) = (crate::theme::Tokens::get().text_dim, visuals.selection.stroke.color); + let marker = |live: bool| Stroke::new(if live { 2.5 } else { 1.5 }, if live { accent } else { faint }); + let outline = Stroke::new(1.0, faint); + let (base_side, base_top) = (visuals.widgets.noninteractive.bg_stroke.color, visuals.widgets.inactive.bg_fill); + let (cap_side, cap_top) = (visuals.widgets.active.bg_fill, visuals.widgets.hovered.bg_fill); + + // The base, the ticks round it to read a twist against, and the ring the cap rests in. + let base: Vec = ring(BASE_RADIUS, 0.0).into_iter().chain(ring(BASE_RADIUS, BASE_HEIGHT)).map(at).collect(); + painter.add(Shape::convex_polygon(hull(base), base_side, outline)); + painter.add(Shape::convex_polygon(ring(BASE_RADIUS, BASE_HEIGHT).into_iter().map(at).collect(), base_top, outline)); + for i in 0..24 { + let (sin, cos) = (i as f32 / 24.0 * std::f32::consts::TAU).sin_cos(); + let (inner, outer) = if i % 6 == 0 { (0.58, 0.76) } else { (0.62, 0.71) }; + painter.line_segment([at([inner * cos, inner * sin, BASE_HEIGHT]), at([outer * cos, outer * sin, BASE_HEIGHT])], outline); + } + let sliding = moving[0] || moving[1]; + painter.add(Shape::closed_line(ring(CAP_RADIUS + 0.03, BASE_HEIGHT).into_iter().map(at).collect(), marker(sliding))); + // The light under the cap, showing in the gap that pulling up widens and pressing closes. + let glow = ring(CAP_RADIUS - 0.04, BASE_HEIGHT).into_iter().map(|p| at(add(p, [pose.offset[0], pose.offset[1], 0.0]))); + painter.add(Shape::convex_polygon(glow.collect(), visuals.selection.bg_fill, Stroke::NONE)); + + // The gauge: its notch is rest, its pointer where pulling up or pressing has the cap. + let [gx, gh] = GAUGE; + let gauge = |h: f32| at([gx, 0.0, h]); + painter.line_segment([gauge(gh - GAUGE_TRAVEL), gauge(gh + GAUGE_TRAVEL)], outline); + for h in [gh - GAUGE_TRAVEL, gh, gh + GAUGE_TRAVEL] { + painter.line_segment([gauge(h) - egui::vec2(3.0, 0.0), gauge(h) + egui::vec2(3.0, 0.0)], outline); + } + let lifting = moving[2]; + painter.add(Shape::convex_polygon(gauge_pointer(&at, pose), if lifting { accent } else { faint }, Stroke::NONE)); + + // The cap: its side, the groove down its front where the side faces the user, then its top. + let bottom = ring(CAP_RADIUS, 0.0); + let top = ring(CAP_RADIUS, CAP_HEIGHT); + let side: Vec = bottom.iter().chain(&top).map(|&p| at(pose.place(p))).collect(); + painter.add(Shape::convex_polygon(hull(side), cap_side, outline)); + let twisting = moving[5]; + let front = apply(&pose.turn, [0.0, -1.0, 0.0]); + if dot(front, eye()) > 0.0 { + let groove = [0.1, CAP_HEIGHT - 0.1].map(|h| at(pose.place([0.0, -CAP_RADIUS, h]))); + painter.line_segment(groove, marker(twisting)); + } + painter.add(Shape::convex_polygon(top.iter().map(|&p| at(pose.place(p))).collect(), cap_top, Stroke::new(1.5, faint))); + let centre = pose.place([0.0, 0.0, CAP_HEIGHT]); + let pointer = pose.place([0.0, -0.36, CAP_HEIGHT]); + painter.line_segment([at(centre), at(pointer)], marker(twisting)); + painter.circle_filled(at(pointer), 2.5, if twisting { accent } else { faint }); + + // The mast stands on the cap's axis; the dashed upright is where it stands at rest. + let tilting = moving[3] || moving[4]; + let rest = [0.0, 0.0, CAP_BOTTOM + CAP_HEIGHT]; + painter.extend(Shape::dashed_line(&[at(rest), at(add(rest, [0.0, 0.0, 0.5]))], Stroke::new(1.0, faint.gamma_multiply(0.6)), 3.0, 3.0)); + let mast = add(centre, scaled(apply(&pose.turn, [0.0, 0.0, 1.0]), 0.5)); + painter.line_segment([at(centre), at(mast)], marker(tilting)); + painter.circle_filled(at(mast), 3.0, if tilting { accent } else { faint }); +} + +/// The picture's size in a window. +const SIZE: egui::Vec2 = egui::vec2(200.0, 150.0); + +/// How a trackball moves for a reading on the canonical axes, as calibration asks for its motions: +/// rolled away from the user (-Y), rolled right (+X), spun clockwise (+Rz), its scroll ring turned +/// clockwise (+Z). The reading is a speed, so this is too. +pub(crate) fn ball_rate(travel: [f32; 6]) -> [f32; 4] { + [-travel[1], travel[0], travel[5], travel[2]] +} + +/// A trackball kept in `ui`'s memory for one call `site` (`ui` and `site` together are its key, so +/// the Preferences picture, the corner and the demo each roll their own once a frame), rolled by +/// `travel` since the last frame: unlike a cap, its ball and scroll ring keep their turn from frame to frame. +/// With `repaint` a rolling ball asks for the next frame in 16 ms; the demo sets its own pace instead +/// (egui keeps the shortest request, so a 16 ms one would beat its slower ones, unfocused too). +fn rolled(ui: &egui::Ui, site: &str, travel: [f32; 6], repaint: bool) -> ball::Ball { + let (rate, seconds) = (ball_rate(travel), ui.input(|i| i.stable_dt)); + if repaint && rate.iter().any(|&v| v != 0.0) { + ui.ctx().request_repaint_after(std::time::Duration::from_millis(16)); + } + ui.data_mut(|data| { + let ball = data.get_temp_mut_or_insert_with(ui.id().with(("sc_space_mouse_ball", site)), ball::Ball::new); + ball.roll(rate, seconds); + *ball + }) +} + +/// The call site of the demo, which paces its own repaints. +const DEMO: &str = "demo"; + +/// Draws a device of `kind` in `rect` moved by `travel`, its markers lit by `moving`. +fn draw(ui: &egui::Ui, site: &str, rect: egui::Rect, kind: Kind, travel: [f32; 6], moving: [bool; 6]) { + match kind { + Kind::ReturnToCenter => paint(ui, rect, &Pose::new(travel), moving), + Kind::Trackball => { + let shown = rolled(ui, site, travel, site != DEMO); + ball::paint(ui, rect, &shown, moving[0] || moving[1] || moving[5], moving[2]); + } + } +} + +/// A device of `kind`, still. +pub(crate) fn rest(ui: &mut egui::Ui, kind: Kind) { + let (rect, _) = ui.allocate_exact_size(SIZE, egui::Sense::hover()); + crate::scenario::publish_rect("space_mouse:picture", rect); + draw(ui, "rest", rect, kind, [0.0; 6], [false; 6]); +} + +const CORNER: egui::Vec2 = egui::vec2(160.0, 120.0); + +/// Where the corner picture sits in `view`: the two left positions at its lower left, the right one +/// at its lower right, each a margin in from the edge (the caller passes the viewport area for the +/// Viewport positions and the whole window for Left Window). Any of `obstacles` (the History bar, +/// the navigation bar, the agent chip) that would cover it moves it up to just above them, and it +/// stays inside `view`. +pub(crate) fn corner_rect(view: egui::Rect, position: crate::prefs::SpaceMouseGhost, obstacles: &[egui::Rect]) -> egui::Rect { + use crate::prefs::SpaceMouseGhost; + const MARGIN: f32 = 12.0; + let x = match position { + SpaceMouseGhost::LeftViewport | SpaceMouseGhost::LeftWindow => view.left() + MARGIN, + SpaceMouseGhost::RightViewport => (view.right() - MARGIN - CORNER.x).max(view.left() + MARGIN), + }; + let mut rect = egui::Rect::from_min_size(egui::pos2(x, view.bottom() - MARGIN - CORNER.y), CORNER); + for _ in 0..obstacles.len() { + let Some(blocking) = obstacles.iter().find(|bar| bar.is_positive() && bar.intersects(rect)) else { break }; + rect = rect.translate(egui::vec2(0.0, blocking.top() - MARGIN - rect.bottom())); + } + // Stacked obstacles in a short view must not push it out of the top. + rect.translate(egui::vec2(0.0, (view.top() - rect.top()).max(0.0))) +} + +/// The device in a corner of the 3D view (`rect`, from `corner_rect`), see-through so it doesn't hide the design: +/// cap or trackball as `calibration` says, moving as the device does. A device without a +/// calibration is read as it reports: each axis as a share of the full deflection a reading is +/// scaled to (`FULL_SCALE`). +pub(crate) fn corner(ui: &mut egui::Ui, rect: egui::Rect, calibration: Option<&Calibration>, raw: [i16; 6]) { + let (kind, travel, moving) = match calibration { + Some(calibration) => (calibration.kind, calibration.travel(raw), calibration.apply(raw).map(|value| value != 0)), + None => { + let travel = raw.map(|value| f32::from(value) / f32::from(solidcraft_space_mouse::FULL_SCALE)); + (Kind::ReturnToCenter, travel, travel.map(|value| value.abs() > 0.05)) + } + }; + let travel = still_unless_moving(kind, travel, moving); + crate::scenario::publish_rect("space_mouse:corner", rect); + let mut corner = ui.new_child(egui::UiBuilder::new().id_salt("sc_space_mouse_corner").max_rect(rect)); + corner.set_opacity(0.6); + draw(&corner, "corner", rect, kind, travel, moving); +} + +/// A trackball's reading is a speed: within the dead zone it is noise, and rolling the picture by +/// it would make it creep. +fn still_unless_moving(kind: Kind, mut travel: [f32; 6], moving: [bool; 6]) -> [f32; 6] { + if kind == Kind::Trackball { + for (value, &moving) in travel.iter_mut().zip(&moving) { + if !moving { + *value = 0.0; + } + } + } + travel +} + +/// The calibrated device as it is now, cap or trackball; a marker lights once its axis is past +/// the dead zone, so it lights when the view would move. A trackball only rolls then, so it +/// doesn't creep with the noise a dead zone hides. +pub(crate) fn live(ui: &mut egui::Ui, calibration: &Calibration, raw: [i16; 6]) { + let (rect, _) = ui.allocate_exact_size(SIZE, egui::Sense::hover()); + let (travel, moving) = (calibration.travel(raw), calibration.apply(raw).map(|value| value != 0)); + draw(ui, "live", rect, calibration.kind, still_unless_moving(calibration.kind, travel, moving), moving); +} + +/// One showing of a step's motion, in seconds: easing out to full deflection, holding there, +/// easing back, then resting before it starts again. +pub(crate) const DEMO_SECONDS: [f32; 4] = [1.5, 0.8, 0.7, 0.5]; + +/// How far through its travel a step's motion is, `seconds` after the step started (0 to 1). +/// It always starts at rest, so every step begins with the cap or ball still. +pub(crate) fn demo_swing(seconds: f32) -> f32 { + let [out, hold, back, rest] = DEMO_SECONDS; + let t = if seconds.is_finite() { seconds.max(0.0) % (out + hold + back + rest) } else { 0.0 }; + // Smoothstep: starts and stops gently. + let ease = |x: f32| { + let x = x.clamp(0.0, 1.0); + x * x * (3.0 - 2.0 * x) + }; + if t < out { + ease(t / out) + } else if t < out + hold { + 1.0 + } else { + 1.0 - ease((t - out - hold) / back) + } +} + +/// The motion a calibration step asks for, played over and over, from rest each time and from +/// rest again whenever the step changes. +pub(crate) fn demo(ui: &mut egui::Ui, step: Step, kind: Kind) { + let now = ui.input(|i| i.time); + let id = egui::Id::new("sc_space_mouse_demo"); + let key = (step.axis, step.positive); + let started = ui.data_mut(|data| { + let (shown, started) = data.get_temp_mut_or_insert_with(id, || (key, now)); + if *shown != key { + *shown = key; + *started = now; + } + *started + }); + let swing = demo_swing((now - started) as f32); + let (mut travel, mut moving) = ([0.0; 6], [false; 6]); + if let (Some(value), Some(live)) = (travel.get_mut(step.axis), moving.get_mut(step.axis)) { + *value = if step.positive { swing } else { -swing }; + *live = swing > 0.15; + } + let (rect, _) = ui.allocate_exact_size(SIZE, egui::Sense::hover()); + draw(ui, DEMO, rect, kind, travel, moving); + let focused = ui.input(|i| i.focused); + ui.ctx().request_repaint_after(demo_repaint_after((now - started) as f32, focused)); +} + +/// How long the demo waits before it is drawn again: 20 frames a second while the cap or ball moves, +/// 4 while it holds or rests (nothing changes), and 2 when the window is not in use. +pub(crate) fn demo_repaint_after(seconds: f32, focused: bool) -> std::time::Duration { + use std::time::Duration; + if !focused { + Duration::from_millis(500) + } else if (demo_swing(seconds) - demo_swing(seconds + 0.1)).abs() < 1e-6 { + Duration::from_millis(250) + } else { + Duration::from_millis(50) + } +} + +/// A level circle round the device's centre line. +fn ring(radius: f32, height: f32) -> Vec { + (0..48) + .map(|i| { + let (sin, cos) = (i as f32 / 48.0 * std::f32::consts::TAU).sin_cos(); + [radius * cos, radius * sin, height] + }) + .collect() +} + +pub(crate) mod ball { + use egui::{Pos2, Shape, Stroke}; + + use super::{BASE_HEIGHT, BASE_RADIUS, P3, add, apply, dot, eye, hull, ring, rotate_x, rotate_y, rotate_z, scaled, view}; + + const RADIUS: f32 = 0.6; + /// The ball's centre; its lower part sits down in the scroll ring. + const CENTRE: P3 = [0.0, 0.0, RING_HEIGHT + 0.2]; + /// The dots on the ball, spread evenly over it. + pub(crate) const DOTS: usize = 90; + /// The scroll ring round the ball, inside and outside, and its notches. + const RING: [f32; 2] = [0.58, 0.72]; + const RING_HEIGHT: f32 = BASE_HEIGHT + 0.05; + const NOTCHES: usize = 24; + + /// A trackball: unlike a cap it doesn't spring back, so the ball's and the scroll ring's + /// turns add up as they move. + #[derive(Clone, Copy, Debug)] + pub(crate) struct Ball { + /// Columns of the ball's rotation. + pub(super) turn: [P3; 3], + /// How far the scroll ring has turned, in radians, clockwise seen from above. + pub(super) wheel: f32, + } + + impl Ball { + pub(crate) fn new() -> Self { + Self { turn: rotate_z(0.0), wheel: 0.0 } + } + + /// Moves the ball for `seconds` at `rate` (turns per second at full deflection): rolled + /// away from the user, rolled to the right, spun clockwise (seen from above), and the + /// scroll ring turned clockwise. + pub(crate) fn roll(&mut self, rate: [f32; 4], seconds: f32) { + let [away, right, clockwise, wheel] = rate.map(|v| if v.is_finite() { v.clamp(-3.0, 3.0) } else { 0.0 }); + let seconds = if seconds.is_finite() { seconds.clamp(0.0, 0.1) } else { 0.0 }; + let turn = std::f32::consts::TAU * seconds; + // Rolling away turns the top away (about right); rolling right turns it right (about away). + let step = apply_all(&rotate_z(-clockwise * turn), apply_all(&rotate_y(right * turn), rotate_x(away * turn))); + self.turn = orthonormal(apply_all(&step, self.turn)); + self.wheel = (self.wheel + wheel * turn) % std::f32::consts::TAU; + } + + /// Where dot `i` of the ball is now, as a direction from its centre. + pub(crate) fn dot(&self, i: usize) -> P3 { + // A Fibonacci sphere: even spacing without poles that crowd. + let k = i as f32 + 0.5; + let up = 1.0 - 2.0 * k / DOTS as f32; + let round = (1.0 - up * up).max(0.0).sqrt(); + let (sin, cos) = (k * std::f32::consts::PI * (3.0 - 5.0_f32.sqrt())).sin_cos(); + apply(&self.turn, [round * cos, round * sin, up]) + } + } + + /// Draws a trackball in `rect`: the ball in its scroll ring on the same base as the cap. The + /// ball's dots slide as it rolls and spins and light while `rolling`; the ring's notches turn + /// with it and light while `scrolling`. + pub(crate) fn paint(ui: &egui::Ui, rect: egui::Rect, ball: &Ball, rolling: bool, scrolling: bool) { + let painter = ui.painter_at(rect); + let visuals = ui.visuals(); + let (scale, at) = view(rect); + let (faint, accent) = (crate::theme::Tokens::get().text_dim, visuals.selection.stroke.color); + let outline = Stroke::new(1.0, faint); + let flat = |radius: f32, height: f32| ring(radius, height).into_iter().map(at).collect::>(); + let base: Vec = ring(BASE_RADIUS, 0.0).into_iter().chain(ring(BASE_RADIUS, BASE_HEIGHT)).map(at).collect(); + painter.add(Shape::convex_polygon(hull(base), visuals.widgets.noninteractive.bg_stroke.color, outline)); + painter.add(Shape::convex_polygon(flat(BASE_RADIUS, BASE_HEIGHT), visuals.widgets.inactive.bg_fill, outline)); + + // The scroll ring: raised a little off the base, its notches turned by the wheel. + let [inner, outer] = RING; + let raised: Vec = ring(outer, BASE_HEIGHT).into_iter().chain(ring(outer, RING_HEIGHT)).map(at).collect(); + painter.add(Shape::convex_polygon(hull(raised), visuals.widgets.active.bg_fill, outline)); + painter.add(Shape::convex_polygon(flat(outer, RING_HEIGHT), visuals.widgets.hovered.bg_fill, outline)); + let notch = Stroke::new(if scrolling { 2.0 } else { 1.0 }, if scrolling { accent } else { faint }); + let (ring_top, ring_side) = (visuals.widgets.hovered.bg_fill, visuals.widgets.active.bg_fill); + // A band between two circles (radius, height), from ring point `from` to `to` (of 48). + let band = |from: usize, to: usize, [ra, ha]: [f32; 2], [rb, hb]: [f32; 2], colour| { + let mut mesh = egui::Mesh::default(); + for i in from..=to { + let (sin, cos) = (i as f32 / 48.0 * std::f32::consts::TAU).sin_cos(); + mesh.colored_vertex(at([ra * cos, ra * sin, ha]), colour); + mesh.colored_vertex(at([rb * cos, rb * sin, hb]), colour); + } + for k in 0..(to - from) as u32 { + mesh.add_triangle(2 * k, 2 * k + 1, 2 * k + 2); + mesh.add_triangle(2 * k + 1, 2 * k + 3, 2 * k + 2); + } + painter.add(Shape::mesh(mesh)); + }; + // Half the ring's top, from ring point `from` to `to`, with its notches. + let half = |from: usize, to: usize, front: bool| { + band(from, to, [inner, RING_HEIGHT], [outer, RING_HEIGHT], ring_top); + for i in 0..NOTCHES { + let (sin, cos) = (i as f32 / NOTCHES as f32 * std::f32::consts::TAU - ball.wheel).sin_cos(); + if (sin < 0.0) == front { + let (a, b) = (inner + 0.03, outer - 0.03); + painter.line_segment([at([a * cos, a * sin, RING_HEIGHT]), at([b * cos, b * sin, RING_HEIGHT])], notch); + } + } + }; + // The far half and the hole go behind the ball, the near half in front of it, so the + // ball sits down in the ring. + half(0, 24, false); + painter.add(Shape::closed_line(flat(outer, RING_HEIGHT), outline)); + painter.add(Shape::convex_polygon(flat(inner, RING_HEIGHT), visuals.extreme_bg_color, outline)); + + // Seen straight along any line, a sphere is a circle. + painter.circle(at(CENTRE), RADIUS * scale, visuals.widgets.active.bg_fill, outline); + let colour = if rolling { accent } else { faint }; + for i in 0..DOTS { + let d = ball.dot(i); + // Only the near half shows; dots shrink and fade toward the edge, which reads as round. + let facing = dot(d, eye()); + if facing <= 0.05 { + continue; + } + let p = at(add(CENTRE, scaled(d, RADIUS))); + painter.circle_filled(p, scale * 0.045 * (0.35 + 0.65 * facing), colour.gamma_multiply(0.35 + 0.65 * facing)); + } + // Everything nearer than the ball, base and ring, over the ball's lower part. + let (base_side, base_top) = (visuals.widgets.noninteractive.bg_stroke.color, visuals.widgets.inactive.bg_fill); + band(24, 48, [BASE_RADIUS, 0.0], [BASE_RADIUS, BASE_HEIGHT], base_side); + band(24, 48, [outer, BASE_HEIGHT], [BASE_RADIUS, BASE_HEIGHT], base_top); + band(24, 48, [outer, BASE_HEIGHT], [outer, RING_HEIGHT], ring_side); + half(24, 48, true); + let edges = [[inner, RING_HEIGHT], [outer, RING_HEIGHT], [outer, BASE_HEIGHT], [BASE_RADIUS, BASE_HEIGHT], [BASE_RADIUS, 0.0]]; + for [radius, height] in edges { + let edge = ring(radius, height); + painter.add(Shape::line(edge.iter().skip(24).chain(edge.first()).map(|&p| at(p)).collect(), outline)); + } + } + + /// `a` after `b`, as rotations. + fn apply_all(a: &[P3; 3], b: [P3; 3]) -> [P3; 3] { + b.map(|column| apply(a, column)) + } + + /// `m` put back to a pure rotation, so many small turns don't skew or shrink it. + fn orthonormal(m: [P3; 3]) -> [P3; 3] { + let unit = |v: P3| { + let length = dot(v, v).sqrt(); + if length > 1e-6 && length.is_finite() { scaled(v, 1.0 / length) } else { [0.0; 3] } + }; + let x = unit(m[0]); + let y = unit(add(m[1], scaled(x, -dot(m[1], x)))); + let z = [x[1] * y[2] - x[2] * y[1], x[2] * y[0] - x[0] * y[2], x[0] * y[1] - x[1] * y[0]]; + if dot(z, z) < 0.5 { rotate_z(0.0) } else { [x, y, z] } + } +} + +/// The convex hull of `points`, in order round it (Andrew's monotone chain). +fn hull(mut points: Vec) -> Vec { + points.sort_by(|a, b| a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y))); + let cross = |o: Pos2, a: Pos2, b: Pos2| (a - o).x * (b - o).y - (a - o).y * (b - o).x; + let mut out: Vec = Vec::with_capacity(points.len() + 1); + for pass in [false, true] { + let start = out.len(); + let ordered: Box> = if pass { Box::new(points.iter().rev()) } else { Box::new(points.iter()) }; + for &p in ordered { + while out.len() >= start + 2 && cross(out[out.len() - 2], out[out.len() - 1], p) <= 0.0 { + out.pop(); + } + out.push(p); + } + out.pop(); + } + out +} + +fn unit(i: usize) -> P3 { + let mut v = [0.0; 3]; + v[i] = 1.0; + v +} + +fn apply(columns: &[P3; 3], p: P3) -> P3 { + [0, 1, 2].map(|row| columns[0][row] * p[0] + columns[1][row] * p[1] + columns[2][row] * p[2]) +} + +fn add(a: P3, b: P3) -> P3 { + [a[0] + b[0], a[1] + b[1], a[2] + b[2]] +} + +fn scaled(a: P3, k: f32) -> P3 { + a.map(|v| v * k) +} + +fn dot(a: P3, b: P3) -> f32 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} + +/// Turns about right: positive takes up toward away. +fn rotate_x(angle: f32) -> [P3; 3] { + let (sin, cos) = angle.sin_cos(); + [[1.0, 0.0, 0.0], [0.0, cos, -sin], [0.0, sin, cos]] +} + +/// Turns about away: positive takes up toward right. +fn rotate_y(angle: f32) -> [P3; 3] { + let (sin, cos) = angle.sin_cos(); + [[cos, 0.0, -sin], [0.0, 1.0, 0.0], [sin, 0.0, cos]] +} + +/// Turns about up: positive is anticlockwise seen from above. +fn rotate_z(angle: f32) -> [P3; 3] { + let (sin, cos) = angle.sin_cos(); + [[cos, sin, 0.0], [-sin, cos, 0.0], [0.0, 0.0, 1.0]] +} diff --git a/crates/ui-egui/src/space_mouse_model_tests.rs b/crates/ui-egui/src/space_mouse_model_tests.rs new file mode 100644 index 0000000000..b3f120df24 --- /dev/null +++ b/crates/ui-egui/src/space_mouse_model_tests.rs @@ -0,0 +1,259 @@ +use super::ball::{Ball, DOTS}; +use super::*; + +fn reading(axis: usize, value: f32) -> [f32; 6] { + let mut travel = [0.0; 6]; + travel[axis] = value; + travel +} + +/// Where a point on the cap lands on screen for a reading. +fn on_screen(travel: [f32; 6], p: P3) -> [f32; 2] { + project(Pose::new(travel).place(p)) +} + +const TOP_CENTRE: P3 = [0.0, 0.0, CAP_HEIGHT]; +const FRONT: P3 = [0.0, -CAP_RADIUS, CAP_HEIGHT / 2.0]; + +#[test] +fn the_cap_moves_the_way_the_reading_says() { + let rest = on_screen([0.0; 6], TOP_CENTRE); + // Slid right: right on screen. + assert!(on_screen(reading(0, 1.0), TOP_CENTRE)[0] > rest[0]); + // Pressed down: lower on screen; pulled up: higher. + assert!(on_screen(reading(2, 1.0), TOP_CENTRE)[1] > rest[1]); + assert!(on_screen(reading(2, -1.0), TOP_CENTRE)[1] < rest[1]); + // Pulled toward the user: nearer, so lower on screen. + assert!(Pose::new(reading(1, 1.0)).place(TOP_CENTRE)[1] < 0.0); +} + +#[test] +fn tilts_and_twists_turn_the_cap_the_right_way() { + let axis = |travel| apply(&Pose::new(travel).turn, [0.0, 0.0, 1.0]); + assert!((axis([0.0; 6])[2] - 1.0).abs() < 1e-6); + // +Rx: the top leans toward the user (the near edge down); tilted left: to the left. + assert!(axis(reading(3, 1.0))[1] < -0.1); + assert!(axis(reading(4, 1.0))[0] < -0.1); + // Twisted clockwise (from above): the groove on the front swings to the user's left. + let rest = Pose::new([0.0; 6]).place(FRONT); + let twisted = Pose::new(reading(5, 1.0)).place(FRONT); + assert!(twisted[0] < rest[0] - 0.1); + assert!((twisted[2] - rest[2]).abs() < 1e-6, "a twist alone keeps the cap's height"); +} + +#[test] +fn hostile_readings_stay_finite() { + for value in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, 1e30] { + let pose = Pose::new([value; 6]); + assert!(pose.place(TOP_CENTRE).iter().all(|v| v.is_finite())); + } +} + +#[test] +fn the_hull_keeps_only_the_outline() { + let square = [(0.0, 0.0), (2.0, 0.0), (2.0, 2.0), (0.0, 2.0), (1.0, 1.0), (1.0, 0.0)]; + let hull = hull(square.iter().map(|&(x, y)| Pos2::new(x, y)).collect()); + assert_eq!(hull.len(), 4); + assert!(!hull.contains(&Pos2::new(1.0, 1.0))); + assert!(super::hull(Vec::new()).is_empty()); +} + +#[test] +fn the_ball_rolls_the_way_it_is_pushed() { + let top_after = |rate: [f32; 3]| { + let mut ball = Ball::new(); + // A quarter turn, in small steps as frames give it. + for _ in 0..25 { + ball.roll([rate[0], rate[1], rate[2], 0.0], 0.01); + } + apply(&ball.turn, [0.0, 0.0, 1.0]) + }; + assert!(top_after([1.0, 0.0, 0.0])[1] > 0.9, "rolled away: the top goes away"); + assert!(top_after([0.0, 1.0, 0.0])[0] > 0.9, "rolled right: the top goes right"); + let mut ball = Ball::new(); + for _ in 0..25 { + ball.roll([0.0, 0.0, 1.0, 0.0], 0.01); + } + // Spun clockwise from above: what faced the user turns to their left. + assert!(apply(&ball.turn, [0.0, -1.0, 0.0])[0] < -0.9); +} + +#[test] +fn the_ball_stays_a_rotation() { + let mut ball = Ball::new(); + for i in 0..20_000 { + ball.roll([0.7, -0.3, (i % 7) as f32 * 0.2, 0.5], 0.016); + } + for (i, a) in ball.turn.iter().enumerate() { + for (j, b) in ball.turn.iter().enumerate() { + let expected = if i == j { 1.0 } else { 0.0 }; + assert!((dot(*a, *b) - expected).abs() < 1e-4); + } + } + for value in [f32::NAN, f32::INFINITY, -1e30] { + ball.roll([value; 4], value); + assert!(ball.turn.iter().flatten().all(|v| v.is_finite()) && ball.wheel.is_finite()); + assert!((0..DOTS).all(|i| ball.dot(i).iter().all(|v| v.is_finite()))); + } +} + +#[test] +fn the_scroll_ring_turns_and_keeps_its_turn() { + let mut ball = Ball::new(); + for _ in 0..10 { + ball.roll([0.0, 0.0, 0.0, 1.0], 0.01); + } + let turned = ball.wheel; + assert!((turned - std::f32::consts::TAU * 0.1).abs() < 1e-4); + // Let go: a scroll ring stays where it was turned to, and the ball doesn't move with it. + ball.roll([0.0; 4], 0.01); + assert_eq!(ball.wheel, turned); + assert!((apply(&ball.turn, [0.0, 0.0, 1.0])[2] - 1.0).abs() < 1e-6); +} + +#[test] +fn each_step_starts_at_rest_and_eases_out_to_full() { + let [out, hold, back, rest] = DEMO_SECONDS; + assert_eq!(demo_swing(0.0), 0.0); + assert!((demo_swing(out) - 1.0).abs() < 1e-6); + assert_eq!(demo_swing(out + hold / 2.0), 1.0); + assert_eq!(demo_swing(out + hold + back + rest / 2.0), 0.0); + // Gently: it creeps away from rest rather than jumping, and never goes back on itself on the way out. + assert!(demo_swing(out * 0.05) < 0.01); + let samples: Vec = (0..=100).map(|i| demo_swing(out * i as f32 / 100.0)).collect(); + assert!(samples.windows(2).all(|w| w[1] >= w[0])); + // Again from rest after a full showing; nothing odd for odd times. + assert_eq!(demo_swing(out + hold + back + rest), 0.0); + for seconds in [f32::NAN, f32::INFINITY, -5.0] { + assert_eq!(demo_swing(seconds), 0.0); + } +} + +#[test] +fn the_corner_picture_goes_where_it_is_told_and_clears_the_bars() { + use crate::prefs::SpaceMouseGhost::{LeftViewport as Current, LeftWindow as Left, RightViewport as Right}; + let view = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(800.0, 600.0)); + let size = egui::vec2(160.0, 120.0); + // Free: lower left for Current and Left, lower right for Right. + assert_eq!(corner_rect(view, Current, &[]).left_bottom(), egui::pos2(12.0, 588.0)); + assert_eq!(corner_rect(view, Left, &[]).left_bottom(), egui::pos2(12.0, 588.0)); + assert_eq!(corner_rect(view, Right, &[]).right_bottom(), egui::pos2(788.0, 588.0)); + // The History bar along the bottom pushes each one up to clear it. + let history = egui::Rect::from_min_max(egui::pos2(0.0, 556.0), egui::pos2(800.0, 596.0)); + for position in [Current, Left, Right] { + let rect = corner_rect(view, position, &[history]); + assert_eq!(rect.size(), size); + assert!(!rect.intersects(history) && rect.bottom() <= history.top() - 12.0 + f32::EPSILON, "{position:?}"); + } + // A bar that does not reach the picture leaves it where it was. + let elsewhere = egui::Rect::from_min_max(egui::pos2(300.0, 556.0), egui::pos2(500.0, 596.0)); + for position in [Current, Left, Right] { + assert_eq!(corner_rect(view, position, &[elsewhere]), corner_rect(view, position, &[])); + } + // The navigation bar in the middle of a narrow view is not covered either, nor is anything else stacked. + let narrow = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(420.0, 600.0)); + let navigation = egui::Rect::from_min_max(egui::pos2(100.0, 520.0), egui::pos2(330.0, 560.0)); + let history = egui::Rect::from_min_max(egui::pos2(0.0, 566.0), egui::pos2(420.0, 600.0)); + for position in [Left, Right] { + let rect = corner_rect(narrow, position, &[history, navigation]); + assert!(narrow.contains_rect(rect), "{position:?} stays inside the view"); + assert!(!rect.intersects(history) && !rect.intersects(navigation), "{position:?} clears both bars: {rect:?}"); + } + // A view narrower than the picture keeps it at the left edge rather than outside. + let tiny = egui::Rect::from_min_size(egui::pos2(50.0, 0.0), egui::vec2(100.0, 400.0)); + assert!(corner_rect(tiny, Right, &[]).left() >= tiny.left()); + // An empty bar is no bar. + assert_eq!(corner_rect(view, Left, &[egui::Rect::NOTHING]), corner_rect(view, Left, &[])); +} + +#[test] +fn stacked_bars_in_a_short_view_do_not_push_the_picture_out_of_it() { + use crate::prefs::SpaceMouseGhost::{LeftViewport, LeftWindow, RightViewport}; + let view = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(500.0, 220.0)); + let history = egui::Rect::from_min_max(egui::pos2(0.0, 180.0), egui::pos2(500.0, 220.0)); + let navigation = egui::Rect::from_min_max(egui::pos2(0.0, 130.0), egui::pos2(500.0, 170.0)); + let chip = egui::Rect::from_min_max(egui::pos2(0.0, 80.0), egui::pos2(500.0, 120.0)); + for position in [LeftViewport, RightViewport, LeftWindow] { + let rect = corner_rect(view, position, &[history, navigation, chip]); + assert!(rect.top() >= view.top() && view.contains_rect(rect), "{position:?}: {rect:?} is outside {view:?}"); + } +} + +/// How much one frame of each picture costs the painter: shapes and tessellated vertices. A ceiling +/// each, so a picture cannot quietly grow expensive (the calibration window once ran a core flat out). +#[test] +fn the_pictures_cost_a_bounded_amount_to_draw_per_frame() { + let ctx = crate::fonts::test_context(); + let calibration = Calibration { + dead_zone_share: 0.0, + kind: Kind::ReturnToCenter, + axes: std::array::from_fn(|source| solidcraft_space_mouse::AxisCalibration { + source, + reversed: false, + rest: 0, + dead_zone: 0, + full: [350; 2], + }), + }; + let cost = |draw_it: &mut dyn FnMut(&mut egui::Ui)| { + let mut output = ctx.run_ui( + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))), ..Default::default() }, + |ui| draw_it(ui), + ); + output.textures_delta.clear(); + let shapes = output.shapes.len(); + let vertices: usize = ctx + .tessellate(output.shapes, 1.0) + .iter() + .filter_map(|p| if let egui::epaint::Primitive::Mesh(m) = &p.primitive { Some(m.vertices.len()) } else { None }) + .sum(); + (shapes, vertices) + }; + let cap = cost(&mut |ui| live(ui, &calibration, [60, -40, 100, 10, -20, 30])); + let ball = Calibration { kind: Kind::Trackball, ..calibration }; + let rolling = cost(&mut |ui| live(ui, &ball, [60, -40, 100, 10, -20, 30])); + let demo_cost = cost(&mut |ui| demo(ui, solidcraft_space_mouse::STEPS[0], Kind::ReturnToCenter)); + eprintln!("PICTURE COST cap (shapes, vertices) {cap:?} trackball {rolling:?} demo {demo_cost:?}"); + for (name, (shapes, vertices)) in [("cap", cap), ("trackball", rolling), ("demo", demo_cost)] { + assert!(shapes <= CEILING_SHAPES && vertices <= CEILING_VERTICES, "{name}: {shapes} shapes, {vertices} vertices"); + } +} + +/// Measured at 45 shapes and 1,374 vertices for the cap, 82 and 2,534 for the trackball. +const CEILING_SHAPES: usize = 150; +const CEILING_VERTICES: usize = 4_000; + +#[test] +fn the_demo_repaints_at_20_fps_only_while_it_moves() { + use std::time::Duration; + let [out, hold, back, rest] = DEMO_SECONDS; + assert_eq!(demo_repaint_after(out / 2.0, true), Duration::from_millis(50), "moving out"); + assert_eq!(demo_repaint_after(out + hold / 2.0, true), Duration::from_millis(250), "holding"); + assert_eq!(demo_repaint_after(out + hold + back / 2.0, true), Duration::from_millis(50), "moving back"); + assert_eq!(demo_repaint_after(out + hold + back + rest / 2.0 - 0.1, true), Duration::from_millis(250), "resting"); + assert_eq!(demo_repaint_after(out / 2.0, false), Duration::from_millis(500), "not in use"); +} + +/// The demo asks for its own pace and nothing shorter, for a ball as for a cap: 50 ms while it moves with the +/// window in use, and 500 ms when the window is not (the ball's own 16 ms request used to win both). +#[test] +fn the_demo_asks_for_its_own_repaint_pace_for_a_ball_and_a_cap() { + let step = Step { axis: 1, positive: true }; + for kind in [Kind::Trackball, Kind::ReturnToCenter] { + for (focused, expected) in [(true, std::time::Duration::from_millis(50)), (false, std::time::Duration::from_millis(500))] { + let ctx = crate::fonts::test_context(); + let mut delay = None; + for time in [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6] { + let mut output = ctx.run_ui(egui::RawInput { time: Some(time), focused, ..Default::default() }, |ui| demo(ui, step, kind)); + output.textures_delta.clear(); + delay = output.viewport_output.get(&egui::ViewportId::ROOT).map(|viewport| viewport.repaint_delay); + } + // egui takes a frame's worth off the delay it reports, so allow for that. + let delay = delay.expect("a repaint delay"); + assert!( + delay <= expected && delay + std::time::Duration::from_millis(20) >= expected, + "{kind:?}, focused {focused}: {delay:?}, wanted about {expected:?}" + ); + } + } +} diff --git a/crates/ui-egui/src/space_mouse_offer.rs b/crates/ui-egui/src/space_mouse_offer.rs new file mode 100644 index 0000000000..6f2ac9e697 --- /dev/null +++ b/crates/ui-egui/src/space_mouse_offer.rs @@ -0,0 +1,904 @@ +//! The offer to calibrate a 3D mouse that has no calibration yet: people don't know calibration +//! exists, and it is what fixes swapped or reversed axes. It shows once per device, as one of the +//! shared toasts (`viewport::transient_messages`), until the user calibrates or closes it. + +use std::collections::BTreeSet; + +use crate::SolveApp; + +/// Whether to offer calibration for `device`: 3D mouse navigation is on, there is a device, it +/// has no calibration saved, the offer has not been made (and dismissed) for it, and the user has +/// not asked never to see it (`never`, one flag for every device, not kept per device). The 3Dconnexion +/// driver (macOS, key `3dxware`) already applies its own settings, so calibration isn't offered +/// for it. +pub fn should_offer(device: Option<&str>, calibrated: bool, offered: &BTreeSet, enabled: bool, never: bool) -> bool { + let Some(device) = device else { return false }; + enabled && !never && !calibrated && !offered.contains(device) && !device.starts_with("3dxware") +} + +/// Whether the card offers to swap to the direct (hidraw) reader: the user chose spacenavd, it is +/// what is being read, and a direct device is there to read instead. Under Automatic the device is +/// read whenever it opens, so spacenavd being read with a device there means the device does not open +/// (and Direct would fall back the same way): a swap would do nothing, so it is not offered. +pub fn swap_offered(device: &str, drivers: Option, driver: crate::prefs::SpaceMouseDriver) -> bool { + device == "spacenavd" && driver == crate::prefs::SpaceMouseDriver::Spacenavd && drivers.is_some_and(|present| present.direct) +} + +/// How long the notice stays when nobody is pointing at it: long enough to read it, find the +/// mouse and decide, which 15 seconds was too short for. +const SHOW_SECONDS: f64 = 30.0; + +/// A longer gap than this between two looks at the offer means it was not on screen meanwhile. +const AWAY_SECONDS: f64 = 2.0; + +/// Whether the notice, first shown or last pointed at `since`, has been up long enough at `now`. +pub fn expired(since: f64, now: f64) -> bool { + now - since >= SHOW_SECONDS +} + +/// What the offer's toast can do when clicked. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Action { + /// Opens Preferences at Navigation and starts the calibration window. + Calibrate, + /// Records this device as offered. + Close, + /// Records that no offer is wanted any more. + Never, + /// Reads the device directly instead of through spacenavd. + Swap, +} + +impl Action { + /// What the button is for, which sets its look and place on the toast. + pub(crate) fn kind(self) -> crate::viewport::ToastKind { + use crate::viewport::ToastKind; + match self { + Self::Calibrate => ToastKind::Primary, + Self::Swap => ToastKind::Secondary, + Self::Close | Self::Never => ToastKind::Dismiss, + } + } + + /// The button's text, as a message id. + pub fn label(self) -> &'static str { + match self { + Self::Calibrate => "ui.3d_mouse_calibrate", + Self::Close => "ui.close", + Self::Never => "ui.3d_mouse_offer_never", + Self::Swap => "ui.3d_mouse_offer_swap", + } + } +} + +/// An offer to show now: its device and its actions (the text is `ui.3d_mouse_offer`). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Offer { + pub device: String, + pub actions: Vec, +} + +/// The offer due this frame, if any, for the shared toast (`viewport::transient_messages`) to draw. +/// It is due when `should_offer` says so, the window has focus, no picker or load has the app, no +/// issue report holds CAD input, and neither Preferences nor the calibration window is open. It goes +/// after `SHOW_SECONDS` unless the pointer is on it (`keep`; then only for this run: it is offered +/// again next time until closed or calibrated). +/// +/// A status or computing toast appearing while it is up, or already up, hides it for the run (nothing +/// is recorded). +/// +/// Everything it reads and writes outside its own state, so a reviewer can check it: +/// - Reads: the device key, saved calibrations, the offered set, the never switch and the 3D mouse +/// enable switch (`app.space_mouse.device`, `app.preferences`); the readers present +/// (`space_mouse.drivers`); `ctx.input` focus, time and pointer position; `app.status`, `computing_since`, +/// `file_picker_busy`, `file_load.busy`, `prefs_window.open`, `space_mouse.calibrating`, +/// `issue_report::cad_input_blocked`. +/// - Writes, only its own: `space_mouse.offer_since`, `space_mouse.offer_hidden`; +/// `space_mouse_offered` and `space_mouse_offer_never` (preferences) when the user clicks Close, +/// Calibrate or Never. +/// - Calibrate opens Preferences at Navigation and starts the calibration window +/// (`prefs::open_at`, `space_mouse_calibration::start`), exactly as Preferences does. Swap (offered only +/// when the user chose spacenavd and a device is there) sets `space_mouse_driver` to Direct, the same +/// setting Preferences does. +/// - It draws nothing and creates no layer: the toast is the shared one. It never touches +/// `ctx.memory`, focus, layer order, keyboard events, dialogs, tools, the selection or the document. +pub fn due(app: &mut SolveApp, ctx: &egui::Context) -> Option { + let device = offerable(app)?; + if input_blocked(app, ctx) { + return None; + } + // Once the user is working, a status or computing toast says so: the offer goes for this run (not + // recorded, so it can come back next start), and does not show while one is up. + if status_up(app) { + app.space_mouse.offer_hidden.insert(device); + app.space_mouse.offer_since = None; + return None; + } + let now = ctx.input(|i| i.time); + // Time away does not count as time shown: after a gap (the window was in the background, Preferences + // or the start page was up, the toast could not be drawn) the 30 seconds start again. + let away = now - app.space_mouse.offer_seen > AWAY_SECONDS; + app.space_mouse.offer_seen = now; + let since = match &app.space_mouse.offer_since { + Some((shown, since)) if *shown == device && !away => *since, + _ => now, + }; + app.space_mouse.offer_since = Some((device.clone(), since)); + if expired(since, now) { + app.space_mouse.offer_hidden.insert(device); + return None; + } + let mut actions = vec![Action::Calibrate]; + if swap_offered(&device, app.space_mouse.drivers, app.preferences.space_mouse_driver) { + actions.push(Action::Swap); + } + actions.extend([Action::Close, Action::Never]); + Some(Offer { device, actions }) +} + +/// The toast was drawn at `rect`: a pointer on it keeps it from expiring. +pub fn keep(app: &mut SolveApp, ctx: &egui::Context, rect: egui::Rect) { + if ctx.input(|i| i.pointer.hover_pos()).is_some_and(|pos| rect.contains(pos)) { + let now = ctx.input(|i| i.time); + app.space_mouse.offer_since = app.space_mouse.offer_since.take().map(|(device, _)| (device, now)); + } +} + +/// The device the offer is for now: the current device when it should be offered one and the offer +/// has not been hidden for the run. No side effects. +pub(crate) fn offerable(app: &SolveApp) -> Option { + let calibrated = app.space_mouse.device.as_ref().is_some_and(|device| app.preferences.space_mouse_calibrations.contains_key(device)); + app.space_mouse.device.clone().filter(|device| { + should_offer( + Some(device), + calibrated, + &app.preferences.space_mouse_offered, + app.preferences.space_mouse_enabled, + app.preferences.space_mouse_offer_never, + ) && !app.space_mouse.offer_hidden.contains(device) + }) +} + +/// Whether something else has the input or the screen: the window is in the background, a file is +/// being picked or loaded, an issue report takes input, Preferences or the calibration window is up, or +/// the start page is. No side effects. +fn input_blocked(app: &SolveApp, ctx: &egui::Context) -> bool { + !ctx.input(|i| i.focused) + || app.file_picker_busy() + || app.file_load.busy() + || crate::issue_report::cad_input_blocked() + || app.prefs_window.open + || app.space_mouse.calibrating.is_some() + || app.home.open +} + +/// A status or computing toast is up. No side effects. +fn status_up(app: &SolveApp) -> bool { + app.status.is_some() || app.computing_since().is_some() +} + +/// Whether `action` of `offer` may be taken now, with no side effects: the offer is still for the +/// current device and still due (not dismissed, not hidden, navigation on), nothing blocks input, no +/// status toast has taken its place, and the action is still on offer (Swap needs `swap_offered`). +/// `due` and `act` both stand on the same pieces. +pub fn eligible(app: &SolveApp, ctx: &egui::Context, offer: &Offer, action: Action) -> bool { + offerable(app).as_deref() == Some(offer.device.as_str()) + && !input_blocked(app, ctx) + && !status_up(app) + && match action { + Action::Swap => swap_offered(&offer.device, app.space_mouse.drivers, app.preferences.space_mouse_driver), + Action::Calibrate | Action::Close | Action::Never => true, + } +} + +/// The user clicked `action` on the toast of `offer`. Taken only if it is still eligible (`eligible`): a +/// click that arrives after the device changed, after something took the input, or for an action no +/// longer on offer does nothing, records nothing for the old device and changes neither the Never flag +/// nor the driver setting. +pub fn act(app: &mut SolveApp, ctx: &egui::Context, offer: &Offer, action: Action) { + if !eligible(app, ctx, offer, action) { + return; + } + match action { + Action::Calibrate | Action::Close => { + app.space_mouse.offer_since = None; + app.preferences.space_mouse_offered.insert(offer.device.clone()); + if action == Action::Calibrate { + // The calibration window lives with Preferences, at Navigation. + crate::prefs::open_at(app, crate::prefs::PAGE_NAVIGATION); + crate::space_mouse_calibration::start(app); + } + } + Action::Never => { + app.preferences.space_mouse_offer_never = true; + app.space_mouse.offer_since = None; + } + Action::Swap => { + // The same setting as Preferences, read live by the reader. The device is not marked + // offered: read directly it has its own key, and it may still want a calibration. + app.preferences.space_mouse_driver = crate::prefs::SpaceMouseDriver::Direct; + app.space_mouse.offer_since = None; + app.space_mouse.offer_hidden.insert(offer.device.clone()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_offer_is_made_once_to_an_uncalibrated_device_with_navigation_on() { + let should_offer = |device, calibrated, offered: &BTreeSet, enabled| should_offer(device, calibrated, offered, enabled, false); + let none = BTreeSet::new(); + let seen: BTreeSet = ["hid 046d:c62b".to_string()].into(); + for device in [Some("hid 046d:c62b"), Some("spacenavd"), Some("web 256f:c63a"), None] { + for calibrated in [false, true] { + for offered in [&none, &seen] { + for enabled in [false, true] { + let expected = device.is_some() && !calibrated && enabled && !device.is_some_and(|d| offered.contains(d)); + assert_eq!(should_offer(device, calibrated, offered, enabled), expected, "{device:?} {calibrated} {offered:?} {enabled}"); + } + } + } + } + // The driver on macOS has its own settings; there is nothing to calibrate. + assert!(!should_offer(Some("3dxware"), false, &none, true)); + } + + #[test] + fn never_show_again_stops_every_offer() { + let none = BTreeSet::new(); + assert!(should_offer(Some("hid 046d:c62b"), false, &none, true, false)); + for device in ["hid 046d:c62b", "spacenavd", "web 256f:c63a"] { + assert!(!should_offer(Some(device), false, &none, true, true), "{device}"); + } + } + + #[test] + fn the_swap_to_direct_is_offered_only_when_spacenavd_was_chosen_and_is_read_with_a_device_there() { + use crate::prefs::SpaceMouseDriver::{Automatic, Direct, Spacenavd}; + use crate::space_mouse::DriversPresent; + for device in ["spacenavd", "hid 046d:c62b", "hid 256f:c63a", "3dxware", "web 256f:c63a"] { + for drivers in [ + None, + Some(DriversPresent { spacenavd: true, direct: false, denied: false }), + Some(DriversPresent { spacenavd: true, direct: true, denied: false }), + Some(DriversPresent { spacenavd: false, direct: true, denied: false }), + ] { + for driver in [Automatic, Spacenavd, Direct] { + let expected = device == "spacenavd" && driver == Spacenavd && drivers.is_some_and(|d| d.direct); + assert_eq!(swap_offered(device, drivers, driver), expected, "{device} {drivers:?} {driver:?}"); + } + } + } + } + + fn press(h: &mut crate::scenario::Harness, widget: &str) { + h.frames(3); + h.step(&serde_json::json!({"widget": widget})).unwrap_or_else(|e| panic!("{widget}: {e}")); + h.frames(2); + } + + fn offered() -> crate::scenario::Harness { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.app.space_mouse.device = Some("hid 046d:c62b".into()); + h.frames(3); + h + } + + #[test] + fn the_notice_goes_after_thirty_seconds() { + assert!(!expired(100.0, 129.9) && expired(100.0, 130.0)); + } + + #[test] + fn closing_the_offer_records_the_device_so_it_does_not_come_back() { + let mut h = offered(); + assert!(crate::scenario::rect_of("toast:space_mouse_offer").is_some(), "the notice is drawn"); + press(&mut h, "Close"); + assert!(h.app.preferences.space_mouse_offered.contains("hid 046d:c62b")); + assert!(h.app.space_mouse.calibrating.is_none()); + h.frames(3); + assert!(h.app.preferences.space_mouse_offered.contains("hid 046d:c62b"), "gone, and it stays gone"); + } + + #[test] + fn calibrating_from_the_offer_opens_the_calibration_and_records_the_device() { + let mut h = offered(); + press(&mut h, "Calibrate…"); + h.frames(3); + assert!(h.app.space_mouse.calibrating.is_some() && h.app.prefs_window.open); + assert!(h.app.preferences.space_mouse_offered.contains("hid 046d:c62b")); + } + + #[test] + fn it_stays_away_from_a_calibrated_device_and_navigation_off() { + let axes = + std::array::from_fn(|source| solidcraft_space_mouse::AxisCalibration { source, reversed: false, rest: 0, dead_zone: 0, full: [350; 2] }); + let calibration = solidcraft_space_mouse::Calibration { dead_zone_share: 0.0, kind: solidcraft_space_mouse::Kind::ReturnToCenter, axes }; + for (enabled, calibrated) in [(false, false), (true, true)] { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.app.space_mouse.device = Some("hid 046d:c62b".into()); + h.app.preferences.space_mouse_enabled = enabled; + if calibrated { + h.app.preferences.space_mouse_calibrations.insert("hid 046d:c62b".into(), calibration); + } + h.frames(4); + assert!(h.app.space_mouse.offer_since.is_none(), "no notice for enabled {enabled}, calibrated {calibrated}"); + } + } +} + +#[cfg(test)] +mod preferences_tests { + /// File, then Preferences…, by clicks: the window must be open, drawn and on top (nothing of ours over it), for settings like a real + /// user with a 3D mouse (saved calibration, driver choice, picture in the view). + #[test] + fn preferences_open_and_draw_with_a_3d_mouse_users_settings() { + // An uncalibrated device (the notice card is up first), the calibrated one and none. + for device in [None, Some("hid 256f:c63a"), Some("spacenavd"), Some("hid 046d:c62b")] { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.app.load_prefs(include_str!("test_data/prefs_space_mouse_user.json")); + h.app.space_mouse.device = device.map(Into::into); + h.frames(4); + // The real way in: File menu, then its Preferences item, by clicks. + h.app.custom_titlebar = true; + h.frames(3); + h.step(&serde_json::json!({"click": {"handle": "appbar:file"}})).unwrap(); + h.frames(3); + h.step(&serde_json::json!({"widget": "Preferences…"})).unwrap_or_else(|e| panic!("{device:?}: {e}")); + h.frames(4); + let window = crate::scenario::rect_of("dialog:window"); + let centre = window.map(|r| r.center()); + let top = centre.and_then(|c| h.ctx.layer_id_at(c)); + assert!(top.is_some_and(|layer| layer.id == egui::Id::new("sc_prefs")), "{device:?}: the layer on top at the window centre is {top:?}"); + assert!(h.app.prefs_window.open, "{device:?}: still open"); + assert!(crate::scenario::rect_of("dialog:window").is_some_and(|r| r.is_positive()), "{device:?}: the window is drawn"); + } + } + + /// Preferences stays open while the calibration notice comes and goes around it. + #[test] + fn the_notice_never_closes_preferences() { + for (open_first, frames_before) in [(false, 0), (false, 40), (true, 0), (true, 90)] { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.app.custom_titlebar = true; + h.app.space_mouse.device = Some("hid 046d:c62b".into()); + h.frames(3); + if open_first { + crate::prefs::open_at(&mut h.app, crate::prefs::PAGE_NAVIGATION); + } + for _ in 0..frames_before { + h.frame(); + } + if !open_first && frames_before > 0 { + assert!(h.app.space_mouse.offer_since.is_some(), "the notice is up before Preferences opens"); + } + if !open_first { + h.step(&serde_json::json!({"click": {"handle": "appbar:file"}})).unwrap(); + h.frames(3); + h.step(&serde_json::json!({"widget": "Preferences…"})).unwrap(); + } + for frame in 0..200 { + h.frame(); + assert!(h.app.prefs_window.open, "open_first {open_first}, before {frames_before}: closed at frame {frame}"); + let window = crate::scenario::rect_of("dialog:window").expect("drawn"); + // The window sizes itself over its first frames. + if frame < 4 { + continue; + } + for f in [0.1_f32, 0.5, 0.9] { + for g in [0.1_f32, 0.5, 0.9] { + let at = window.min + egui::vec2(window.width() * f, window.height() * g); + let top = h.ctx.layer_id_at(at); + let order = h.ctx.memory(|m| m.areas().visible_layer_ids()); + assert!(top.is_some_and(|l| l.id == egui::Id::new("sc_prefs")), "frame {frame} at {at:?}: top is {top:?}; order {order:?}"); + } + } + } + } + } + + /// The genuine first run: default settings, the start page up, a 3D mouse that connects, then + /// File, Preferences… by clicks. It stays open, on top, for 240 frames, with and without a + /// device (so with and without the calibration notice). + #[test] + fn a_fresh_profile_opens_preferences_from_the_start_page_and_keeps_it() { + for device in [None, Some("hid 046d:c62b"), Some("spacenavd")] { + let mut h = crate::scenario::Harness::new(); + h.app.custom_titlebar = true; + h.app.home.open = true; + h.frames(3); + h.app.space_mouse.device = device.map(Into::into); + h.frames(30); + h.step(&serde_json::json!({"click": {"handle": "appbar:file"}})).unwrap(); + h.frames(3); + h.step(&serde_json::json!({"widget": "Preferences…"})).unwrap_or_else(|e| panic!("{device:?}: {e}")); + for frame in 0..240 { + h.frame(); + assert!(h.app.prefs_window.open, "{device:?}: closed at frame {frame}"); + if frame > 4 { + let window = crate::scenario::rect_of("dialog:window").expect("drawn"); + let top = h.ctx.layer_id_at(window.center()); + assert!(top.is_some_and(|l| l.id == egui::Id::new("sc_prefs")), "{device:?} frame {frame}: top is {top:?}"); + } + } + } + } + + /// Behind an issue report (CAD input blocked) the offer is not drawn, so it cannot be clicked. + #[test] + fn the_offer_cannot_be_used_while_an_issue_report_blocks_input() { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.app.space_mouse.device = Some("hid 046d:c62b".into()); + crate::issue_report::set_cad_input_blocked_for_test(true); + h.frames(6); + let hidden = h.app.space_mouse.offer_since.is_none() && crate::scenario::rect_of("toast:space_mouse_offer").is_none(); + crate::issue_report::set_cad_input_blocked_for_test(false); + assert!(hidden, "not drawn while input is blocked"); + assert!(h.app.space_mouse.calibrating.is_none() && h.app.preferences.space_mouse_offered.is_empty()); + // And it is there again once the report is gone. + h.frames(4); + assert!(h.app.space_mouse.offer_since.is_some()); + } + + /// Showing, expiring and closing the offer changes nothing outside its own state: the open + /// dialog, tool, selection, undo history, Preferences and calibration are as they were. + #[test] + fn showing_expiring_and_closing_the_offer_touch_no_other_window() { + let state = |h: &crate::scenario::Harness| { + ( + h.app.dialog.is_some(), + h.app.tool.is_some(), + h.app.session.selection.len(), + h.app.session.undo.len(), + h.app.prefs_window.open, + h.app.space_mouse.calibrating.is_some(), + h.app.keymap.open, + h.app.home.open, + ) + }; + for how in ["expire", "close"] { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.app.run("solid.box", serde_json::json!({"length": 10, "width": 10, "height": 10})).unwrap(); + h.app.start("solid.extrude"); + h.frames(3); + let before = state(&h); + h.app.space_mouse.device = Some("hid 046d:c62b".into()); + h.frames(4); + assert!(h.app.space_mouse.offer_since.is_some(), "{how}: the offer is up"); + assert_eq!(state(&h), before, "{how}: showing it changes nothing else"); + if how == "close" { + h.step(&serde_json::json!({"widget": "Close"})).unwrap(); + } else if let Some((device, _)) = h.app.space_mouse.offer_since.take() { + h.app.space_mouse.offer_since = Some((device, -1000.0)); + } + h.frames(4); + assert!(h.app.space_mouse.offer_since.is_none() || how == "expire"); + assert_eq!(state(&h), before, "{how}: going away changes nothing else"); + } + } + + fn spacenavd_harness() -> crate::scenario::Harness { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.app.space_mouse.device = Some("spacenavd".into()); + h.app.space_mouse.drivers = Some(crate::space_mouse::DriversPresent { spacenavd: true, direct: true, denied: false }); + // Spacenavd chosen by the user, so the card may offer the swap to the device. + h.app.preferences.space_mouse_driver = crate::prefs::SpaceMouseDriver::Spacenavd; + h.frames(4); + h + } + + #[test] + fn swapping_to_the_direct_driver_sets_it_and_leaves_the_offered_set_alone() { + let mut h = spacenavd_harness(); + assert!(h.app.space_mouse.offer_since.is_some(), "the card is up"); + h.step(&serde_json::json!({"widget": "Swap to Hidraw (Direct Driver)"})).unwrap(); + h.frames(3); + assert_eq!(h.app.preferences.space_mouse_driver, crate::prefs::SpaceMouseDriver::Direct); + assert!(h.app.preferences.space_mouse_offered.is_empty(), "the new source may still be offered"); + assert!(h.app.space_mouse.offer_since.is_none()); + // Without a direct device there is no such button. + let mut h = spacenavd_harness(); + h.app.space_mouse.drivers = Some(crate::space_mouse::DriversPresent { spacenavd: true, direct: false, denied: false }); + h.frames(3); + assert!(h.step(&serde_json::json!({"widget": "Swap to Hidraw (Direct Driver)"})).is_err()); + } + + #[test] + fn never_show_again_is_saved_and_stops_the_card() { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.app.space_mouse.device = Some("hid 046d:c62b".into()); + h.frames(4); + h.step(&serde_json::json!({"widget": "Never show again"})).unwrap(); + h.frames(4); + assert!(h.app.preferences.space_mouse_offer_never); + assert!(h.app.preferences.space_mouse_offered.is_empty(), "not just this device"); + assert!(h.app.space_mouse.offer_since.is_none()); + // Saved with the other settings, and no other device brings the card back. + let saved = serde_json::to_value(&h.app.preferences).unwrap(); + assert_eq!(saved["space_mouse_offer_never"], true); + h.app.space_mouse.device = Some("hid 256f:c63a".into()); + h.frames(4); + assert!(h.app.space_mouse.offer_since.is_none()); + } + + /// Frames of the harness are 1/60 s: this many make `seconds`. + fn run(h: &mut crate::scenario::Harness, seconds: f64) { + for _ in 0..(seconds * 60.0).round() as usize { + h.frame(); + } + } + + fn card_up(h: &crate::scenario::Harness) -> bool { + h.app.space_mouse.offer_since.is_some() && crate::scenario::rect_of("toast:space_mouse_offer").is_some() + } + + /// The offer stays its own 30 seconds, and time away (Preferences open) is not time shown. + #[test] + fn the_offer_stays_its_thirty_seconds_and_time_away_is_not_time_shown() { + let mut h = spacenavd_harness(); + run(&mut h, 2.0); + for second in 0..26 { + run(&mut h, 1.0); + assert!(card_up(&h), "second {second}: the offer is gone"); + } + // Over its own time it goes, for this run only: no record. + run(&mut h, 5.0); + assert!(!card_up(&h)); + assert!(h.app.preferences.space_mouse_offered.is_empty()); + // Time away: Preferences open for 45 s must not use the time up. + let mut h = spacenavd_harness(); + run(&mut h, 3.0); + assert!(card_up(&h)); + h.app.prefs_window.open = true; + run(&mut h, 45.0); + h.app.prefs_window.open = false; + run(&mut h, 3.0); + assert!(card_up(&h), "the offer waited for Preferences to close"); + run(&mut h, 25.0); + assert!(card_up(&h), "and has its 30 seconds again"); + } + + /// Once the user is working (a status toast), the offer goes for the run, and nothing is recorded. + #[test] + fn a_status_toast_dismisses_the_offer_for_the_run_without_a_record() { + let mut h = spacenavd_harness(); + run(&mut h, 3.0); + assert!(card_up(&h)); + h.app.status = Some(("Done".into(), crate::now_ms(), false)); + run(&mut h, 1.0); + assert!(!card_up(&h), "the offer went"); + assert!(h.app.preferences.space_mouse_offered.is_empty() && !h.app.preferences.space_mouse_offer_never, "nothing recorded"); + // And it does not come back when the status goes, nor with the key shown again. + h.app.status = None; + run(&mut h, 10.0); + assert!(!card_up(&h)); + // A status already up when the offer would first show keeps it away too. + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.app.status = Some(("Opened".into(), crate::now_ms(), false)); + h.app.space_mouse.device = Some("hid 046d:c62b".into()); + run(&mut h, 3.0); + assert!(!card_up(&h)); + assert!(h.app.preferences.space_mouse_offered.is_empty()); + } + + /// A status toast takes the offer's place for the run, and the 3D mouse picture in the view keeps + /// clear of whichever toast is up: the offer card, then the status that replaced it. + #[test] + fn the_picture_keeps_clear_of_the_toast_that_is_up_and_a_status_replaces_the_offer() { + use crate::prefs::SpaceMouseGhost::{LeftViewport, LeftWindow, RightViewport}; + // Wide, and narrow enough that the toast reaches across the corners the picture sits in. + for (position, width) in [LeftViewport, RightViewport, LeftWindow].into_iter().flat_map(|position| [(position, 1600), (position, 760)]) { + let mut h = spacenavd_harness(); + h.call("ui.resize", serde_json::json!({"width": width, "height": 700})); + h.app.preferences.space_mouse_in_view = true; + h.app.preferences.space_mouse_ghost_position = position; + run(&mut h, 3.0); + assert!(card_up(&h), "{position:?}"); + let rect = |name| crate::scenario::rect_of(name).unwrap_or_else(|| panic!("{position:?}: no {name}")); + let (card, picture) = (rect("toast:space_mouse_offer"), rect("space_mouse:corner")); + eprintln!("RECTS {width} {position:?} card {card:?} picture {picture:?}"); + assert!(!card.intersects(picture), "{position:?}: the picture {picture:?} overlaps the card {card:?}"); + h.app.status = Some(("Done".into(), crate::now_ms(), false)); + run(&mut h, 1.0); + assert!(!card_up(&h), "{position:?}: the offer went"); + assert!(crate::scenario::rect_of("toast:space_mouse_offer").is_none()); + let (toast, picture) = (rect("toast"), rect("space_mouse:corner")); + eprintln!("RECTS {width} {position:?} toast {toast:?} picture {picture:?}"); + assert!(!toast.intersects(picture), "{position:?}: the picture {picture:?} overlaps the status toast {toast:?}"); + } + } + + /// A click that lands after the device changed acts on the device now shown (the card is drawn + /// afresh for it) or on nothing: never on the device the card was first drawn for. + #[test] + fn a_click_after_the_device_changed_acts_on_the_device_now_shown_or_on_nothing() { + let (first, second) = ("spacenavd", "hid 256f:c63a"); + let mut h = spacenavd_harness(); + run(&mut h, 2.0); + assert!(card_up(&h)); + h.app.space_mouse.device = Some(second.into()); + h.step(&serde_json::json!({"widget": "Calibrate…"})).unwrap(); + h.frames(3); + // The card is drawn afresh for the new device in the frame of the click, which may or may not take the + // click; either way nothing is done for the device it was first drawn for. + let wizard = h.app.space_mouse.calibrating.as_ref().map(|(device, _)| device.as_str()); + assert!(wizard.is_none() || wizard == Some(second), "wizard for {wizard:?}"); + assert!(!h.app.preferences.space_mouse_offered.contains(first), "nothing recorded for the first device"); + // The device went away: the click finds no card. + let mut h = spacenavd_harness(); + run(&mut h, 2.0); + assert!(card_up(&h)); + let before = h.app.preferences.clone(); + h.app.space_mouse.device = None; + h.step(&serde_json::json!({"widget": "Calibrate…"})).ok(); + h.frames(3); + assert!(h.app.space_mouse.calibrating.is_none() && !h.app.prefs_window.open, "no wizard for a device that is gone"); + assert_eq!(h.app.preferences, before); + } + + /// Behind a guard that became active after the card was drawn (an issue report taking input), none of the + /// card's four buttons does anything. + #[test] + fn a_click_after_a_guard_became_active_does_nothing_behind_it() { + for widget in ["Calibrate…", "Close", "Never show again", "Swap to Hidraw (Direct Driver)"] { + let mut h = spacenavd_harness(); + run(&mut h, 2.0); + assert!(card_up(&h), "{widget}"); + let before = h.app.preferences.clone(); + crate::issue_report::set_cad_input_blocked_for_test(true); + h.step(&serde_json::json!({"widget": widget})).ok(); + h.frames(3); + crate::issue_report::set_cad_input_blocked_for_test(false); + assert!(h.app.space_mouse.calibrating.is_none() && !h.app.prefs_window.open, "{widget}: no wizard, no Preferences"); + assert_eq!(h.app.preferences, before, "{widget}: nothing recorded or changed"); + // And the card is back once the guard is gone, untouched. + run(&mut h, 1.0); + assert!(card_up(&h), "{widget}: the card is still offered"); + } + } + + /// `act` itself refuses a stale action: the device changed since the card was drawn, something took + /// the input, or the action is no longer on offer. Nothing is recorded for the old device, and neither + /// the Never flag nor the driver changes. (The control: with nothing stale each action does its job.) + #[test] + fn act_refuses_a_stale_action_and_changes_nothing() { + use crate::prefs::SpaceMouseDriver::{Automatic, Direct}; + use crate::space_mouse_offer::{Action, act, due}; + for action in [Action::Calibrate, Action::Close, Action::Never, Action::Swap] { + let fresh = || { + let mut h = spacenavd_harness(); + run(&mut h, 2.0); + let offer = due(&mut h.app, &h.ctx).expect("due"); + assert!(offer.actions.contains(&action), "{action:?} is on the card"); + (h, offer) + }; + // Control. + let (mut h, offer) = fresh(); + act(&mut h.app, &h.ctx, &offer, action); + let done = match action { + Action::Calibrate => h.app.space_mouse.calibrating.is_some() && h.app.preferences.space_mouse_offered.contains("spacenavd"), + Action::Close => h.app.preferences.space_mouse_offered.contains("spacenavd"), + Action::Never => h.app.preferences.space_mouse_offer_never, + Action::Swap => h.app.preferences.space_mouse_driver == Direct && h.app.preferences.space_mouse_offered.is_empty(), + }; + assert!(done, "{action:?}: the fresh action works"); + type Stale = (&'static str, fn(&mut crate::scenario::Harness)); + let cases: [Stale; 7] = [ + ("the device changed", |h| h.app.space_mouse.device = Some("hid 256f:c63a".into())), + ("the device went", |h| h.app.space_mouse.device = None), + ("an issue report took the input", |_| crate::issue_report::set_cad_input_blocked_for_test(true)), + ("Preferences opened", |h| h.app.prefs_window.open = true), + ("a status toast is up", |h| h.app.status = Some(("Done".into(), crate::now_ms(), false))), + ("the device was dismissed elsewhere", |h| { + h.app.preferences.space_mouse_offered.insert("spacenavd".into()); + }), + ("the action is no longer on offer", |h| { + // Swap needs spacenavd chosen; for the others the driver is irrelevant, so the offer is + // taken away by Never being set elsewhere. + h.app.preferences.space_mouse_driver = Automatic; + h.app.preferences.space_mouse_offer_never = true; + }), + ]; + for (what, stale) in cases { + let (mut h, offer) = fresh(); + stale(&mut h); + let (before, calibrating, window, hidden) = ( + h.app.preferences.clone(), + h.app.space_mouse.calibrating.is_some(), + h.app.prefs_window.open, + h.app.space_mouse.offer_hidden.clone(), + ); + act(&mut h.app, &h.ctx, &offer, action); + crate::issue_report::set_cad_input_blocked_for_test(false); + assert_eq!(h.app.preferences, before, "{action:?}, {what}: nothing recorded or changed"); + assert_eq!(h.app.space_mouse.calibrating.is_some(), calibrating, "{action:?}, {what}: no wizard"); + assert_eq!(h.app.prefs_window.open, window, "{action:?}, {what}: Preferences untouched"); + assert_eq!(h.app.space_mouse.offer_hidden, hidden, "{action:?}, {what}"); + } + // Swap alone: with Automatic chosen (the device is read when it opens) it is no longer on offer, the + // other actions still are. + let (mut h, offer) = fresh(); + h.app.preferences.space_mouse_driver = Automatic; + let before = h.app.preferences.clone(); + act(&mut h.app, &h.ctx, &offer, action); + if action == Action::Swap { + assert_eq!(h.app.preferences, before, "Swap is refused once it is not on offer"); + } else { + assert!(h.app.preferences != before || h.app.space_mouse.calibrating.is_some(), "{action:?} still works with Automatic chosen"); + } + } + } + + /// A different device key while the offer is up gets its own offer (its own calibration key); the + /// first one is left alone, with no record made for either. + #[test] + fn a_new_device_key_gets_its_own_offer_and_leaves_the_old_one_alone() { + let mut h = spacenavd_harness(); + run(&mut h, 10.0); + assert!(card_up(&h)); + h.app.space_mouse.device = Some("hid 256f:c63a".into()); + run(&mut h, 1.0); + assert!(card_up(&h)); + assert_eq!(h.app.space_mouse.offer_since.as_ref().map(|(device, _)| device.as_str()), Some("hid 256f:c63a")); + assert!(h.app.preferences.space_mouse_offered.is_empty() && h.app.space_mouse.offer_hidden.is_empty()); + // Back to the first within its 30 seconds: shown again, its timer starting afresh. + h.app.space_mouse.device = Some("spacenavd".into()); + run(&mut h, 1.0); + assert!(card_up(&h)); + // Switching the driver in Preferences while it is up changes nothing by itself. + h.app.preferences.space_mouse_driver = crate::prefs::SpaceMouseDriver::Direct; + run(&mut h, 2.0); + assert!(card_up(&h)); + } + + /// Hot-plug: a daemon with no records is no device (no offer), the first record is, and an offer + /// hidden for the run stays hidden. + #[test] + fn the_offer_follows_the_first_record_and_a_hidden_offer_stays_hidden() { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.app.space_mouse.drivers = Some(crate::space_mouse::DriversPresent { spacenavd: true, direct: true, denied: false }); + h.app.preferences.space_mouse_driver = crate::prefs::SpaceMouseDriver::Spacenavd; + h.app.space_mouse.device = None; + run(&mut h, 3.0); + assert!(!card_up(&h) && h.app.preferences.space_mouse_offered.is_empty()); + h.app.space_mouse.device = Some("spacenavd".into()); + run(&mut h, 2.0); + assert!(card_up(&h), "the first record shows the offer"); + assert!(h.widget("Swap to Hidraw (Direct Driver)").is_some(), "with a direct device there, Swap shows"); + // Hidden for the run (a status toast), it stays hidden when the device is seen again. + h.app.status = Some(("Done".into(), crate::now_ms(), false)); + run(&mut h, 1.0); + h.app.status = None; + h.app.space_mouse.device = None; + run(&mut h, 1.0); + h.app.space_mouse.device = Some("spacenavd".into()); + run(&mut h, 2.0); + assert!(!card_up(&h)); + // Under Automatic (the default) the device is read whenever it opens, so spacenavd being read with + // a device there is a device that does not open: a swap would do nothing, and is not offered. + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.app.space_mouse.drivers = Some(crate::space_mouse::DriversPresent { spacenavd: true, direct: true, denied: false }); + h.app.space_mouse.device = Some("spacenavd".into()); + run(&mut h, 2.0); + assert!(card_up(&h) && h.widget("Swap to Hidraw (Direct Driver)").is_none(), "no Swap under Automatic"); + // Without a direct device there is no Swap. + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.app.space_mouse.drivers = Some(crate::space_mouse::DriversPresent { spacenavd: true, direct: false, denied: false }); + h.app.space_mouse.device = Some("spacenavd".into()); + run(&mut h, 2.0); + assert!(card_up(&h) && h.widget("Swap to Hidraw (Direct Driver)").is_none()); + } + + /// The same device, plugged back in, gets what it had: its saved calibration applies, Preferences + /// calls it calibrated, and the offer stays away, whether Preferences was open or not and on whatever + /// node it comes up (the key is its ids). + #[test] + fn an_unplugged_and_replugged_device_keeps_its_calibration_and_is_not_offered_again() { + let axes = + std::array::from_fn(|source| solidcraft_space_mouse::AxisCalibration { source, reversed: false, rest: 0, dead_zone: 0, full: [350; 2] }); + let calibration = solidcraft_space_mouse::Calibration { dead_zone_share: 0.0, kind: solidcraft_space_mouse::Kind::ReturnToCenter, axes }; + for prefs_open in [false, true] { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.app.preferences.space_mouse_calibrations.insert("hid 046d:c62b".into(), calibration); + h.app.space_mouse.device = Some("hid 046d:c62b".into()); + if prefs_open { + crate::prefs::open_at(&mut h.app, crate::prefs::PAGE_NAVIGATION); + } + run(&mut h, 2.0); + assert_eq!(crate::space_mouse::calibration(&h.app), Some(calibration)); + // Unplugged (a few seconds), then back, on another node: the same key. + h.app.space_mouse.device = None; + run(&mut h, 4.0); + assert!(crate::space_mouse::calibration(&h.app).is_none(), "nothing to apply while it is away"); + h.app.space_mouse.device = Some("hid 046d:c62b".into()); + run(&mut h, 2.0); + assert_eq!(crate::space_mouse::calibration(&h.app), Some(calibration), "prefs_open {prefs_open}: the calibration applies again"); + assert!(!card_up(&h) && h.app.space_mouse.offer_since.is_none(), "no offer for a calibrated device"); + assert!(h.app.preferences.space_mouse_offered.is_empty() && h.app.preferences.space_mouse_calibrations.contains_key("hid 046d:c62b")); + } + } + + /// A device that was already offered (closed, say) is not offered again when it is plugged back in. + #[test] + fn a_device_already_offered_is_not_offered_again_after_a_replug() { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.app.preferences.space_mouse_offered.insert("hid 046d:c62b".into()); + h.app.space_mouse.device = Some("hid 046d:c62b".into()); + run(&mut h, 2.0); + assert!(!card_up(&h)); + h.app.space_mouse.device = None; + run(&mut h, 3.0); + h.app.space_mouse.device = Some("hid 046d:c62b".into()); + run(&mut h, 3.0); + assert!(!card_up(&h) && h.app.space_mouse.offer_since.is_none()); + } + + /// The offer keeps away while Preferences (and so its 3D mouse section) is open, and is back when it is + /// closed if nothing dismissed it; Calibrate from the card and from Preferences reach the same wizard. + #[test] + fn the_offer_keeps_away_from_the_open_section_and_calibrate_starts_the_same_wizard_either_way() { + let mut h = spacenavd_harness(); + run(&mut h, 2.0); + assert!(card_up(&h)); + crate::prefs::open_at(&mut h.app, crate::prefs::PAGE_NAVIGATION); + run(&mut h, 3.0); + assert!(!card_up(&h), "no card over Preferences"); + h.app.prefs_window.open = false; + run(&mut h, 2.0); + assert!(card_up(&h), "back once Preferences is closed, nothing was dismissed"); + assert!(h.app.preferences.space_mouse_offered.is_empty()); + let offer = crate::space_mouse_offer::due(&mut h.app, &h.ctx).expect("due"); + crate::space_mouse_offer::act(&mut h.app, &h.ctx, &offer, crate::space_mouse_offer::Action::Calibrate); + let from_card = h.app.space_mouse.calibrating.as_ref().map(|(device, wizard)| (device.clone(), wizard.stage(), wizard.kind())); + assert!(h.app.prefs_window.open); + let mut other = spacenavd_harness(); + crate::space_mouse_calibration::start(&mut other.app); + let from_prefs = other.app.space_mouse.calibrating.as_ref().map(|(device, wizard)| (device.clone(), wizard.stage(), wizard.kind())); + assert_eq!(from_card, from_prefs); + assert!(from_card.is_some()); + } + + /// The driver fallback round trip: Direct asked for, the device away so the daemon is read (its own + /// key), the device back: the saved calibration under the device's key applies again and the saved + /// driver choice never changed. + #[test] + fn a_driver_fallback_round_trip_keeps_the_calibration_and_the_driver_choice() { + let axes = + std::array::from_fn(|source| solidcraft_space_mouse::AxisCalibration { source, reversed: false, rest: 0, dead_zone: 0, full: [350; 2] }); + let calibration = solidcraft_space_mouse::Calibration { dead_zone_share: 0.0, kind: solidcraft_space_mouse::Kind::ReturnToCenter, axes }; + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.app.preferences.space_mouse_driver = crate::prefs::SpaceMouseDriver::Direct; + h.app.preferences.space_mouse_calibrations.insert("hid 046d:c62b".into(), calibration); + h.app.space_mouse.device = Some("hid 046d:c62b".into()); + run(&mut h, 2.0); + assert_eq!(crate::space_mouse::calibration(&h.app), Some(calibration)); + // Away: the daemon is read under its own key, which has no calibration (it is a different reading). + h.app.space_mouse.device = Some("spacenavd".into()); + run(&mut h, 2.0); + assert!(crate::space_mouse::calibration(&h.app).is_none()); + assert_eq!(h.app.preferences.space_mouse_driver, crate::prefs::SpaceMouseDriver::Direct, "the choice is not changed by the fallback"); + // Back: the device's own key, and its calibration, at once. + h.app.space_mouse.device = Some("hid 046d:c62b".into()); + run(&mut h, 1.0); + assert_eq!(crate::space_mouse::calibration(&h.app), Some(calibration)); + assert!(h.app.preferences.space_mouse_calibrations.contains_key("hid 046d:c62b")); + } +} diff --git a/crates/ui-egui/src/stand_up.rs b/crates/ui-egui/src/stand_up.rs index b64dee4dae..d00b02f469 100644 --- a/crates/ui-egui/src/stand_up.rs +++ b/crates/ui-egui/src/stand_up.rs @@ -44,8 +44,11 @@ pub fn stand_up(app: &mut SolveApp) -> Result<(), String> { app.stand_up_reason = false; let Some(bodies) = app.stand_up.take() else { return Err("no file is waiting to be stood up".into()) }; match app.stand_up_occurrence.take() { - // The import's component turns about its parent's origin, contents and all. - Some(occurrence) => app.run("occurrence.move", json!({ "occurrence": occurrence, "axis": [1, 0, 0], "angle": "90 deg" })), + // The import's component turns about its parent's origin, contents and all, even when it + // came in as the design's grounded first part (#2804): it is still being placed. + Some(occurrence) => { + app.run("occurrence.move", json!({ "occurrence": occurrence, "axis": [1, 0, 0], "angle": "90 deg", "move_grounded": true })) + } None => app.run("solid.move", json!({ "bodies": bodies, "axis": [1, 0, 0], "angle": "90 deg" })), } .map_err(|e| e.to_string())?; diff --git a/crates/ui-egui/src/test_boat.rs b/crates/ui-egui/src/test_boat.rs new file mode 100644 index 0000000000..d77b17c46b --- /dev/null +++ b/crates/ui-egui/src/test_boat.rs @@ -0,0 +1,216 @@ +//! The test model in Preferences > Navigation > 3D mouse: a small boat that moves with the 3D mouse +//! exactly as the view does, so what each setting on that page does can be felt on it. It is a +//! tessellation of a CC0 model (`assets/models/test_boat.bin`, made by +//! `assets/models/make_test_boat.py`; see ATTRIBUTION.md). +//! +//! The boat is drawn as one indexed mesh: the vertices are projected once with a [`Camera`] of its +//! own and shaded smoothly from their normals by one light that follows the camera, coloured from the +//! theme; the triangles facing away are dropped and the rest sorted far to near. The camera takes the same per-frame motion the viewport's +//! camera takes (`navigation::device_camera`), so calibration, reassigned axes, invert, speed and +//! the zoom direction all show. Pan and zoom are held so the boat never leaves its box. + +use std::sync::OnceLock; + +use egui::{Pos2, Rect, pos2}; +use solidcraft_engine::geom::Vec3; +use solidcraft_engine::render::Camera; + +use crate::space_mouse::DeviceMotion; +use crate::theme::Tokens; + +const DATA: &[u8] = include_bytes!("../../../assets/models/test_boat.bin"); + +/// The model: triangles in millimetres about its centre. Each vertex has its own normal (smooth +/// where the surface is, split at creases); each triangle has the face normal and centre that the +/// culling and sorting use. +pub(crate) struct Model { + pub vertices: Vec, + pub vertex_normals: Vec, + pub triangles: Vec<[u16; 3]>, + pub normals: Vec, + pub centroids: Vec, + /// The furthest a vertex is from the centre. + pub radius: f64, +} + +/// Bytes of one vertex in the file: its position (i16 x 3) and its normal (i8 x 3). +const VERTEX_BYTES: usize = 9; + +/// Reads the file: u32 vertex count, u32 triangle count, f32 centre (3) and half size, then per +/// vertex i16 x 3 and i8 x 3, then the triangles as u16 x 3. `None` for anything that does not add up. +pub(crate) fn parse(data: &[u8]) -> Option { + let word = |at: usize| data.get(at..at + 4).map(|b| [b[0], b[1], b[2], b[3]]); + let (vertex_count, triangle_count) = (u32::from_le_bytes(word(0)?) as usize, u32::from_le_bytes(word(4)?) as usize); + let half = f64::from(f32::from_le_bytes(word(20)?)); + let (vertices_at, triangles_at) = (24, 24usize.checked_add(vertex_count.checked_mul(VERTEX_BYTES)?)?); + if data.len() != triangles_at.checked_add(triangle_count.checked_mul(6)?)? + || !half.is_finite() + || half <= 0.0 + || vertex_count == 0 + || vertex_count > usize::from(u16::MAX) + 1 + { + return None; + } + let short = |at: usize| data.get(at..at + 2).map(|b| i16::from_le_bytes([b[0], b[1]])); + // Centred on the origin: the file's own centre is where the model sat, not where it is drawn. + let vertices: Vec = (0..vertex_count) + .map(|i| { + let at = vertices_at + VERTEX_BYTES * i; + Some(Vec3::new(f64::from(short(at)?), f64::from(short(at + 2)?), f64::from(short(at + 4)?)) * (half / 32767.0)) + }) + .collect::>()?; + let vertex_normals: Vec = (0..vertex_count) + .map(|i| { + let at = vertices_at + VERTEX_BYTES * i + 6; + let byte = |k: usize| data.get(at + k).map(|&b| f64::from(i8::from_le_bytes([b]))); + Some(Vec3::new(byte(0)?, byte(1)?, byte(2)?).normalized().unwrap_or(Vec3::Z)) + }) + .collect::>()?; + let triangles: Vec<[u16; 3]> = (0..triangle_count) + .map(|i| { + let at = triangles_at + 6 * i; + let index = |k: usize| data.get(at + 2 * k..at + 2 * k + 2).map(|b| u16::from_le_bytes([b[0], b[1]])); + Some([index(0)?, index(1)?, index(2)?]).filter(|t| t.iter().all(|&v| usize::from(v) < vertex_count)) + }) + .collect::>()?; + let corner = |t: &[u16; 3], k: usize| vertices[usize::from(t[k])]; + let normals = + triangles.iter().map(|t| (corner(t, 1) - corner(t, 0)).cross(corner(t, 2) - corner(t, 0)).normalized().unwrap_or(Vec3::Z)).collect(); + let centroids = triangles.iter().map(|t| (corner(t, 0) + corner(t, 1) + corner(t, 2)) * (1.0 / 3.0)).collect(); + let radius = vertices.iter().map(|v| v.len()).fold(0.0, f64::max); + Some(Model { vertices, vertex_normals, triangles, normals, centroids, radius }) +} + +/// The built-in model, read once. +pub(crate) fn model() -> Option<&'static Model> { + static MODEL: OnceLock> = OnceLock::new(); + MODEL.get_or_init(|| parse(DATA)).as_ref() +} + +/// Vertical field of view of the boat's camera. +const FOV: f64 = 0.6; +/// How near and far the boat's camera may be: nearest still shows the whole boat in the box. +const NEAREST: f64 = 3.0; +const FARTHEST: f64 = 12.0; + +/// The boat's view: a camera on the boat and when it was last drawn, so a page opened again starts +/// afresh. +#[derive(Clone, Copy, Debug)] +pub struct Boat { + pub cam: Camera, + pub seen: f64, +} + +impl Boat { + pub fn new(radius: f64) -> Self { + Self { cam: Camera { distance: radius * 6.0, fov: FOV, ..Camera::default() }, seen: 0.0 } + } +} + +/// `motion` for `seconds` on the boat's camera in a box `aspect` wide for its height of `height` +/// points, with pan and zoom held so a sphere of `radius` stays inside it. +pub(crate) fn step( + cam: &mut Camera, + motion: crate::space_mouse::Motion, + seconds: f64, + height: f64, + radius: f64, + aspect: f64, + reverse_zoom: bool, + free_orbit: bool, +) { + crate::navigation::device_camera(cam, motion, seconds, height, true, reverse_zoom, free_orbit); + cam.fov = FOV; + // Zoom within limits: the nearest still shows the whole boat in the shorter side of the box. + let shorter = aspect.min(1.0); + cam.distance = cam.distance.clamp(radius * NEAREST / shorter.max(0.2), radius * FARTHEST); + // Pan so the boat's centre stays where the whole boat is still inside: within the box less its radius. + let half = cam.distance * (FOV / 2.0).tan(); + let room = (half * aspect.min(1.0) - radius).max(0.0); + let off = cam.target.len(); + if off > room && off > 0.0 { + cam.target = cam.target * (room / off); + } + if !cam.is_valid() { + *cam = Boat::new(radius).cam; + } +} + +/// The boat for one frame: every vertex projected once with its shade (0 to 1, from its own normal, +/// so surfaces are smooth), and the triangles facing the eye as indices, far to near. +pub(crate) struct Frame { + pub points: Vec, + pub shades: Vec, + pub indices: Vec, +} + +/// Projects `model` through `cam` into `rect`. Pure, for the drawing and for tests. +pub(crate) fn project(model: &Model, cam: &Camera, rect: Rect) -> Frame { + let aspect = f64::from(rect.width()) / f64::from(rect.height().max(1.0)); + let vp = cam.view_proj(aspect, model.radius * 2.0); + let (right, up, back) = cam.basis(); + let light = (right * -0.4 + up * 0.6 + back * 0.7).normalized().unwrap_or(back); + let (width, height) = (f64::from(rect.width()), f64::from(rect.height())); + let (mut points, mut shades) = (Vec::with_capacity(model.vertices.len()), Vec::with_capacity(model.vertices.len())); + let mut behind = vec![false; model.vertices.len()]; + for (i, (vertex, normal)) in model.vertices.iter().zip(&model.vertex_normals).enumerate() { + let c = vp.apply(*vertex); + behind[i] = c[3] <= 1e-9; + let w = if behind[i] { 1.0 } else { c[3] }; + points.push(pos2(rect.left() + ((c[0] / w + 1.0) * 0.5 * width) as f32, rect.top() + ((1.0 - c[1] / w) * 0.5 * height) as f32)); + shades.push(normal.dot(light).max(0.0) as f32); + } + let eye = cam.eye(); + let mut visible: Vec<(f64, usize)> = (0..model.triangles.len()) + .filter(|&i| model.normals[i].dot(eye - model.centroids[i]) > 0.0 && !model.triangles[i].iter().any(|&v| behind[usize::from(v)])) + .map(|i| ((eye - model.centroids[i]).len(), i)) + .collect(); + visible.sort_by(|a, b| b.0.total_cmp(&a.0)); + let indices = visible.into_iter().flat_map(|(_, i)| model.triangles[i].map(u32::from)).collect(); + Frame { points, shades, indices } +} + +/// Draws the boat in a box of `size` and moves it by this frame's motion. `boat` is the page's state. +pub(crate) fn show( + ui: &mut egui::Ui, + boat: &mut Option, + motion: Option, + reverse_zoom: bool, + free_orbit: bool, + size: egui::Vec2, +) -> Option { + let model = model()?; + let t = Tokens::get(); + let now = ui.input(|i| i.time); + let (rect, _) = ui.allocate_exact_size(size, egui::Sense::hover()); + // A page opened again (not drawn for a moment) starts with the boat back. + let mut state = boat.filter(|b| now - b.seen < 0.5).unwrap_or_else(|| Boat::new(model.radius)); + state.seen = now; + if let Some(motion) = motion { + let aspect = f64::from(rect.width()) / f64::from(rect.height().max(1.0)); + step(&mut state.cam, motion.motion, motion.seconds, f64::from(rect.height()), model.radius, aspect, reverse_zoom, free_orbit); + } + let painter = ui.painter_at(rect); + painter.rect(rect, t.radius_sm, t.dialog_body, egui::Stroke::new(1.0, t.dialog_rule), egui::StrokeKind::Inside); + let (dark, lit) = (t.border, t.text); + let frame = project(model, &state.cam, rect.shrink(1.0)); + let vertices = frame + .points + .iter() + .zip(&frame.shades) + .map(|(&pos, &shade)| egui::epaint::Vertex { pos, uv: egui::epaint::WHITE_UV, color: dark.lerp_to_gamma(lit, 0.25 + 0.75 * shade) }) + .collect(); + let mesh = egui::Mesh { indices: frame.indices, vertices, texture_id: egui::TextureId::default() }; + painter.add(egui::Shape::mesh(mesh)); + *boat = Some(state); + Some(rect) +} + +/// The boat back where it began. +pub(crate) fn reset(boat: &mut Option) { + *boat = None; +} + +#[cfg(test)] +#[path = "test_boat_tests.rs"] +mod tests; diff --git a/crates/ui-egui/src/test_boat_tests.rs b/crates/ui-egui/src/test_boat_tests.rs new file mode 100644 index 0000000000..4c8521d7dc --- /dev/null +++ b/crates/ui-egui/src/test_boat_tests.rs @@ -0,0 +1,156 @@ +use super::*; +use crate::space_mouse::Motion; +use egui::vec2; + +fn boat() -> &'static Model { + model().expect("the built-in model reads") +} + +#[test] +fn test_boat_asset_reads_with_its_counts_and_bounds() { + let m = boat(); + assert!(m.triangles.len() >= 500 && m.triangles.len() <= 2_000, "{} triangles", m.triangles.len()); + // Smooth shading: vertices are shared where the surface is smooth, and split at creases. + assert!(m.vertices.len() > m.triangles.len() / 3 && m.vertices.len() <= m.triangles.len() * 3, "{} vertices", m.vertices.len()); + assert!(DATA.len() < 100_000, "{} bytes", DATA.len()); + // About 60 mm long, centred on the origin. + let (low, high) = m.vertices.iter().fold((Vec3::new(1e9, 1e9, 1e9), Vec3::new(-1e9, -1e9, -1e9)), |(lo, hi), v| (lo.min(*v), hi.max(*v))); + let size = high - low; + assert!((size.x.max(size.y).max(size.z) - 60.0).abs() < 1.0, "{size:?}"); + assert!((m.radius - 30.0).abs() < 15.0 && m.radius >= 25.0, "radius {}", m.radius); + // Every normal is a unit vector. + assert!(m.normals.iter().all(|n| (n.len() - 1.0).abs() < 1e-6)); + assert!(m.vertex_normals.iter().all(|n| (n.len() - 1.0).abs() < 1e-6)); +} + +#[test] +fn test_boat_rejects_files_that_do_not_add_up() { + assert!(parse(&[]).is_none() && parse(&DATA[..40]).is_none()); + let mut long = DATA.to_vec(); + long.push(0); + assert!(parse(&long).is_none()); + // An index past the vertices. + let mut bad = DATA.to_vec(); + let at = bad.len() - 2; + bad[at..].copy_from_slice(&u16::MAX.to_le_bytes()); + assert!(parse(&bad).is_none()); + // A half size that is not a number. + let mut nan = DATA.to_vec(); + nan[20..24].copy_from_slice(&f32::NAN.to_le_bytes()); + assert!(parse(&nan).is_none()); +} + +fn motion(translation: [f64; 3], rotation: [f64; 3]) -> Motion { + Motion { translation, rotation } +} + +#[test] +fn test_boat_step_orbits_pans_zooms_rolls_and_holds_the_boat_in_its_box() { + let radius = boat().radius; + let start = Boat::new(radius).cam; + let aspect = 1.4; + let run = |m: Motion, seconds: f64| { + let mut cam = start; + step(&mut cam, m, seconds, 150.0, radius, aspect, false, false); + cam + }; + // Nothing moves nothing. + assert_eq!(run(motion([0.0; 3], [0.0; 3]), 0.05), start); + // Orbit turns the view about the boat; roll rolls it; both keep the target. + let orbit = run(motion([0.0; 3], [0.5, 0.5, 0.0]), 0.05); + assert!(orbit.yaw != start.yaw || orbit.pitch != start.pitch); + assert_eq!(orbit.target, start.target); + assert!(run(motion([0.0; 3], [0.0, 0.0, 0.5]), 0.05).roll != start.roll); + // Zoom changes the distance one way for push and the other for pull. + let (push, pull) = (run(motion([0.0, 1.0, 0.0], [0.0; 3]), 0.05), run(motion([0.0, -1.0, 0.0], [0.0; 3]), 0.05)); + assert!(push.distance < start.distance && pull.distance > start.distance); + // Pan moves the target, within the room the box leaves. + let panned = run(motion([1.0, 0.0, 0.0], [0.0; 3]), 0.05); + assert!(panned.target != start.target); + // Held down for a long time, the limits hold: nearest, farthest, and the pan room. + let mut cam = start; + for _ in 0..400 { + step(&mut cam, motion([1.0, 1.0, 1.0], [0.0; 3]), 0.05, 150.0, radius, aspect, false, false); + } + assert!(cam.distance >= radius * NEAREST / 1.0 - 1e-6 && cam.target.len() <= cam.distance * (FOV / 2.0).tan() + 1e-6); + let mut cam = start; + for _ in 0..400 { + step(&mut cam, motion([-1.0, -1.0, -1.0], [0.0; 3]), 0.05, 150.0, radius, aspect, false, false); + } + assert!(cam.distance <= radius * FARTHEST + 1e-6); + // Hostile numbers leave a valid camera. + let mut cam = start; + step(&mut cam, motion([f64::NAN, f64::INFINITY, 1e300], [f64::NAN; 3]), 0.05, 150.0, radius, aspect, false, false); + assert!(cam.is_valid()); +} + +#[test] +fn test_boat_draws_inside_its_box_at_every_size_and_pose() { + let m = boat(); + for (w, h) in [(120.0_f32, 90.0_f32), (200.0, 150.0), (260.0, 150.0), (150.0, 260.0), (400.0, 100.0)] { + let rect = Rect::from_min_size(pos2(10.0, 20.0), vec2(w, h)); + let aspect = f64::from(w) / f64::from(h); + let mut cam = Boat::new(m.radius).cam; + // Through the extremes of what the 3D mouse can do. + for (translation, rotation) in [([0.0; 3], [0.0; 3]), ([1.0, -1.0, 1.0], [1.0, 0.0, 1.0]), ([-1.0, 1.0, -1.0], [0.0, 1.0, -1.0])] { + for _ in 0..200 { + step(&mut cam, motion(translation, rotation), 0.05, f64::from(h), m.radius, aspect, false, false); + } + let frame = project(m, &cam, rect.shrink(1.0)); + assert!(!frame.indices.is_empty(), "{w}x{h}: something is drawn"); + assert!(frame.shades.iter().all(|s| (0.0..=1.0).contains(s))); + for &index in &frame.indices { + let corner = frame.points[index as usize]; + assert!(rect.expand(0.5).contains(corner), "{w}x{h}: {corner:?} is outside {rect:?}"); + } + } + } +} + +/// What a frame asks of the painter: every vertex once and the facing triangles' indices, with a +/// ceiling, and the time it takes to build one (printed; a loose limit so a slow runner is not a failure). +#[test] +fn test_boat_costs_a_bounded_amount_to_project_per_frame() { + let m = boat(); + let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(260.0, 150.0)); + let cam = Boat::new(m.radius).cam; + let started = std::time::Instant::now(); + let frames = 20; + let mut last = None; + for _ in 0..frames { + last = Some(project(m, &cam, rect)); + } + let per_frame = started.elapsed() / frames; + let frame = last.unwrap(); + eprintln!("BOAT COST {} vertices, {} indices, {per_frame:?} per frame", frame.points.len(), frame.indices.len()); + assert!(frame.points.len() <= 5_000 && frame.indices.len() <= 3 * 5_000, "{} vertices, {} indices", frame.points.len(), frame.indices.len()); + assert!(per_frame < std::time::Duration::from_millis(100), "{per_frame:?}"); +} + +#[test] +fn test_boat_far_faces_come_first_and_a_page_opened_again_starts_afresh() { + let m = boat(); + let cam = Boat::new(m.radius).cam; + let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(200.0, 150.0)); + let drawn = project(m, &cam, rect).indices.len() / 3; + // Only the faces turned to the eye: roughly half the triangles of a closed boat. + assert!(drawn < m.triangles.len() * 3 / 4 && drawn > m.triangles.len() / 4, "{drawn} of {}", m.triangles.len()); + // The boat comes back with the page: not drawn for a moment, the state starts afresh. + let ctx = crate::fonts::test_context(); + let mut state = None; + let moved = Some(DeviceMotion { motion: motion([0.0; 3], [1.0, 0.0, 0.0]), seconds: 0.05 }); + let draw = |time: f64, motion: Option, state: &mut Option| { + let mut output = ctx.run_ui(egui::RawInput { time: Some(time), ..Default::default() }, |ui| { + show(ui, state, motion, false, false, vec2(200.0, 150.0)); + }); + output.textures_delta.clear(); + }; + draw(1.0, moved, &mut state); + draw(1.016, moved, &mut state); + let turned = state.unwrap().cam; + assert_ne!((turned.yaw, turned.pitch), (Boat::new(m.radius).cam.yaw, Boat::new(m.radius).cam.pitch)); + draw(5.0, None, &mut state); + assert_eq!(state.unwrap().cam, Boat::new(m.radius).cam, "away for a while, it starts afresh"); + reset(&mut state); + assert!(state.is_none()); +} diff --git a/crates/ui-egui/src/test_data/prefs_space_mouse_user.json b/crates/ui-egui/src/test_data/prefs_space_mouse_user.json new file mode 100644 index 0000000000..d5b48aac3c --- /dev/null +++ b/crates/ui-egui/src/test_data/prefs_space_mouse_user.json @@ -0,0 +1 @@ +{"agent_cursor":{"follow_camera":false,"show":false,"speed":1.0},"auto_project":true,"autosave_minutes":5.0,"browser_collapsed":[],"browser_expanded":[],"dark":true,"grid":true,"ground_shadow":true,"perspective":false,"pick_bodies":false,"preferences":{"agent_always_allow":{},"agent_chat_minimized":false,"agent_chat_popped":false,"agent_chat_side":"right","agent_chat_turn_limit":24,"agent_chat_width":380.0,"agent_chat_window":null,"agent_default":"","agent_models":{},"agent_off_thread":false,"agent_permissions":{},"agents":[],"ai_features":true,"ai_permissions":"edits","ai_toolbar_button":true,"ambient_occlusion":false,"angle_decimals":1,"browser_float":false,"browser_width":250.0,"check_updates":true,"color_vision":"off","custom_slicers":[],"custom_themes":[],"default_units":"mm","disabled_plugins":[],"display_quality":"normal","grid_follows_view":false,"language":"en","length_decimals":2,"look_at_anim":true,"look_at_speed":1.0,"manipulator_free_alt":true,"measure_axis_distances":true,"middle_double_click":"home","mouse_gestures":{"enabled":false,"wheels":{}},"msaa":4,"nav":"fusion","navigator_style":"axes","offer_stand_up":true,"orbit_around":"view_centre","orbit_cursor":false,"pan_reverse_x":false,"radial_context_menu":false,"save_mesh_format":"stl","save_mesh_send":false,"save_mesh_slicer":"","selection_shift_add_only":false,"show_frame_rate":false,"smooth_wheel_zoom":true,"space_mouse_axis_source":[0,1,2,3,4,5],"space_mouse_axis_speed":[1.0,1.0,1.0,1.0,1.0,1.0],"space_mouse_buttons":{},"space_mouse_calibrations":{"hid 256f:c63a":{"axes":[{"dead_zone":0,"full":[350,350],"rest":0,"reversed":false,"source":0},{"dead_zone":0,"full":[350,350],"rest":0,"reversed":false,"source":1},{"dead_zone":22,"full":[350,350],"rest":0,"reversed":false,"source":2},{"dead_zone":0,"full":[350,350],"rest":0,"reversed":false,"source":3},{"dead_zone":0,"full":[350,350],"rest":0,"reversed":false,"source":4},{"dead_zone":0,"full":[350,350],"rest":0,"reversed":false,"source":5}],"dead_zone_share":0.0,"kind":"ReturnToCenter"}},"space_mouse_driver":"direct","space_mouse_enabled":true,"space_mouse_ghost_position":"current","space_mouse_in_view":true,"space_mouse_offered":["spacenavd"],"space_mouse_reverse":[false,false,false,false,false,false],"space_mouse_speed":1.0,"supersampling":1.0,"supersampling_adaptive":true,"suppress_sketch_snap":{"alt":true,"ctrl":true,"shift":false},"theme":"dark","timeline_left":false,"timeline_left_browser_collapsed":false,"timeline_left_height":360.0,"trackpad_pan":true,"ui_font":"","ui_scale":1.0,"up_axis":"z","update":{"last_check":0.0,"next_check":0.0,"rate_limited_until":0.0,"remind_after":0.0,"skipped":""},"view_dxf_dashed":false,"view_dxf_tolerance":0.01,"zoom_reverse":false,"zoom_to_cursor":true},"recent":[],"recent_opened_at":{},"shortcut_box":{"pinned":[],"recent":[]},"shortcuts":{},"toolbar":{"panels":{},"version":2},"visual_style":0} \ No newline at end of file diff --git a/crates/ui-egui/src/test_support.rs b/crates/ui-egui/src/test_support.rs index 030367571e..30d5a2e1c8 100644 --- a/crates/ui-egui/src/test_support.rs +++ b/crates/ui-egui/src/test_support.rs @@ -33,10 +33,10 @@ pub(crate) fn click(h: &mut Harness, handle: &str) { h.frames(3); } -/// One component named Block, with its original occurrence and the root active. +/// One component named Block, with its original occurrence free and the root active. pub(crate) fn component_box() -> (Harness, u64, u64) { let mut h = Harness::new(); - let part = h.app.run("component.create", json!({"name":"Block"})).unwrap(); + let part = h.app.run("component.create", json!({"name":"Block","grounded":false})).unwrap(); let component = part["component"].as_u64().unwrap(); let occurrence = part["occurrence"].as_u64().unwrap(); h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap(); diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index c564342ad7..dd9989e7a1 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -27,6 +27,193 @@ thread_local! { static CUSTOM: Cell> = const { Cell::new(None) }; } +/// Three structural border levels plus independent content and control outlines. +/// Focus, selection, status, artwork and CAD geometry do not use these roles. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BorderRole { + Primary, + Secondary, + Tertiary, + Container, + Control, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Borders { + pub primary: Option, + pub secondary: Option, + /// Pattern for secondary section rules; solid preserves existing themes. + pub secondary_dotted: bool, + pub tertiary: Option, + /// Solid outlines for nested previews, information wells and shortcut keycaps. + pub container: Option, + pub control: Option, + pub primary_inset: Option, + /// Optional directional chrome. Absent themes keep their flat strokes. + pub bevel: Option, +} + +/// Two directional edge bands, shared by raised chrome and recessed editors. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Bevel { + pub width: f32, + pub highlight: Color32, + pub light: Color32, + pub shadow: Color32, + pub dark_shadow: Color32, +} + +impl Bevel { + /// Draw inside existing geometry; no extra input targets or layout allocation. + pub fn paint(self, painter: &egui::Painter, rect: egui::Rect, radius: impl Into, recessed: bool, enabled: bool) { + if !rect.is_finite() || self.width <= 0.0 || !self.width.is_finite() || rect.width().min(rect.height()) <= self.width * 4.0 { + return; + } + let radius = radius.into(); + let bands = if recessed { + [(self.shadow, self.highlight), (self.dark_shadow, self.light)] + } else { + [(self.highlight, self.dark_shadow), (self.light, self.shadow)] + }; + for (index, (upper, lower)) in bands.into_iter().enumerate() { + let inset = (index as f32 + 0.5) * self.width; + let edge = rect.shrink(inset); + let mut rounding: egui::epaint::CornerRadiusF32 = radius.into(); + rounding -= inset; + let mut path = Vec::new(); + egui::epaint::tessellator::path::rounded_rectangle(&mut path, edge, rounding); + if let Some(first) = path.first().copied() { + path.push(first); + } + for pair in path.windows(2) { + if let Some((a, b)) = pair.first().zip(pair.get(1)) { + let middle = *a + (*b - *a) * 0.5; + let relative = (middle - edge.center()) / edge.size(); + let color = if relative.x + relative.y < 0.0 { upper } else { lower }; + let color = if enabled { color } else { color.gamma_multiply(0.45) }; + painter.line_segment([*a, *b], Stroke::new(self.width, color)); + } + } + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct InsetBorder { + pub stroke: Stroke, + /// Clear distance from the inner edge of the primary stroke, in logical points. + pub gap: f32, +} + +impl Borders { + pub const LEGACY: Self = Self { + primary: None, + secondary: None, + secondary_dotted: false, + tertiary: None, + container: None, + control: None, + primary_inset: None, + bevel: None, + }; + + pub fn stroke(self, role: BorderRole, fallback: Stroke) -> Stroke { + match role { + BorderRole::Primary => self.primary, + BorderRole::Secondary => self.secondary, + BorderRole::Tertiary => self.tertiary, + BorderRole::Container => self.container, + BorderRole::Control => self.control, + } + .unwrap_or(fallback) + } +} + +impl Tokens { + /// Bevel themes use the editable surface colour independently of button faces. + pub fn input_fill(&self) -> Color32 { + if self.borders.bevel.is_some() { self.field } else { self.control_fill } + } + + pub fn input_bevel(&self, ui: &egui::Ui, response: &egui::Response) { + if let Some(bevel) = self.borders.bevel { + bevel.paint(ui.painter(), response.rect.shrink(1.0), self.control_radius, true, response.enabled()); + } + } + + pub fn button_bevel(&self, ui: &egui::Ui, response: &egui::Response, selected: bool) { + self.button_bevel_with_radius(ui, response, selected, self.control_radius); + } + + /// Compound controls supply the same corner geometry as their selected fill. + pub fn button_bevel_with_radius(&self, ui: &egui::Ui, response: &egui::Response, selected: bool, radius: impl Into) { + if let Some(bevel) = self.borders.bevel { + let pressed = response.enabled() + && (response.is_pointer_button_down_on() + || (response.has_focus() && ui.input(|i| i.key_down(egui::Key::Space) || i.key_down(egui::Key::Enter)))); + bevel.paint(ui.painter(), response.rect, radius, selected || pressed, response.enabled()); + } + } + + pub fn primary_border(&self, color: Color32) -> Stroke { + self.borders.stroke(BorderRole::Primary, Stroke::new(1.0, color)) + } + pub fn secondary_border(&self, color: Color32) -> Stroke { + self.borders.stroke(BorderRole::Secondary, Stroke::new(1.0, color)) + } + /// Secondary rules share a pattern as well as a stroke. Dot count is bounded for tiny widths. + fn secondary_dots(&self, painter: &egui::Painter, path: &[egui::Pos2], stroke: Stroke) { + if stroke.width <= 0.0 || path.iter().any(|p| !p.is_finite()) { + return; + } + let length: f32 = path.windows(2).map(|pair| pair.first().zip(pair.get(1)).map_or(0.0, |(a, b)| a.distance(*b))).sum(); + if !length.is_finite() { + return; + } + let spacing = (stroke.width * 3.0).max(3.0).max(length / 4096.0); + painter.extend(egui::Shape::dotted_line(path, stroke.color, spacing, stroke.width * 0.5)); + } + + pub fn secondary_line(&self, painter: &egui::Painter, points: [egui::Pos2; 2], color: Color32) { + let stroke = self.secondary_border(color); + if self.borders.secondary_dotted { + self.secondary_dots(painter, &points, stroke); + } else { + painter.line_segment(points, stroke); + } + } + + pub fn secondary_hline(&self, painter: &egui::Painter, range: impl Into, y: f32, color: Color32) { + let range = range.into(); + self.secondary_line(painter, [egui::pos2(range.min, y), egui::pos2(range.max, y)], color); + } + + pub fn secondary_vline(&self, painter: &egui::Painter, x: f32, range: impl Into, color: Color32) { + let range = range.into(); + self.secondary_line(painter, [egui::pos2(x, range.min), egui::pos2(x, range.max)], color); + } + + /// Enclosures stay solid independently of the section-divider pattern. + pub fn container_rect(&self, painter: &egui::Painter, rect: egui::Rect, radius: impl Into, fill: Color32, color: Color32) { + let radius = radius.into(); + painter.rect(rect, radius, fill, self.container_border(color), egui::StrokeKind::Inside); + if let Some(bevel) = self.borders.bevel { + bevel.paint(painter, rect, radius, false, true); + } + } + + pub fn container_border(&self, color: Color32) -> Stroke { + self.borders.stroke(BorderRole::Container, Stroke::new(1.0, color)) + } + + pub fn tertiary_border(&self, color: Color32) -> Stroke { + self.borders.stroke(BorderRole::Tertiary, Stroke::new(1.0, color)) + } + pub fn control_border_stroke(&self, color: Color32) -> Stroke { + self.borders.stroke(BorderRole::Control, Stroke::new(1.0, color)) + } +} + /// A theme file's palette and whether it is dark. #[derive(Clone, Copy, Debug, PartialEq)] pub struct Custom { @@ -40,6 +227,20 @@ pub fn set_custom(custom: Option) -> bool { CUSTOM.with(|c| c.replace(custom) != custom) } +/// Isolated widget tests restore the thread's theme even when an assertion fails. +#[cfg(test)] +pub(crate) fn scoped_tokens(tokens: Tokens) -> impl Drop { + struct Restore(Option); + impl Drop for Restore { + fn drop(&mut self) { + set_custom(self.0); + } + } + let restore = Restore(CUSTOM.get()); + set_custom(Some(Custom { dark: is_dark(), tokens })); + restore +} + /// A tab's selection colours. This is deliberately separate from the global widget style: /// application chrome, checkboxes, status colours and dialog actions retain their own tokens. #[derive(Clone, Copy, Debug, PartialEq)] @@ -85,12 +286,31 @@ impl TabAccent { // Model and shared utility tabs retain their existing selection exactly. _ => (t.accent, t.selected_ink, t.accent_soft), }; + let ink = if contrast_ratio(ink, soft) >= 4.5 { + ink + } else if contrast_ratio(color, soft) >= 4.5 { + color + } else if contrast_ratio(t.text, soft) >= 4.5 { + t.text + } else if contrast_ratio(Color32::BLACK, soft) >= contrast_ratio(Color32::WHITE, soft) { + Color32::BLACK + } else { + Color32::WHITE + }; Self { color, ink, soft } } /// Small text on a tab's solid pill. Brighter fills need dark ink in either theme. pub fn on_fill(self, t: &Tokens) -> Color32 { - if contrast_ratio(t.on_accent, self.color) >= 4.5 { t.on_accent } else { t.tab_on_fill_dark } + if contrast_ratio(t.on_accent, self.color) >= 4.5 { + t.on_accent + } else if contrast_ratio(t.tab_on_fill_dark, self.color) >= 4.5 { + t.tab_on_fill_dark + } else if contrast_ratio(Color32::BLACK, self.color) >= contrast_ratio(Color32::WHITE, self.color) { + Color32::BLACK + } else { + Color32::WHITE + } } pub fn current(ctx: &egui::Context) -> Self { @@ -225,6 +445,8 @@ pub struct IconColors { #[derive(Clone, Copy, Debug, PartialEq)] pub struct Tokens { + /// Optional structural/control strokes. None retains each surface’s legacy styling. + pub borders: Borders, /// File/start presentation, with explicit foreground/background pairs. pub file_dim: Color32, pub file_recovery_well: Color32, @@ -526,12 +748,19 @@ pub struct Tokens { pub continuity: [Color32; 4], pub analysis: AnalysisColors, pub icons: IconColors, + /// Optional SVG display roles and semantic ramps from the active theme file. + pub icon_palette: Option, /// The SolidCraft mark on the application bar: light, mid and dark faces (the gaps between /// them show the bar). pub logo: [Color32; 3], } impl Tokens { + /// An expanded outline follows its fill, including a theme's deliberately square corners. + pub(crate) fn outset_radius(radius: f32, outset: f32) -> f32 { + if radius > 0.0 { radius + outset } else { 0.0 } + } + /// Subtle shell elevation, derived from the semantic application-bar colour. pub(crate) fn shell_shadow(self) -> egui::Shadow { egui::Shadow { offset: [0, self.space_2 as i8], blur: self.space_8 as u8, spread: 0, color: self.app_bar.gamma_multiply(0.18) } @@ -590,7 +819,7 @@ fn visuals(dark: bool) -> egui::Visuals { let mut v = if dark { egui::Visuals::dark() } else { egui::Visuals::light() }; v.panel_fill = t.panel; v.window_fill = t.panel; - v.window_stroke = Stroke::new(1.0, t.border); + v.window_stroke = t.primary_border(t.border); v.extreme_bg_color = t.field; v.faint_bg_color = t.panel_header; v.override_text_color = Some(t.text); @@ -599,14 +828,24 @@ fn visuals(dark: bool) -> egui::Visuals { v.widgets.active.weak_bg_fill = t.accent_soft; v.selection.bg_fill = t.accent_soft; v.selection.stroke = Stroke::new(1.0, t.accent); - v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, t.border); + v.widgets.noninteractive.bg_stroke = t.tertiary_border(t.border); v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, t.text); v.widgets.inactive.fg_stroke = Stroke::new(1.0, t.text); v.widgets.hovered.weak_bg_fill = t.hover; v.widgets.hovered.bg_fill = t.hover; // egui uses the open-state fill for a movable window's active title bar. v.widgets.open.weak_bg_fill = t.panel_header; + for state in [&mut v.widgets.inactive, &mut v.widgets.hovered, &mut v.widgets.active, &mut v.widgets.open] { + state.bg_stroke = t.borders.stroke(BorderRole::Control, state.bg_stroke); + } v.window_corner_radius = egui::CornerRadius::same(t.radius_md); + v.menu_corner_radius = egui::CornerRadius::same(t.radius_md); + // Keep the existing 2/3 point egui shapes under the default 4 point small token. + // Set every interaction state so hovering or focusing cannot restore rounded corners. + for widget in [&mut v.widgets.noninteractive, &mut v.widgets.inactive, &mut v.widgets.active, &mut v.widgets.open] { + widget.corner_radius = egui::CornerRadius::same(t.radius_sm / 2); + } + v.widgets.hovered.corner_radius = egui::CornerRadius::same(t.radius_sm.saturating_sub(1)); v } @@ -623,6 +862,39 @@ pub fn apply(ctx: &egui::Context) { #[cfg(test)] mod tests { + #[test] + fn widget_states_and_popup_frames_follow_theme_radii_without_changing_defaults() { + use super::*; + let old_custom = CUSTOM.get(); + for dark in [false, true] { + set_custom(None); + let actual = visuals(dark); + let original = if dark { egui::Visuals::dark() } else { egui::Visuals::light() }; + assert_eq!(actual.menu_corner_radius, original.menu_corner_radius); + for (a, b) in [ + (actual.widgets.noninteractive, original.widgets.noninteractive), + (actual.widgets.inactive, original.widgets.inactive), + (actual.widgets.hovered, original.widgets.hovered), + (actual.widgets.active, original.widgets.active), + (actual.widgets.open, original.widgets.open), + ] { + assert_eq!(a.corner_radius, b.corner_radius); + } + let tokens = Tokens { radius_sm: 0, radius_md: 0, ..Tokens::dark() }; + set_custom(Some(Custom { dark, tokens })); + let square = visuals(dark); + set_custom(old_custom); + assert_eq!(square.window_corner_radius, egui::CornerRadius::ZERO); + assert_eq!(square.menu_corner_radius, egui::CornerRadius::ZERO); + for widget in [square.widgets.noninteractive, square.widgets.inactive, square.widgets.hovered, square.widgets.active, square.widgets.open] + { + assert_eq!(widget.corner_radius, egui::CornerRadius::ZERO); + } + } + assert_eq!(Tokens::outset_radius(0.0, 3.0), 0.0); + assert_eq!(Tokens::outset_radius(6.0, 3.0), 9.0); + } + #[test] fn file_view_foreground_pairs_hold_small_text_contrast_without_changing_accent() { for dark in [false, true] { diff --git a/crates/ui-egui/src/theme_files.rs b/crates/ui-egui/src/theme_files.rs index 3388a43e09..692f54d3c9 100644 --- a/crates/ui-egui/src/theme_files.rs +++ b/crates/ui-egui/src/theme_files.rs @@ -1,4 +1,4 @@ -//! Colour themes as files (#2255): a `solidcraft-theme/1` JSON file names a base palette (the +//! Colour and shape themes as files (#2255): a `solidcraft-theme/1` JSON file names a base palette (the //! built-in Dark or Light) and sets any of its [`Tokens`] by name, so a theme can be a handful of //! lines. Built-in themes (Gruvbox and others) are such files under `assets/styles/ui/themes/`; //! imported ones are kept in the preferences (`Prefs::custom_themes`), so they also work in the @@ -68,7 +68,7 @@ impl Base { } } -/// A theme: its id (the `theme` preference's value), name, base and colour overrides, each a +/// A theme: its id (the `theme` preference's value), name, base, optional radii and colour overrides, each a /// `#rrggbb` / `#rrggbbaa` string or a list of them, keyed by [`keys`]. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct ThemeFile { @@ -76,6 +76,166 @@ pub struct ThemeFile { pub name: String, pub base: Base, pub colors: BTreeMap, + /// Optional corner radii in logical UI points. Missing entries keep the base values. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub radii: BTreeMap, + /// Optional border roles; absent entries retain the existing surface-specific styling. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub borders: BTreeMap, + /// SVG display roles and three-colour semantic ramps; omitted values keep compiled defaults. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub icon_palette: BTreeMap, +} + +/// Maximum imported radius, in logical UI points; widgets clamp to their own bounds. +const MAX_RADIUS: u8 = 32; +const RADIUS_KEYS: [&str; 6] = ["radius_sm", "radius_md", "radius_lg", "radius_xl", "control_radius", "sketch_mode_radius"]; + +fn radius_slot<'a>(t: &'a mut Tokens, key: &str) -> Option<&'a mut u8> { + match key { + "radius_sm" => Some(&mut t.radius_sm), + "radius_md" => Some(&mut t.radius_md), + "radius_lg" => Some(&mut t.radius_lg), + "radius_xl" => Some(&mut t.radius_xl), + "control_radius" => Some(&mut t.control_radius), + "sketch_mode_radius" => Some(&mut t.sketch_mode_radius), + _ => None, + } +} + +impl ThemeFile { + /// Validate direct control-channel preference updates as strictly as file imports. + pub(crate) fn validate_radii(&self) -> Result<(), String> { + for (key, value) in &self.radii { + if !RADIUS_KEYS.contains(&key.as_str()) { + return Err(format!("`radii.{key}` is not a theme radius")); + } + if *value > MAX_RADIUS { + return Err(format!("`radii.{key}` must be an integer from 0 to {MAX_RADIUS}")); + } + } + Ok(()) + } +} + +/// Strictly bounded strokes shared by file imports and direct preference updates. +pub(crate) fn parse_borders(values: &BTreeMap) -> Result { + use crate::theme::{Bevel, Borders, InsetBorder}; + if values.len() > 7 { + return Err("`borders` has more than seven roles".into()); + } + let mut borders = Borders::default(); + for (key, value) in values { + let obj = value.as_object().ok_or_else(|| format!("`borders.{key}` must be an object"))?; + if key == "bevel" { + if obj.keys().any(|k| !matches!(k.as_str(), "width" | "highlight" | "light" | "shadow" | "dark_shadow")) { + return Err("`borders.bevel` has an unknown setting".into()); + } + let width = obj + .get("width") + .and_then(Value::as_f64) + .filter(|v| v.is_finite() && *v > 0.0 && *v <= 2.0) + .ok_or("`borders.bevel.width` must be a finite number greater than 0 and at most 2")? as f32; + let color = |name: &str| { + obj.get(name) + .and_then(Value::as_str) + .and_then(parse_color) + .ok_or_else(|| format!("`borders.bevel.{name}` must be #rrggbb or #rrggbbaa")) + }; + borders.bevel = Some(Bevel { + width, + highlight: color("highlight")?, + light: color("light")?, + shadow: color("shadow")?, + dark_shadow: color("dark_shadow")?, + }); + continue; + } + if obj.keys().any(|k| k != "color" && k != "width" && !(key == "primary_inset" && k == "gap") && !(key == "secondary" && k == "pattern")) { + return Err(format!("`borders.{key}` has an unknown setting")); + } + let number = |name: &str, max: f64| -> Result { + obj.get(name) + .and_then(Value::as_f64) + .filter(|v| v.is_finite() && *v >= 0.0 && *v <= max) + .map(|v| v as f32) + .ok_or_else(|| format!("`borders.{key}.{name}` must be a finite number from 0 to {max}")) + }; + let width = number("width", 4.0)?; + let color = obj + .get("color") + .and_then(Value::as_str) + .and_then(parse_color) + .ok_or_else(|| format!("`borders.{key}.color` must be #rrggbb or #rrggbbaa"))?; + let stroke = egui::Stroke::new(width, color); + match key.as_str() { + "primary" => borders.primary = Some(stroke), + "secondary" => { + borders.secondary = Some(stroke); + borders.secondary_dotted = match obj.get("pattern") { + None => false, + Some(Value::String(pattern)) if pattern == "solid" => false, + Some(Value::String(pattern)) if pattern == "dotted" => true, + _ => return Err("`borders.secondary.pattern` must be solid or dotted".into()), + }; + } + "tertiary" => borders.tertiary = Some(stroke), + "container" => borders.container = Some(stroke), + "control" => borders.control = Some(stroke), + "primary_inset" => borders.primary_inset = Some(InsetBorder { stroke, gap: number("gap", 12.0)? }), + _ => return Err(format!("`borders.{key}` is not a border role")), + } + } + Ok(borders) +} + +/// Import only known, opaque SVG roles and bounded semantic ramps. GAP remains an eraser. +pub(crate) fn parse_icon_palette(values: &BTreeMap) -> Result, String> { + use crate::icon_svg::{ACCENT_NAMES, Palette, ROLE_NAMES}; + if values.is_empty() { + return Ok(None); + } + if values.len() > ROLE_NAMES.len() + ACCENT_NAMES.len() + 1 { + return Err("too many icon palette entries".into()); + } + let mut palette = Palette::default(); + for (name, value) in values { + let ramp = if name == "solid" { Some(0) } else { ACCENT_NAMES.iter().position(|a| *a == name).map(|a| a + 1) }; + let role = ROLE_NAMES.iter().position(|r| *r == name && *r != "gap"); + if ramp.is_none() && role.is_none() { + return Err(format!("`icon_palette.{name}` is not an SVG role or semantic ramp")); + } + let colors = colors_of(value, if ramp.is_some() { 3 } else { 1 }).map_err(|e| format!("`icon_palette.{name}` {e}"))?; + if colors.iter().any(|c| c.a() != 255) { + return Err(format!("`icon_palette.{name}` must use opaque colours")); + } + let rgb = |c: &Color32| [c.r(), c.g(), c.b()]; + if let Some(slot) = ramp.and_then(|r| palette.accents.get_mut(r)) { + if let [a, b, c] = colors.as_slice() { + *slot = Some([rgb(a), rgb(b), rgb(c)]); + } + } else if let Some((slot, color)) = role.and_then(|r| palette.roles.get_mut(r)).zip(colors.first()) { + *slot = Some(rgb(color)); + } + } + Ok(Some(palette)) +} + +fn export_icon_palette(palette: Option) -> BTreeMap { + let mut out = BTreeMap::new(); + let Some(palette) = palette else { return out }; + let hex = |[r, g, b]: [u8; 3]| format!("#{r:02x}{g:02x}{b:02x}"); + for (name, color) in crate::icon_svg::ROLE_NAMES.iter().zip(palette.roles) { + if let Some(rgb) = color { + out.insert((*name).into(), json!(hex(rgb))); + } + } + for (name, ramp) in std::iter::once("solid").chain(crate::icon_svg::ACCENT_NAMES.iter().copied()).zip(palette.accents) { + if let Some(colors) = ramp { + out.insert(name.into(), json!(colors.map(hex))); + } + } + out } /// The settable colours: a key, and where it lives in [`Tokens`]. `one` keys take a colour, @@ -92,6 +252,17 @@ macro_rules! theme_keys { k } + /// Stable identity of resolved colours, including custom themes with the same base/id. + /// Share the token inventory with import/export so added colour tokens cannot be missed. + pub(crate) fn color_key(t: &Tokens) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + $(t.$one.hash(&mut h);)* + $(t.$many.hash(&mut h);)* + $(t.icons.$ione.hash(&mut h);)* + h.finish() + } + fn slot<'a>(t: &'a mut Tokens, key: &str) -> Option<&'a mut [Color32]> { match key { $(stringify!($one) => Some(std::slice::from_mut(&mut t.$one)),)* @@ -106,7 +277,7 @@ macro_rules! theme_keys { // Not colours: the hover lift is a grey step, the analysis colours belong to shaders. let Tokens { $($one: _,)* $($many: _,)* - hover_face_lift: _, analysis: _, icons: _, + hover_face_lift: _, analysis: _, icons: _, borders: _, icon_palette: _, file_action_height: _, file_back_height: _, file_recovery_icon: _, file_grid_gap: _, file_nav_width: _, file_nav_height: _, file_tile_height: _, file_tile_icon: _, file_recent_height: _, file_sample_image_height: _, file_content_inset_x: _, file_content_inset_y: _, file_section_gap: _, font_start_title: _, @@ -175,8 +346,21 @@ fn colors_of(v: &Value, len: usize) -> Result, String> { list.into_iter().map(|c| c.as_str().and_then(parse_color).ok_or_else(|| format!("`{c}` is not a colour such as \"#282828\""))).collect() } +// The application mark now has three faces and no outline. Older theme exports and +// saved preferences carry a fourth, retired outline colour; keep their face colours. +fn colors_for_key(key: &str, value: &Value, len: usize) -> Result, String> { + if key == "logo" + && len == 3 + && let Value::Array(colors) = value + && colors.len() == 4 + { + return colors_of(&Value::Array(colors.iter().take(len).cloned().collect()), len); + } + colors_of(value, len) +} + /// A theme file's text, checked: the format tag, a name, a base and only known keys with valid -/// colours. Nothing is taken from a file with any error; the message names every bad key. +/// colours and bounded radii. Nothing is taken from a file with any error; the message names every bad key. pub fn parse(text: &str) -> Result { if text.len() > MAX_BYTES { return Err(format!("a theme file is at most {} KiB", MAX_BYTES / 1024)); @@ -214,17 +398,49 @@ pub fn from_value(v: &Value) -> Result { match slot(&mut probe, k) { None => bad.push(format!("`{k}` is not a theme colour")), Some(s) => { - if let Err(e) = colors_of(c, s.len()) { + if let Err(e) = colors_for_key(k, c, s.len()) { bad.push(format!("`{k}` {e}")); } } } } + let radii = match v.get("radii") { + None => BTreeMap::new(), + Some(Value::Object(values)) => { + if values.len() > RADIUS_KEYS.len() { + return Err(format!("`radii` has more than {} entries", RADIUS_KEYS.len())); + } + let mut radii = BTreeMap::new(); + for (key, value) in values { + if !RADIUS_KEYS.contains(&key.as_str()) { + bad.push(format!("`radii.{key}` is not a theme radius")); + } else if let Some(n) = value.as_u64().filter(|n| *n <= u64::from(MAX_RADIUS)) { + radii.insert(key.clone(), n as u8); + } else { + bad.push(format!("`radii.{key}` must be an integer from 0 to {MAX_RADIUS}")); + } + } + radii + } + Some(_) => return Err("`radii` must be an object of radius names and values".into()), + }; if !bad.is_empty() { bad.truncate(8); return Err(bad.join("; ")); } - Ok(ThemeFile { id: slug(name), name: name.to_string(), base, colors: src.into_iter().collect() }) + let borders = match v.get("borders") { + None => BTreeMap::new(), + Some(Value::Object(values)) => values.iter().map(|(k, v)| (k.clone(), v.clone())).collect(), + Some(_) => return Err("`borders` must be an object of border roles".into()), + }; + parse_borders(&borders)?; + let icon_palette = match v.get("icon_palette") { + None => BTreeMap::new(), + Some(Value::Object(values)) => values.iter().map(|(k, v)| (k.clone(), v.clone())).collect(), + Some(_) => return Err("`icon_palette` must be an object of SVG roles and ramps".into()), + }; + parse_icon_palette(&icon_palette)?; + Ok(ThemeFile { id: slug(name), name: name.to_string(), base, colors: src.into_iter().collect(), radii, borders, icon_palette }) } /// An id from a name: lower-case letters, digits and dashes. @@ -241,17 +457,27 @@ fn slug(name: &str) -> String { if s.is_empty() { "theme".into() } else { s } } -/// The theme's palette: its base with its colours on top. A colour the saved preferences hold +/// The theme's tokens: its base with its colours and radii on top. A colour the saved preferences hold /// that this version can't read (a hand edit) is skipped, never an error. pub fn resolve(theme: &ThemeFile) -> Tokens { let mut t = if theme.base.is_dark() { Tokens::dark() } else { Tokens::light() }; for (k, v) in &theme.colors { if let Some(s) = slot(&mut t, k) - && let Ok(c) = colors_of(v, s.len()) + && let Ok(c) = colors_for_key(k, v, s.len()) { s.copy_from_slice(&c); } } + for (key, value) in &theme.radii { + if *value <= MAX_RADIUS + && let Some(slot) = radius_slot(&mut t, key) + { + *slot = *value; + } + } + // Malformed hand-edited saved settings cannot introduce non-finite drawing geometry. + t.borders = parse_borders(&theme.borders).unwrap_or_default(); + t.icon_palette = parse_icon_palette(&theme.icon_palette).unwrap_or_default(); t } @@ -265,7 +491,37 @@ pub fn export(name: &str, base: Base, t: &Tokens) -> Value { colors.insert(k.to_string(), v); } } - json!({"format": FORMAT, "name": name, "base": base, "colors": colors}) + let radii: BTreeMap<_, _> = RADIUS_KEYS.into_iter().filter_map(|key| radius_slot(&mut t, key).map(|value| (key, *value))).collect(); + let mut borders = serde_json::Map::new(); + for (key, stroke) in [ + ("primary", t.borders.primary), + ("secondary", t.borders.secondary), + ("tertiary", t.borders.tertiary), + ("container", t.borders.container), + ("control", t.borders.control), + ] { + if let Some(stroke) = stroke { + borders.insert(key.into(), json!({"width": stroke.width, "color": format_color(stroke.color)})); + } + } + if t.borders.secondary_dotted + && let Some(secondary) = borders.get_mut("secondary").and_then(Value::as_object_mut) + { + secondary.insert("pattern".into(), json!("dotted")); + } + if let Some(inset) = t.borders.primary_inset { + borders.insert("primary_inset".into(), json!({"width": inset.stroke.width, "color": format_color(inset.stroke.color), "gap": inset.gap})); + } + if let Some(bevel) = t.borders.bevel { + let shadow = format_color(bevel.shadow); + borders.insert( + "bevel".into(), + json!({"width": bevel.width, "highlight": format_color(bevel.highlight), + "light": format_color(bevel.light), "shadow": shadow, "dark_shadow": format_color(bevel.dark_shadow)}), + ); + } + let icon_palette = export_icon_palette(t.icon_palette); + json!({"format": FORMAT, "name": name, "base": base, "colors": colors, "radii": radii, "borders": borders, "icon_palette": icon_palette}) } /// The theme files that come with SolidCraft (original files; Gruvbox, Tokyo Night, Solarized, Nord, Nosferatu and Catppuccin map open-source palettes, MIT). @@ -339,6 +595,134 @@ pub fn add(custom: &mut Vec, mut theme: ThemeFile) -> Result(serde_json::to_value(&file).unwrap()).unwrap(), file); + } + let saved: ThemeFile = serde_json::from_value(json!({"id":"old","name":"Old","base":"dark","colors":{}})).unwrap(); + assert!(saved.borders.is_empty()); + let partial = + from_value(&json!({"format":FORMAT,"name":"Partial","base":"dark","borders":{"secondary":{"width":2,"color":"#ffffff"}}})).unwrap(); + assert_eq!(resolve(&partial).primary_border(Color32::RED), egui::Stroke::new(1.0, Color32::RED)); + } + + #[test] + fn malformed_border_settings_are_refused_and_saved_settings_are_safe() { + for settings in [ + json!([]), + json!({"unknown":{"width":1,"color":"#ffffff"}}), + json!({"primary":true}), + json!({"primary":{"width":-1,"color":"#ffffff"}}), + json!({"primary":{"width":4.1,"color":"#ffffff"}}), + json!({"primary":{"width":"1","color":"#ffffff"}}), + json!({"primary":{"width":1,"color":"white"}}), + json!({"primary":{"width":1,"color":"#ffffff","gap":1}}), + json!({"primary_inset":{"width":1,"color":"#ffffff","gap":13}}), + json!({"primary_inset":{"width":1,"color":"#ffffff"}}), + ] { + assert!(from_value(&json!({"format":FORMAT,"name":"Bad","base":"dark","borders":settings})).is_err(), "{settings}"); + } + let file = ThemeFile { borders: BTreeMap::from([("primary".into(), json!({"width":1e100,"color":"#ffffff"}))]), ..Default::default() }; + assert_eq!(resolve(&file).borders, crate::theme::Borders::LEGACY); + assert!(parse_borders(&BTreeMap::from([("primary".into(), json!({"width":null,"color":"#ffffff"}))])).is_err()); + assert_eq!(resolve(find("blueprint", &[]).unwrap()).borders.primary.unwrap().color, Color32::WHITE); + for id in ["gruvbox-dark", "gruvbox-light", "gruvbox-dark-soft", "gruvbox-light-soft"] { + assert_eq!(resolve(find(id, &[]).unwrap()).borders, crate::theme::Borders::LEGACY); + } + } + + #[test] + fn bevel_settings_roundtrip_and_reject_invalid_values() { + let bevel = json!({"width":1,"highlight":"#ffffff","light":"#dfdfdf","shadow":"#808080","dark_shadow":"#000000"}); + for base in ["light", "dark"] { + let source = json!({"format":FORMAT,"name":"Beveled","base":base,"borders":{"bevel":bevel}}); + let file = from_value(&source).unwrap(); + let tokens = resolve(&file); + assert_eq!(resolve(&from_value(&export("Beveled", file.base, &tokens)).unwrap()), tokens); + assert_eq!(tokens.input_fill(), tokens.field); + for invalid in [json!(0), json!(-1), json!(2.1), json!("1"), Value::Null] { + let mut bad = source.clone(); + bad["borders"]["bevel"]["width"] = invalid; + assert!(from_value(&bad).is_err()); + } + for key in ["highlight", "light", "shadow", "dark_shadow"] { + let mut bad = source.clone(); + bad["borders"]["bevel"][key] = json!("white"); + assert!(from_value(&bad).is_err()); + } + let mut bad = source; + bad["borders"]["bevel"]["extra"] = json!(true); + assert!(from_value(&bad).is_err()); + } + for theme in builtin() { + assert_eq!(resolve(theme).borders.bevel.is_some(), theme.id == "solidcraft-95", "{}", theme.id); + } + } + + #[test] + fn icon_palette_import_export_is_bounded_and_keeps_legacy_defaults() { + let source = json!({"format":FORMAT,"name":"Icons","base":"dark","icon_palette": { + "ink":"#123456", "line":"#abcdef", "solid":["#c0d0e0","#456789","#123456"], + "sketch":["#c0e0d0","#458967","#125634"] }}); + let file = from_value(&source).unwrap(); + let tokens = resolve(&file); + assert!(tokens.icon_palette.is_some()); + assert_eq!(resolve(&from_value(&export("Icons", file.base, &tokens)).unwrap()), tokens); + for (key, value) in [ + ("gap", json!("#ffffff")), + ("unknown", json!("#ffffff")), + ("ink", json!("#ffffff80")), + ("solid", json!(["#ffffff"])), + ("surface", json!(["#ffffff", "#ffffff", "no"])), + ] { + let mut bad = source.clone(); + bad["icon_palette"][key] = value; + assert!(from_value(&bad).is_err(), "{bad}"); + } + let old = from_value(&json!({"format":FORMAT,"name":"Legacy","base":"light"})).unwrap(); + assert!(resolve(&old).icon_palette.is_none()); + let damaged = ThemeFile { icon_palette: BTreeMap::from([("gap".into(), json!("#ffffff"))]), ..Default::default() }; + assert!(resolve(&damaged).icon_palette.is_none()); + } + + #[test] + fn shipped_themes_have_readable_distinct_workspace_colours_and_icon_palettes() { + use crate::theme::{TabAccent, contrast_ratio}; + assert_eq!(builtin().len(), BUILTIN.len(), "all shipped themes must be valid"); + for file in builtin() { + let t = resolve(file); + assert!(t.icon_palette.is_some(), "{} has no icon palette", file.id); + let mut fills = std::collections::HashSet::new(); + for tab in ["MODEL", "SURFACE", "MESH", "FORM", "SHEET METAL", "RENDER", "SKETCH"] { + let accent = TabAccent::for_tab(&t, tab); + assert!(fills.insert(accent.color), "{} repeats {tab}", file.id); + assert!(contrast_ratio(accent.ink, accent.soft) >= 4.5, "{} {tab} soft text", file.id); + assert!(contrast_ratio(accent.on_fill(&t), accent.color) >= 4.5, "{} {tab} pill text", file.id); + } + } + } + #[test] fn built_in_themes_read_and_differ_from_their_base() { assert_eq!(builtin().len(), BUILTIN.len(), "every built-in theme file reads"); @@ -366,11 +750,15 @@ mod tests { let mut file = export("Older theme", Base::Dark, &Tokens::dark()); let colors = file.get_mut("colors").and_then(Value::as_object_mut).unwrap(); colors.insert("panel".into(), json!("#102030")); + colors.insert("logo".into(), json!(["#102030", "#405060", "#708090", "#000000"])); for key in RETIRED_COLORS { colors.insert((*key).into(), json!("#abcdef")); } let imported = from_value(&file).unwrap(); assert_eq!(resolve(&imported).panel, Color32::from_rgb(0x10, 0x20, 0x30)); + let resolved = resolve(&imported); + assert_eq!(resolved.logo, [parse_color("#102030").unwrap(), parse_color("#405060").unwrap(), parse_color("#708090").unwrap()]); + assert_eq!(export("Current", Base::Dark, &resolved)["colors"]["logo"].as_array().unwrap().len(), 3); assert_eq!(imported.colors.len(), keys().len()); assert!(RETIRED_COLORS.iter().all(|key| !imported.colors.contains_key(*key))); } @@ -438,4 +826,73 @@ mod tests { assert!(add(&mut custom, mk("one too many")).is_err()); assert!(find("gruvbox-dark", &custom).is_some() && find("mine", &custom).is_some() && find("dark", &custom).is_none()); } + + fn radius_values(mut tokens: Tokens) -> Vec { + RADIUS_KEYS.iter().map(|key| *radius_slot(&mut tokens, key).unwrap()).collect() + } + + #[test] + fn optional_radii_preserve_old_files_and_saved_preferences() { + for base in [Base::Dark, Base::Light] { + let file = json!({"format": FORMAT, "name": "Old", "base": base}); + let parsed = from_value(&file).unwrap(); + let defaults = if base.is_dark() { Tokens::dark() } else { Tokens::light() }; + assert_eq!(resolve(&parsed), defaults); + // Preferences serialize ThemeFile directly, without the export format wrapper. + let stored = json!({"id": "old", "name": "Old", "base": base, "colors": {}}); + let old: ThemeFile = serde_json::from_value(stored).unwrap(); + assert_eq!(resolve(&old), defaults); + let partial = from_value(&json!({"format": FORMAT, "name": "Partial", "base": base, "radii": {"control_radius": 0}})).unwrap(); + assert_eq!(resolve(&partial), Tokens { control_radius: 0, ..defaults }); + } + } + + #[test] + fn every_radius_round_trips_through_export_and_preferences_on_either_base() { + for values in [[0; 6], [1, 2, 3, 4, 5, 6], [32; 6]] { + let mut tokens = Tokens::dark(); + for (key, value) in RADIUS_KEYS.iter().zip(values) { + *radius_slot(&mut tokens, key).unwrap() = value; + } + for base in [Base::Dark, Base::Light] { + let parsed = from_value(&export("Shapes", base, &tokens)).unwrap(); + let restored: ThemeFile = serde_json::from_value(serde_json::to_value(&parsed).unwrap()).unwrap(); + assert_eq!(radius_values(resolve(&restored)), values); + assert_eq!(restored.radii.len(), RADIUS_KEYS.len()); + } + } + } + + #[test] + fn invalid_radii_are_refused_and_hand_edited_preferences_resolve_safely() { + for value in [json!(-1), json!(33), json!(256), json!(1.5), json!("4"), json!(null), json!(true), json!([4]), json!({})] { + let error = from_value(&json!({"format": FORMAT, "name": "Bad", "base": "dark", "radii": {"control_radius": value}})).unwrap_err(); + assert!(error.contains("radii.control_radius"), "{error}"); + } + assert!(from_value(&json!({"format": FORMAT, "name": "Bad", "base": "dark", "radii": []})).unwrap_err().contains("radii")); + let error = from_value(&json!({"format": FORMAT, "name": "Bad", "base": "dark", "radii": {"typo": 4, "radius_sm": -1}})).unwrap_err(); + assert!(error.contains("radii.typo") && error.contains("radii.radius_sm")); + let hand_edited = ThemeFile { radii: BTreeMap::from([("radius_sm".into(), 255), ("typo".into(), 6)]), ..Default::default() }; + assert_eq!(resolve(&hand_edited), Tokens::dark()); + } + + #[test] + fn built_in_shapes_match_their_character_and_light_dark_families_agree() { + let values = |id: &str| radius_values(resolve(find(id, &[]).unwrap())); + for id in ["platinum-9", "green-phosphor", "amber-phosphor"] { + assert_eq!(values(id), [0; 6], "{id}"); + } + assert_eq!(values("solidcraft-95"), [3, 3, 4, 4, 3, 3]); + assert_eq!(values("blueprint"), [4, 6, 8, 10, 6, 7]); + assert_eq!(values("cupertino"), [6, 8, 12, 16, 8, 9]); + let defaults = radius_values(Tokens::dark()); + assert_eq!(defaults, radius_values(Tokens::light())); + for suffix in ["", "-hard", "-soft"] { + assert_eq!(values(&format!("gruvbox-dark{suffix}")), defaults); + assert_eq!(values(&format!("gruvbox-light{suffix}")), defaults); + } + for file in builtin().iter().filter(|file| file.radii.is_empty()) { + assert_eq!(radius_values(resolve(file)), defaults, "{}", file.id); + } + } } diff --git a/crates/ui-egui/src/timeline.rs b/crates/ui-egui/src/timeline.rs index 9f2d67e968..546e27898b 100644 --- a/crates/ui-egui/src/timeline.rs +++ b/crates/ui-egui/src/timeline.rs @@ -114,6 +114,7 @@ pub(crate) fn icon_of(k: &FeatureKind) -> &'static str { FeatureKind::Remove { .. } => "delete", FeatureKind::BoundingSolid { .. } => "bounding_solid", FeatureKind::Pipe { .. } => "pipe", + FeatureKind::FrameMember { .. } => "frame_member", FeatureKind::Emboss { .. } => "emboss", FeatureKind::ReplaceFace { .. } => "replace_face", FeatureKind::Align { .. } => "align", @@ -344,7 +345,7 @@ pub fn timeline(app: &mut SolveApp, ui: &mut egui::Ui) { app.shell.history = Some(rect); crate::scenario::publish_rect("history:card", rect); let mut child = ui.new_child(UiBuilder::new().id_salt("sc_timeline").max_rect(rect)); - ui.painter().add(Container::new(ui.ctx(), Surface::Panel).frame(ui.ctx(), t.timeline, crate::frame::Side::Left).paint(rect)); + Container::paint_frame(ui.painter(), Container::new(ui.ctx(), Surface::Panel).frame(ui.ctx(), t.timeline, crate::frame::Side::Left), rect); child.set_clip_rect(rect); timeline_contents(app, &mut child, true); } else { @@ -355,6 +356,8 @@ pub fn timeline(app: &mut SolveApp, ui: &mut egui::Ui) { .show(ui, |ui| { timeline_contents(app, ui, false); }); + let frame = Container::new(ui.ctx(), Surface::Panel).edge_frame(t.timeline); + Container::paint_inset(ui.painter(), response.response.rect - frame.outer_margin, frame); app.shell.history = Some(response.response.rect); crate::scenario::publish_rect("history:strip", response.response.rect); } diff --git a/crates/ui-egui/src/timeline_left.rs b/crates/ui-egui/src/timeline_left.rs index bb5d13b940..3ac582c19b 100644 --- a/crates/ui-egui/src/timeline_left.rs +++ b/crates/ui-egui/src/timeline_left.rs @@ -257,7 +257,7 @@ pub fn left_timeline(app: &mut SolveApp, ui: &mut egui::Ui) { { return; } - ui.painter().hline(ui.max_rect().x_range(), ui.cursor().top(), Stroke::new(1.0, t.border)); + t.secondary_hline(ui.painter(), ui.max_rect().x_range(), ui.cursor().top(), t.border); ui.add_space(t.space_3); egui::ScrollArea::vertical() .id_salt("sc_left_timeline_scroll") @@ -857,7 +857,11 @@ fn vertical_group_row( drop_zones.push((rect, group.id)); slots.push(VerticalSlot { y: rect.bottom(), position: if open { position } else { end_position }, group: Some(group.id), indent_group: None }); if selected || response.hovered() { - ui.painter().rect_filled(rect, 3.0, if selected { crate::theme::TabAccent::current(ui.ctx()).soft } else { t.hover }); + ui.painter().rect_filled( + rect, + t.radius_sm.saturating_sub(1), + if selected { crate::theme::TabAccent::current(ui.ctx()).soft } else { t.hover }, + ); } let offset = if names { depth as f32 * 14.0 } else { 0.0 }; let centre = pos2(rect.left() + 9.0 + offset, rect.center().y); @@ -1158,7 +1162,7 @@ fn vertical_features(app: &mut SolveApp, ui: &mut egui::Ui, names: bool) { folder_drop_zones.iter().find(|(rect, group)| *group == target && rect.shrink2(vec2(0.0, 5.0)).contains(pointer)).map(|(rect, _)| *rect) }); if let Some(rect) = hovered_folder { - ui.painter().rect_stroke(rect.shrink(1.0), 3.0, Stroke::new(2.0, t.sketch_accent), egui::StrokeKind::Inside); + ui.painter().rect_stroke(rect.shrink(1.0), t.radius_sm.saturating_sub(1), Stroke::new(2.0, t.sketch_accent), egui::StrokeKind::Inside); } else { let left = vertical_drop_bar_left(ui.min_rect().left(), folder_target, &groups); crate::ui_kit::history_drop_bar( diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 03921fd2ec..6c57a8ddde 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -257,10 +257,10 @@ pub(crate) fn offer_pill( crate::scenario::publish_target(handle, rect); let hot = response.hovered() || response.has_focus(); let fill = if hot { t.app_bar_hover_button } else { t.accent }; - let radius = PILL_HEIGHT / 2.0; + let radius = PILL_HEIGHT / 2.0 * f32::from(t.radius_xl) / 10.0; ui.painter().rect_filled(rect, radius, fill); if response.has_focus() { - ui.painter().rect_stroke(rect.expand(2.0), radius + 2.0, Stroke::new(1.0, t.app_bar_text), egui::StrokeKind::Outside); + ui.painter().rect_stroke(rect.expand(2.0), Tokens::outset_radius(radius, 2.0), Stroke::new(1.0, t.app_bar_text), egui::StrokeKind::Outside); } let glyph_x = if label.is_some() { rect.left() + icon_x } else { rect.center().x }; let glyph = Rect::from_center_size(pos2(glyph_x, rect.center().y), vec2(16.0, 16.0)); @@ -596,7 +596,7 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) { .fixed_pos(pos2(ctx.data(|d| d.get_temp::(egui::Id::new("sc_file_menu_x"))).unwrap_or(100.0), APP_BAR_HEIGHT)) .order(egui::Order::Foreground) .show(ctx, |ui| { - crate::ui_kit::Menu::frame().show(ui, |ui| { + crate::ui_kit::Menu::show_frame(ui, crate::ui_kit::Menu::frame(), |ui| { ui.set_min_width(190.0); ui.visuals_mut().widgets.inactive.weak_bg_fill = Color32::TRANSPARENT; ui.visuals_mut().widgets.inactive.bg_stroke = Stroke::NONE; @@ -911,7 +911,7 @@ fn ribbon_tabs(app: &mut SolveApp, ui: &mut egui::Ui, row: Rect) { // edges, so their outer corners are concentric with the card's corners. let floating = Container::for_layout(app.ui.layout, Surface::Panel).floating(); let inset = RIBBON_TAB_INSET; - let corner = if floating { t.radius_lg.saturating_sub(inset as u8).max(2) } else { 5 }; + let corner = if floating { t.radius_lg.saturating_sub(inset as u8).max(t.radius_sm / 2) } else { t.control_radius.saturating_sub(1) }; let search = Rect::from_min_size(pos2(row.right() - search_width - inset, row.top() + inset), vec2(search_width, TAB_ROW_HEIGHT - 2.0 * inset)); // The single editor is drawn on the foreground layer by shortcut_box::show, so it // can grow over the ribbon without being clipped to the tab row. @@ -997,9 +997,10 @@ fn ribbon_tabs(app: &mut SolveApp, ui: &mut egui::Ui, row: Rect) { Stroke::new(2.0, panel_accent(&t, &app.ui.tab, "")), ); } else { - ui.painter().line_segment( + t.secondary_line( + ui.painter(), [row.left_bottom() - vec2(0.0, 0.5), row.right_bottom() - vec2(0.0, 0.5)], - Stroke::new(1.0, t.toolbar.lerp_to_gamma(t.border, 0.5)), + t.toolbar.lerp_to_gamma(t.border, 0.5), ); } } @@ -1023,7 +1024,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { let fill = if floating { Color32::TRANSPARENT } else { t.toolbar }; let ribbon = Container::new(ui.ctx(), Surface::Chrome).edge("sc_toolbar", Edge::Top); let frame = Container::new(ui.ctx(), Surface::Chrome).edge_frame(fill); - ribbon.exact_size(height).show_separator_line(false).frame(frame).show(ui, |ui| { + let chrome = ribbon.exact_size(height).show_separator_line(false).frame(frame).show(ui, |ui| { let r = if floating { Rect::from_min_max(cards.workspace.min, cards.ribbon.max) } else { frame.outer_rect(ui.max_rect()) }; let ws = if floating { cards.workspace } else { Rect::from_min_size(r.left_top(), vec2(WORKSPACE_WIDTH, ribbon_height - 1.0)) }; let ribbon_left = if floating { cards.ribbon.left() } else { ws.right() }; @@ -1051,11 +1052,11 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { let accent_inset = if floating { f32::from(t.radius_lg) } else { 0.0 }; painter.rect_filled( Rect::from_min_size(ws.left_top() + vec2(accent_inset, 0.0), vec2((ws.width() - 2.0 * accent_inset).max(0.0), t.space_3)), - egui::CornerRadius { nw: 0, ne: 0, sw: if floating { 2 } else { 0 }, se: if floating { 2 } else { 0 } }, + egui::CornerRadius { nw: 0, ne: 0, sw: if floating { t.radius_sm / 2 } else { 0 }, se: if floating { t.radius_sm / 2 } else { 0 } }, t.accent, ); if !floating { - painter.line_segment([ws.right_top(), ws.right_bottom()], Stroke::new(1.0, t.border)); + t.secondary_line(&painter, [ws.right_top(), ws.right_bottom()], t.border); } let render = crate::render_workspace::active(app); let workspace_icon = if render { "ws_render" } else { "ws_design" }; @@ -1112,7 +1113,13 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { let mut shown = crate::toolbar_custom::buttons(app, &tab, panel, &cmds, promote); // Gallery cells are painted together after the large buttons, in reading order. if tab == "SKETCH" && panel == "CONSTRAIN" { - shown.sort_by_key(|c| crate::workspace::CONSTRAINT_GALLERY.iter().position(|id| *id == c.id).map(|i| i + 1).unwrap_or(0)); + shown.sort_by_key(|c| { + if app.toolbar_custom.in_gallery(&tab, panel, c.id) { + crate::workspace::CONSTRAINT_GALLERY.iter().position(|id| *id == c.id).map(|i| i + 1).unwrap_or(0) + } else { + 0 + } + }); } (panel, cmds, shown) }) @@ -1122,7 +1129,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { .map(|(panel, _, shown)| { let mut widths: Vec<_> = shown .iter() - .filter(|c| !(tab == "SKETCH" && *panel == "CONSTRAIN" && crate::workspace::CONSTRAINT_GALLERY.contains(&c.id))) + .filter(|c| !app.toolbar_custom.in_gallery(&tab, panel, c.id)) .map(|c| { let family = crate::sketch_families::family(c.id) .filter(|f| tab == "SKETCH" && *panel == "CREATE" && f.commands.first().copied() == Some(c.id)); @@ -1135,7 +1142,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { } }) .collect(); - if tab == "SKETCH" && *panel == "CONSTRAIN" && shown.iter().any(|c| crate::workspace::CONSTRAINT_GALLERY.contains(&c.id)) { + if shown.iter().any(|c| app.toolbar_custom.in_gallery(&tab, panel, c.id)) { widths.push(ButtonSize { width: gallery_width, min_width: gallery_width }); } PanelSize { buttons: widths } @@ -1168,7 +1175,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { let width = if finish { finish_width } else { plan.width() }; if finish && finish_right { px = right - finish_width; - painter.line_segment([pos2(px, top), pos2(px, r.bottom() - 1.0)], Stroke::new(1.0, t.border)); + painter.line_segment([pos2(px, top), pos2(px, r.bottom() - 1.0)], t.tertiary_border(t.border)); } let button_widths = &plan.widths; let sketch_create = tab == "SKETCH" && *panel == "CREATE"; @@ -1178,11 +1185,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { let popup_id = ui.id().with(("panel_menu", &tab, panel)); // Dropped on the panel's free space (all of a collapsed panel): the button goes to // its end. - let gallery: Vec<_> = shown - .iter() - .copied() - .filter(|c| tab == "SKETCH" && *panel == "CONSTRAIN" && crate::workspace::CONSTRAINT_GALLERY.contains(&c.id)) - .collect(); + let gallery: Vec<_> = shown.iter().copied().filter(|c| app.toolbar_custom.in_gallery(&tab, panel, c.id)).collect(); let large: Vec<_> = shown.iter().copied().filter(|c| !gallery.iter().any(|g| g.id == c.id)).collect(); let drawn = if collapsed { 0 } else { plan.shown.min(button_widths.len()) }; // Buttons narrower than the panel's minimum width (a one-button panel) are centred. @@ -1312,13 +1315,13 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { if !collapsed && drawn > large.len() { if !gallery.is_empty() { // A quiet rule sets the grid apart from the large button before it. - painter.line_segment( + t.secondary_line( + &painter, [pos2(button_x - 1.5, top + 8.0), pos2(button_x - 1.5, top + button_height.min(46.0))], - Stroke::new(1.0, t.toolbar.lerp_to_gamma(t.border, 0.5)), + t.toolbar.lerp_to_gamma(t.border, 0.5), ); } - for c in &gallery { - let index = crate::workspace::CONSTRAINT_GALLERY.iter().position(|id| *id == c.id).unwrap_or(0); + for (index, c) in gallery.iter().enumerate() { let columns = if labels { 3 } else { 6 }; let cell = Rect::from_min_size( pos2(button_x + (index % columns) as f32 * 27.0, top + 6.0 + (index / columns) as f32 * 23.0), @@ -1330,7 +1333,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { let accent = crate::theme::TabAccent::for_tab(&t, &tab); painter.rect( cell, - 4.0, + t.radius_sm, if active { if accent.color == t.accent { t.panel.lerp_to_gamma(command_accent(c.icon), 0.18) } else { accent.soft } } else if resp.hovered() && info.enabled { @@ -1385,7 +1388,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { let accent = crate::theme::TabAccent::for_tab(&t, &tab); let hot = lresp.hovered() && enabled_panel; if panel_open || hot { - painter.rect_filled(lr, 4.0, if panel_open { accent.soft } else { t.hover }); + painter.rect_filled(lr, t.radius_sm, if panel_open { accent.soft } else { t.hover }); } let label = if finish { app.tr("ui.ribbon_exit").to_string() } else { ribbon_label(app, panel) }; let font = if panel_open || hot { @@ -1435,7 +1438,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { let area = Container::new(ui.ctx(), Surface::Popup).area(popup_id).fixed_pos(lr.left_bottom()).order(egui::Order::Foreground).show( ui.ctx(), |ui| { - section_frame(ui).show(ui, |ui| { + crate::ui_kit::Menu::show_frame(ui, crate::ui_kit::Menu::frame(), |ui| { ui.spacing_mut().item_spacing.y = 0.0; ui.set_min_width(200.0); if *panel == "SELECT" { @@ -1507,7 +1510,7 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { } } px += width; - painter.line_segment([pos2(px, top + 4.0), pos2(px, top + group_height - 3.0)], Stroke::new(1.0, t.border)); + painter.line_segment([pos2(px, top + 4.0), pos2(px, top + group_height - 3.0)], t.tertiary_border(t.border)); px += PANEL_GAP; } if !overflow.is_empty() { @@ -1525,7 +1528,14 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { } for c in cmds { let info = c.info(&app.session); - let response = crate::ui_kit::command_menu_button(ui, c.id, c.label, c.icon, c.shortcut.unwrap_or(""), info.enabled); + let response = crate::ui_kit::command_menu_button( + ui, + c.id, + c.label, + c.icon, + &crate::keymap::display_for(app, c.id).unwrap_or_default(), + info.enabled, + ); crate::scenario::publish_handle(&format!("toolbar:{}", c.id), response.rect.center()); if response.clicked() { app.start(c.id); @@ -1566,6 +1576,14 @@ pub fn toolbar(app: &mut SolveApp, ui: &mut egui::Ui) { crate::context_menu::open_for(app, at, crate::context_menu::Target::ToolbarCommand { id: id.to_string() }); } }); + if floating { + let frame = Container::new(ui.ctx(), Surface::Chrome).frame(ui.ctx(), t.toolbar, crate::frame::Side::Left); + for card in [cards.workspace, cards.ribbon] { + Container::paint_inset(ui.painter(), frame.widget_rect(card), frame); + } + } else { + Container::paint_inset(ui.painter(), chrome.response.rect - frame.outer_margin, frame); + } } /// A short divider between the brand, file actions and document tabs. @@ -1620,8 +1638,7 @@ fn ribbon_tooltip(app: &SolveApp, response: egui::Response, command: &solidcraft let name = app.tr_label(command.label); let name_width = ui.painter().layout_no_wrap(name.to_string(), crate::fonts::semibold(crate::theme::Tokens::get().font_heading), t.text).size().x; - let key_width = command - .shortcut + let key_width = crate::keymap::display_for(app, command.id) .map(|key| { ui.painter().layout_no_wrap(key.to_string(), FontId::monospace(crate::theme::Tokens::get().font_body), t.text_dim).size().x + 24.0 }) @@ -1630,12 +1647,12 @@ fn ribbon_tooltip(app: &SolveApp, response: egui::Response, command: &solidcraft ui.horizontal(|ui| { let (rect, _) = ui.allocate_exact_size(vec2(name_width, 20.0), Sense::hover()); ribbon_text(ui, rect.center(), name, t.font_heading, t.text, true); - if let Some(key) = command.shortcut { + if let Some(key) = crate::keymap::display_for(app, command.id) { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { egui::Frame::NONE .fill(t.panel_header) - .stroke(Stroke::new(1.0, t.border)) - .corner_radius(3.0) + .stroke(t.tertiary_border(t.border)) + .corner_radius(t.radius_sm.saturating_sub(1)) .inner_margin(egui::Margin::symmetric(5, 1)) .show(ui, |ui| { ui.label(egui::RichText::new(key).monospace().color(t.text_dim)); @@ -1661,10 +1678,6 @@ fn ribbon_tooltip(app: &SolveApp, response: egui::Response, command: &solidcraft }) } -fn section_frame(_ui: &egui::Ui) -> egui::Frame { - crate::ui_kit::Menu::frame() -} - fn section_heading(ui: &mut egui::Ui, label: &str, width: f32) { let t = Tokens::get(); let (rect, _) = ui.allocate_exact_size(vec2(width, 24.0), Sense::hover()); @@ -1701,8 +1714,7 @@ fn dropdown_width(app: &SolveApp, ui: &egui::Ui, cmds: &[&'static solidcraft_eng cmds.iter() .map(|c| { let label = ui.painter().layout_no_wrap(app.tr_label(c.label).to_string(), font.clone(), t.text).size().x; - let key = c - .shortcut + let key = crate::keymap::display_for(app, c.id) .map(|k| ui.painter().layout_no_wrap(k.to_string(), FontId::monospace(t.font_button), t.text).size().x + 24.0) .unwrap_or(0.0); 6.0 + 20.0 + 8.0 + label + key + 30.0 @@ -1739,7 +1751,8 @@ fn dropdown_row(app: &SolveApp, ui: &mut egui::Ui, c: &'static solidcraft_engine let mut row = crate::ui_kit::MenuItem::new(c.id, &label, c.icon); row.enabled = info.enabled; row.highlighted = hot; - row.shortcut = c.shortcut.unwrap_or(""); + let shortcut = crate::keymap::display_for(app, c.id).unwrap_or_default(); + row.shortcut = &shortcut; row.paint(ui, rect, &resp, 26.0); if hot { crate::scenario::publish_handle(&format!("dropdown_hot:{}", c.id), rect.center()); @@ -1747,7 +1760,7 @@ fn dropdown_row(app: &SolveApp, ui: &mut egui::Ui, c: &'static solidcraft_engine let p = ui.painter(); if pointer_on || resp.has_focus() { if pin.hovered() { - p.rect_filled(pr, 3.0, t.border.gamma_multiply(0.6)); + p.rect_filled(pr, t.radius_sm.saturating_sub(1), t.border.gamma_multiply(0.6)); } pin_icon(p, pr, pinned, if pinned { t.accent } else { t.text_dim }); crate::scenario::publish_handle(&format!("dropdown_pin:{}", c.id), pr.center()); @@ -1920,6 +1933,14 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) { } // The Keyboard Shortcuts window is waiting for a key: it is not a shortcut. if crate::keymap::capturing(app) { + // Parameters is drawn before Preferences; do not let its footer see this Escape. + if ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) { + crate::keymap::stop_capture(app); + app.esc_handled = true; + } + return; + } + if crate::params_dialog::cancel_on_escape(app, ctx) { return; } // A dimension value that is taking the keyboard: letters belong to it (a parameter name @@ -1978,6 +1999,12 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) { match k { Key::Escape => { app.esc_handled = true; + // End navigation before the underlying tool or dialog; sketch double-Escape + // handling starts with the next press. + if app.viewport.nav.take().is_some() { + app.last_escape = None; + continue; + } // Two quick presses in a sketch finish it (keeping it, like Finish Sketch); // the first one has already cancelled whatever was active. let now = ctx.input(|i| i.time); @@ -2001,7 +2028,7 @@ pub fn shortcuts(app: &mut SolveApp, ctx: &egui::Context) { } continue; } - if crate::sketch_text_move::cancel_drag(app) { + if crate::sketch_text_move::cancel_drag(app) || crate::occurrence_drag::cancel(app) { continue; } if crate::select_other::is_open(app) { @@ -2933,6 +2960,57 @@ mod ribbon_gallery_tests { use super::*; use crate::scenario::{Harness, rect_of}; + #[test] + fn dragged_constraint_becomes_full_button_and_reset_restores_gallery() { + let mut h = Harness::new(); + h.call("ui.resize", json!({"width":1800,"height":900})); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.frames(3); + let id = crate::workspace::CONSTRAINT_GALLERY[0]; + let cell_handle = format!("ribbon:constraint:{id}"); + let button_handle = format!("toolbar:{id}"); + let button = format!("button:{id}"); + let cell = rect_of(&cell_handle).unwrap(); + let count = || crate::scenario::handles("ribbon:constraint:").len(); + let original_count = count(); + h.step(&json!({"drag":[{"handle":button_handle},{"handle":"toolbar:sketch.dimension"}]})).unwrap(); + h.frames(3); + assert!(rect_of(&cell_handle).is_none()); + let full = rect_of(&button).unwrap(); + assert!(full.height() > cell.height()); + assert!(full.left() < rect_of("button:sketch.dimension").unwrap().left()); + assert_eq!(count(), original_count - 1); + let next = format!("ribbon:constraint:{}", crate::workspace::CONSTRAINT_GALLERY[1]); + assert_eq!(rect_of(&next).unwrap().top(), cell.top()); + h.step(&json!({"drag":[{"handle":button_handle},{"handle":next}]})).unwrap(); + h.frames(3); + assert_eq!(rect_of(&cell_handle), Some(cell)); + assert_eq!(count(), original_count); + h.step(&json!({"drag":[{"handle":button_handle},{"handle":"toolbar:sketch.dimension"}]})).unwrap(); + h.frames(3); + assert!(rect_of(&cell_handle).is_none()); + let saved = crate::toolbar_custom::export(&h.app); + crate::toolbar_custom::reset(&mut h.app, "SKETCH"); + crate::toolbar_custom::import(&mut h.app, &saved).unwrap(); + h.frames(3); + assert!(rect_of(&cell_handle).is_none()); + assert_eq!(rect_of(&button).unwrap().height(), full.height()); + // Settings saved before the gallery could change load with the default gallery. + let mut older = h.app.toolbar_custom.prefs(); + older.as_object_mut().unwrap().remove("gallery_buttons"); + let mut loaded = crate::toolbar_custom::ToolbarCustom::default(); + loaded.load(&older); + assert_eq!(loaded.panels, h.app.toolbar_custom.panels); + assert!(loaded.in_gallery("SKETCH", "CONSTRAIN", id)); + crate::toolbar_custom::reset(&mut h.app, "MODEL"); + h.frames(2); + assert!(rect_of(&cell_handle).is_none()); + crate::toolbar_custom::reset(&mut h.app, "SKETCH"); + h.frames(3); + assert_eq!(rect_of(&cell_handle), Some(cell)); + assert_eq!(count(), original_count); + } + #[test] fn constraint_gallery_preserves_button_handles_order_and_pin_actions() { let mut h = Harness::new(); @@ -3071,3 +3149,42 @@ mod fill_width_tests { } } } + +#[cfg(test)] +mod sketch_return_tab_tests { + use super::*; + + /// #3250: finishing a sketch goes back to the tab it was started from, not always to MODEL. + #[test] + fn finishing_a_sketch_returns_to_the_tab_it_started_from() { + for start in ["MODEL", "SURFACE", "ASSEMBLE", "INSERT", "INSPECT", "MANAGE", "VIEW"] { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.app.ui.tab = start.into(); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + assert_eq!(h.app.ui.tab, "SKETCH", "{start}: the sketch tab shows while sketching"); + h.app.run("sketch.finish", json!({})).unwrap(); + assert!(h.app.session.active_sketch.is_none()); + assert_eq!(h.app.ui.tab, start, "{start}: back on the tab the sketch started from"); + } + } + + /// A second sketch started from another tab returns to that tab, and a start tab that is + /// not a normal toolbar tab (a stale name) falls back to MODEL. + #[test] + fn sketch_return_tab_follows_each_sketch_and_falls_back_to_model() { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + for start in ["SURFACE", "INSPECT"] { + h.app.ui.tab = start.into(); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + assert_eq!(h.app.ui.tab, start); + } + let start = "NO SUCH TAB"; + h.app.ui.tab = start.into(); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.app.run("sketch.finish", json!({})).unwrap(); + assert_eq!(h.app.ui.tab, "MODEL", "{start}: falls back to MODEL"); + } +} diff --git a/crates/ui-egui/src/toolbar_custom.rs b/crates/ui-egui/src/toolbar_custom.rs index 98f4ea08f3..7638cd3df2 100644 --- a/crates/ui-egui/src/toolbar_custom.rs +++ b/crates/ui-egui/src/toolbar_custom.rs @@ -24,13 +24,23 @@ pub struct ToolbarCustom { /// `"//"` → the panel's buttons, in order (only panels the user /// changed). pub panels: BTreeMap>, + /// `"/"` → gallery commands moved to full-size buttons. + pub gallery_buttons: BTreeMap>, /// The Customize Toolbar window is open (not kept). pub customizing: bool, } impl ToolbarCustom { + /// Whether a command still uses a compact gallery cell in this panel. + pub fn in_gallery(&self, tab: &str, panel: &str, id: &str) -> bool { + tab == "SKETCH" + && panel == "CONSTRAIN" + && crate::workspace::CONSTRAINT_GALLERY.contains(&id) + && !self.gallery_buttons.get(&format!("{WORKSPACE}/{tab}")).is_some_and(|ids| ids.iter().any(|c| c == id)) + } + pub fn prefs(&self) -> Value { - json!({"version": 2, "panels": self.panels}) + json!({"version": 2, "panels": self.panels, "gallery_buttons": self.gallery_buttons}) } pub fn load(&mut self, v: &Value) { let current = v.get("version").and_then(Value::as_u64) == Some(2); @@ -47,6 +57,19 @@ impl ToolbarCustom { .collect() }) .unwrap_or_default(); + self.gallery_buttons = v + .get("gallery_buttons") + .and_then(Value::as_object) + .map(|o| { + o.iter() + .take(500) + .filter_map(|(k, v)| { + let ids = v.as_array()?.iter().take(100).filter_map(|x| x.as_str().map(str::to_string)).collect(); + Some((k.clone(), ids)) + }) + .collect() + }) + .unwrap_or_default(); if !current { self.migrate(); } @@ -192,6 +215,16 @@ pub fn remove(app: &mut SolveApp, tab: &str, id: &str) { /// Move a button before another one (`before`: None puts it at the end of `panel`). pub fn reorder(app: &mut SolveApp, tab: &str, id: &str, panel: &str, before: Option<&str>) { + // A gallery command dropped on a gallery cell goes (back) into the gallery; dropped anywhere + // else it becomes a full-size button. + if tab == "SKETCH" && crate::workspace::CONSTRAINT_GALLERY.contains(&id) { + let into_gallery = before.is_some_and(|b| app.toolbar_custom.in_gallery(tab, panel, b)); + let ids = app.toolbar_custom.gallery_buttons.entry(format!("{WORKSPACE}/{tab}")).or_default(); + ids.retain(|c| c != id); + if !into_gallery { + ids.push(id.to_string()); + } + } if let Some(from) = pinned_on(app, tab, id) { let mut ids = own(app, tab, from); ids.retain(|x| x != id); @@ -217,6 +250,7 @@ pub fn toggle(app: &mut SolveApp, tab: &str, id: &str) { pub fn reset(app: &mut SolveApp, tab: &str) { let prefix = format!("{WORKSPACE}/{tab}/"); app.toolbar_custom.panels.retain(|k, _| !k.starts_with(&prefix)); + app.toolbar_custom.gallery_buttons.remove(&format!("{WORKSPACE}/{tab}")); } /// The menu of a command (toolbar button, drop-down row, S box row). diff --git a/crates/ui-egui/src/tools.rs b/crates/ui-egui/src/tools.rs index 53a66a83b7..3bf1a155d9 100644 --- a/crates/ui-egui/src/tools.rs +++ b/crates/ui-egui/src/tools.rs @@ -78,7 +78,7 @@ pub struct Tool { pub hover: Option<(Vec2, Option)>, /// Picked entity ids. pub picks: Vec, - /// Horizontal/Vertical point references: point ids or `{midpoint: line_id}`. + /// Horizontal/Vertical point references, or the Dimension tool's midpoint snaps. pub point_picks: Vec, /// Inline dimension boxes for the shape being drawn, the point count they were made for, /// and whether the first box should take the keyboard. @@ -277,24 +277,60 @@ pub fn on_click(app: &mut SolveApp, proj: &Proj, pos: Pos2, shift: bool) { Kind::Ext => crate::sketch_tools::on_click(app, &mut tool, proj, pos), Kind::Dimension => { let hits = pick(app, proj, pos); - let hit = hits.iter().find_map(|h| match h { - Hit::SketchPoint { id, .. } => Some((id.clone(), false)), - Hit::SketchCurve { id, .. } => Some((id.clone(), true)), - _ => None, + // The snap identifies the intended line even when another (projected) edge + // overlaps it and wins the ordinary curve hit. Both picks can be midpoints. + let midpoint = sketch_point_at(app, proj, pos).and_then(|(_, snap)| snap).and_then(|id| { + if id.starts_with("mid:") { return Some(id); } + // An earlier dimension may already have materialized this midpoint. + // Picking that point retains the same midpoint intent on subsequent dimensions. + let state = app.session.world_state(); + let ss = app.session.active_sketch.and_then(|sid| state.sketch(sid))?; + let point = ss.sketch.resolve_point(&id)?; + ss.sketch.constraints.iter().find_map(|c| match c.kind { + solidcraft_engine::sketch::ConstraintKind::Midpoint { p, l } if !c.driven && p == point => { + ss.sketch.curves.get(l).map(|c| format!("mid:{}", c.id)) + } + _ => None, + }) + }).filter(|id| { + let Some(line) = id.strip_prefix("mid:") else { return false }; + let state = app.session.world_state(); + app.session + .active_sketch + .and_then(|sid| state.sketch(sid)) + .is_some_and(|ss| { + ss.sketch.curve_index(line).is_some_and(|l| solidcraft_engine::view::sketch_curve_visible(&ss.sketch, l)) + // A circle/arc and a line still measure their edge-to-edge gap. + && tool.picks.first().is_none_or(|first| { + ss.sketch.curve_index(first).is_none_or(|l| matches!(ss.sketch.shape(l), Some(solidcraft_engine::sketch::Shape::Line { .. }))) + }) + }) + }); + let hit = midpoint.as_deref().and_then(|id| id.strip_prefix("mid:")).map(|id| (id.to_string(), true, true)).or_else(|| { + hits.iter().find_map(|h| match h { + Hit::SketchPoint { sketch, id, .. } if Some(*sketch) == app.session.active_sketch => Some((id.clone(), false, true)), + Hit::SketchCurve { sketch, id, straight, .. } if Some(*sketch) == app.session.active_sketch => { + Some((id.clone(), true, *straight)) + } + _ => None, + }) }); let fresh = |tool: &Tool, id: &String| !tool.picks.contains(id) && tool.picks.len() < 2; if let Some(typed) = crate::dim_view::live() { match hit { // Before anything is typed, another entity joins the dimension, as in the // place-first flow (a distance, an angle between lines). - Some((id, _)) if !typed && fresh(&tool, &id) => { - let mut picks = tool.picks.clone(); - picks.push(id); + Some((id, _, midpoint_target)) if !typed && fresh(&tool, &id) => { + let picks = dimension_picks(&tool, id, midpoint.as_deref(), midpoint_target); match crate::dim_view::begin_live(app, picks.clone(), Some(pos)) { - Ok(true) => tool.picks = picks, - Ok(false) => { - crate::dim_view::cancel(); + Ok(live) => { + if !live { + crate::dim_view::cancel(); + } tool.picks = picks; + if let Some(midpoint) = midpoint { + tool.point_picks.push(json!(midpoint)); + } } Err(error) => app.set_status(error, true), } @@ -306,9 +342,12 @@ pub fn on_click(app: &mut SolveApp, proj: &Proj, pos: Pos2, shift: bool) { } } } - } else if let Some((id, curve)) = hit { + } else if let Some((id, curve, midpoint_target)) = hit { if fresh(&tool, &id) { - tool.picks.push(id); + tool.picks = dimension_picks(&tool, id, midpoint.as_deref(), midpoint_target); + if let Some(midpoint) = midpoint { + tool.point_picks.push(json!(midpoint)); + } // As soon as the picks determine a dimension (one line, circle or arc, or a // second entity), its value takes the keyboard: D, click, type. A pick that // doesn't keeps the place-first flow; placing reports why. @@ -320,7 +359,10 @@ pub fn on_click(app: &mut SolveApp, proj: &Proj, pos: Pos2, shift: bool) { && let Some((at, _)) = sketch_point_at(app, proj, pos) { match crate::dim_view::begin_dimension(app, tool.picks.clone(), at) { - Ok(()) => tool.picks.clear(), + Ok(()) => { + tool.picks.clear(); + tool.point_picks.clear(); + } Err(error) => app.set_status(error, true), } } @@ -331,6 +373,20 @@ pub fn on_click(app: &mut SolveApp, proj: &Proj, pos: Pos2, shift: bool) { } } +/// A single edge still measures its length; a second pick uses the snapped midpoints. +fn dimension_picks(tool: &Tool, id: String, midpoint: Option<&str>, midpoint_target: bool) -> Vec { + let mut picks = tool.picks.clone(); + if midpoint_target + && picks.len() == 1 + && let Some(midpoint) = tool.point_picks.first().and_then(Value::as_str) + && let Some(first) = picks.first_mut() + { + *first = midpoint.to_string(); + } + picks.push(if picks.len() == 1 { midpoint.map(str::to_string).unwrap_or(id) } else { id }); + picks +} + /// Put down the next point of the shape being drawn; the last point runs the command (with /// the typed values as dimension constraints). pub fn place(app: &mut SolveApp, p: (Vec2, Option)) { @@ -399,7 +455,7 @@ pub fn place(app: &mut SolveApp, p: (Vec2, Option)) { } /// Geometry and typed constraints are committed atomically, as one undo step. -fn draw(app: &mut SolveApp, t: &Tool, params: Value) -> Result { +pub(crate) fn draw(app: &mut SolveApp, t: &Tool, params: Value) -> Result { let dimensions = crate::sketch_dims::dimensions(t); if dimensions.as_object().is_none_or(|values| values.is_empty()) { app.run(t.cmd, params) @@ -743,6 +799,7 @@ pub(crate) fn preview_curve( let (start, end) = if span(*b) < span(*c) { (*a, *c) } else { (*c, *a) }; (centre, start, end, centre.dist(*a), (2.0 * hover.dist(*c)).max(1e-3)) }; + let width = slot_width.unwrap_or(width); let t0 = (start - centre).angle(); let sweep = ((end - centre).angle() - t0).rem_euclid(std::f64::consts::TAU); let half = width * 0.5; @@ -881,9 +938,10 @@ pub fn preview(app: &SolveApp, t: &Tool, painter: &egui::Painter, proj: &Proj) { tk.text_dim, ); } - if t.kind == Kind::HorizontalVertical { + if matches!(t.kind, Kind::HorizontalVertical | Kind::Dimension) { for target in &t.point_picks { - let point = if let Some(id) = target.get("midpoint").and_then(Value::as_str) { + let midpoint = target.get("midpoint").and_then(Value::as_str).or_else(|| target.as_str().and_then(|id| id.strip_prefix("mid:"))); + let point = if let Some(id) = midpoint { ss.sketch.curve_index(id).and_then(|l| match ss.sketch.shape(l)? { solidcraft_engine::sketch::Shape::Line { a, b } => Some((a + b) * 0.5), _ => None, diff --git a/crates/ui-egui/src/ui_kit/actions.rs b/crates/ui-egui/src/ui_kit/actions.rs index 33a37e29f3..87388e6960 100644 --- a/crates/ui-egui/src/ui_kit/actions.rs +++ b/crates/ui-egui/src/ui_kit/actions.rs @@ -85,6 +85,7 @@ pub(crate) struct Footer<'a> { pub confirm: bool, /// Parent surface corners; the footer inherits only its two bottom corners. pub corners: Option, + pub secondary_left: bool, } pub(crate) fn footer(ui: &mut Ui, ok_label: &str, cancel_label: &str, ok_enabled: bool, options: Footer<'_>) -> Action { @@ -99,7 +100,7 @@ pub(super) fn footer_with_left( options: Footer<'_>, left: impl FnOnce(&mut Ui), ) -> Action { - let Footer { apply: apply_label, status, selected, danger, confirm, corners } = options; + let Footer { apply: apply_label, status, selected, danger, confirm, corners, secondary_left } = options; let t = Tokens::get(); let ok_enabled = ok_enabled && !ok_label.is_empty(); // The footer is a full-width band (the card has no margin of its own): its own fill, a rule @@ -114,14 +115,26 @@ pub(super) fn footer_with_left( egui::CornerRadius { nw: 0, ne: 0, sw: corners.sw.saturating_sub(1), se: corners.se.saturating_sub(1) }, t.dialog_footer, ); - ui.painter().hline(band.x_range(), band.top(), egui::Stroke::new(1.0, t.dialog_rule)); + t.secondary_hline(ui.painter(), band.x_range(), band.top(), t.dialog_rule); scenario::publish_rect("dialog:footer", band); scenario::publish_count("dialog:ok:enabled", f64::from(ok_enabled && ui.is_enabled())); let inner = band.shrink2(vec2(t.space_12, t.space_10)); scenario::publish_rect("dialog:footer:actions", inner); let mut action = if ui.is_enabled() { keyboard(ui.ctx(), ok_enabled) } else { Action::None }; - let primary_fill = if danger { t.form_error } else { t.form_primary }; - let primary_ink = if danger && crate::theme::contrast_ratio(t.on_accent, primary_fill) < 4.5 { t.tab_on_fill_dark } else { t.on_accent }; + let primary_fill = if t.borders.bevel.is_some() && !danger { + t.control_fill + } else if danger { + t.form_error + } else { + t.form_primary + }; + let primary_ink = if t.borders.bevel.is_some() && !danger { + t.form_ink + } else if danger && crate::theme::contrast_ratio(t.on_accent, primary_fill) < 4.5 { + t.tab_on_fill_dark + } else { + t.on_accent + }; let mut actions_left = inner.right(); let row = ui.scope_builder(egui::UiBuilder::new().max_rect(inner).layout(egui::Layout::left_to_right(egui::Align::Center)), |ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -152,14 +165,18 @@ pub(super) fn footer_with_left( } else { t.control_fill }) - .stroke(egui::Stroke::new( - if primary && ok_enabled { 0.0 } else { 1.0 }, - if marked && !primary { t.form_primary } else { t.control_border }, - )) + .stroke(if primary && ok_enabled { + egui::Stroke::NONE + } else if marked && !primary { + egui::Stroke::new(1.0, t.form_primary) + } else { + t.control_border_stroke(t.control_border) + }) .wrap_mode(egui::TextWrapMode::Extend) .corner_radius(t.control_radius) .min_size(vec2(if primary { t.space_24 * 3.0 } else { 0.0 }, t.form_button_height)), ); + t.button_bevel(ui, &response, false); pointer_on_hover(ui, &response); actions_left = actions_left.min(response.rect.left()); let key = match kind { @@ -178,12 +195,17 @@ pub(super) fn footer_with_left( let left_rect = egui::Rect::from_min_max(inner.min, egui::pos2((actions_left - t.space_8).max(inner.left()), inner.bottom())); ui.scope_builder(egui::UiBuilder::new().max_rect(left_rect).layout(egui::Layout::left_to_right(egui::Align::Center)), |ui| { if let Some(label) = apply_label.filter(|_| confirm) { - let response = ui.add( - egui::Button::new(crate::i18n::tr_label(label)) - .frame(false) - .selected(selected == Some(Action::Apply)) - .min_size(vec2(0.0, t.form_button_height)), - ); + let marked = selected == Some(Action::Apply); + let mut button = + egui::Button::new(crate::i18n::tr_label(label)).frame(secondary_left).selected(marked).min_size(vec2(0.0, t.form_button_height)); + if secondary_left { + ui.spacing_mut().button_padding = vec2(t.space_12, 0.0); + button = button + .fill(if marked { t.selected_bg } else { t.control_fill }) + .stroke(egui::Stroke::new(1.0, if marked { t.form_primary } else { t.control_border })) + .corner_radius(t.control_radius); + } + let response = ui.add(button); pointer_on_hover(ui, &response); scenario::publish_target("dialog:apply", response.rect); scenario::publish_handle(&format!("dialog:{label}"), response.rect.center()); @@ -213,7 +235,7 @@ pub(crate) struct PairButton<'a> { /// Equal-width secondary actions with optional icons and selected state. Tokens: `form_button_height, control_*, selected_bg, hover, font_label, text, icon/fill, accent, sketch_footer_icon_size, space_4/6/10`. pub(crate) fn button_pair(ui: &mut egui::Ui, key: &str, states: [bool; 2], buttons: [PairButton<'_>; 2]) -> (bool, bool) { let t = Tokens::get(); - ui.painter().hline(ui.max_rect().x_range(), ui.cursor().top(), egui::Stroke::new(1.0, t.dialog_rule)); + t.secondary_hline(ui.painter(), ui.max_rect().x_range(), ui.cursor().top(), t.dialog_rule); let mut clicked = [false; 2]; egui::Frame::NONE.inner_margin(egui::Margin::symmetric(t.space_10 as i8, t.space_6 as i8)).show(ui, |ui| { ui.horizontal(|ui| { @@ -232,7 +254,7 @@ pub(crate) fn button_pair(ui: &mut egui::Ui, key: &str, states: [bool; 2], butto } else { t.control_fill }, - egui::Stroke::new(1.0, t.control_border), + t.control_border_stroke(t.control_border), egui::StrokeKind::Inside, ); let label = crate::i18n::tr_label(button.label); @@ -253,6 +275,7 @@ pub(crate) fn button_pair(ui: &mut egui::Ui, key: &str, states: [bool; 2], butto } painter.galley(egui::pos2(left + icon_width, rect.center().y - galley.size().y / 2.0), galley, t.form_ink); response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), on, label)); + t.button_bevel(ui, &response, on); pointer_on_hover(ui, &response); crate::scenario::publish_target(&format!("{key}:{}", button.key), rect); if let Some(value) = clicked.get_mut(index) { @@ -277,6 +300,10 @@ pub enum Button { fn button_widget(label: egui::WidgetText, options: Button) -> egui::Button<'static> { let t = Tokens::get(); + if t.borders.bevel.is_some() && !matches!(options, Button::Ghost) { + let button = egui::Button::new(label.color(t.form_ink)).fill(t.control_fill).stroke(t.control_border_stroke(t.control_border)); + return if matches!(options, Button::Small | Button::SmallSelected) { button.small() } else { button }; + } match options { Button::Small => egui::Button::new(label).small(), Button::Primary => { @@ -298,6 +325,9 @@ pub fn button(ui: &mut Ui, label: impl Into, options: Button) Button::Enabled(enabled) => ui.add_enabled_ui(enabled, |ui| ui.add(button_widget(crate::i18n::tr_label(&key).into(), options))).inner, _ => ui.add(button_widget(label, options)), }; + if !matches!(options, Button::Ghost) { + Tokens::get().button_bevel(ui, &response, matches!(options, Button::SmallSelected)); + } pointer_on_hover(ui, &response); let key = if matches!(options, Button::Enabled(_)) { crate::i18n::tr_label(&key) } else { &key }; publish_button(ui, &response, key); @@ -323,13 +353,15 @@ pub(crate) fn button_at(ui: &mut Ui, id: &str, rect: egui::Rect, label: impl Int }, ) .inner; + if !matches!(options, Button::Ghost) { + Tokens::get().button_bevel(ui, &response, matches!(options, Button::SmallSelected)); + } pointer_on_hover(ui, &response); publish_button(ui, &response, &key); response } /// Accent action outside the footer, using the same shared button component. -#[cfg(any(not(target_arch = "wasm32"), test))] pub(crate) fn primary_button(ui: &mut Ui, label: &str) -> Response { let response = button(ui, crate::i18n::tr_label(label), Button::Primary); scenario::publish_target(&format!("dialog:button:{label}"), response.rect); @@ -340,9 +372,14 @@ pub(crate) fn primary_button(ui: &mut Ui, label: &str) -> Response { /// `form_primary, on_accent, form_button_height, font_label`. pub(crate) fn wide_primary_button(ui: &mut Ui, label: &str) -> Response { let t = Tokens::get(); - let text = RichText::new(crate::i18n::tr_label(label)).size(t.font_label).color(t.on_accent); + let text = RichText::new(crate::i18n::tr_label(label)).size(t.font_label).color(if t.borders.bevel.is_some() { t.form_ink } else { t.on_accent }); let size = vec2(ui.available_width(), t.form_button_height); - let response = ui.add(egui::Button::new(crate::fonts::RichTextExt::bold_face(text)).fill(t.form_primary).min_size(size)); + let response = ui.add( + egui::Button::new(crate::fonts::RichTextExt::bold_face(text)) + .fill(if t.borders.bevel.is_some() { t.control_fill } else { t.form_primary }) + .min_size(size), + ); + t.button_bevel(ui, &response, false); pointer_on_hover(ui, &response); response } @@ -417,7 +454,7 @@ pub(crate) fn icon_label_button(ui: &mut Ui, label: &str, icon: &str, enabled: b rect, t.control_radius, if response.hovered() { t.hover } else { t.control_fill }, - egui::Stroke::new(1.0, t.control_border), + t.control_border_stroke(t.control_border), egui::StrokeKind::Inside, ); let icon_rect = diff --git a/crates/ui-egui/src/ui_kit/assistant.rs b/crates/ui-egui/src/ui_kit/assistant.rs index f6e162d4e1..486f42be7e 100644 --- a/crates/ui-egui/src/ui_kit/assistant.rs +++ b/crates/ui-egui/src/ui_kit/assistant.rs @@ -51,7 +51,7 @@ pub(crate) fn assistant_header(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) - ui.set_min_height(t.form_close_size); add(ui) }); - ui.painter().hline(output.response.rect.x_range(), output.response.rect.bottom(), egui::Stroke::new(1.0, t.dialog_rule)); + t.secondary_hline(ui.painter(), output.response.rect.x_range(), output.response.rect.bottom(), t.dialog_rule); output } @@ -76,7 +76,7 @@ pub(crate) fn assistant_bubble(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) - ui.add_space(width - bubble_width); egui::Frame::NONE .fill(t.accent_soft) - .corner_radius(egui::CornerRadius { nw: t.radius_xl, ne: t.radius_xl, sw: t.radius_xl, se: t.space_3 as u8 }) + .corner_radius(egui::CornerRadius { nw: t.radius_xl, ne: t.radius_xl, sw: t.radius_xl, se: t.radius_sm.saturating_sub(1) }) .inner_margin(egui::Margin::symmetric((t.space_8 + t.space_3) as i8, t.space_8 as i8)) .show(ui, |ui| { let content_width = (bubble_width - (t.space_8 + t.space_3) * 2.0).max(0.0); @@ -156,7 +156,7 @@ pub(crate) fn assistant_tool_frame(ui: &mut Ui, failed: bool, add: impl FnOnc let t = Tokens::get(); egui::Frame::NONE .fill(if failed { t.form_error_fill } else { t.dialog_body }) - .stroke(egui::Stroke::new(1.0, t.dialog_rule)) + .stroke(t.container_border(t.dialog_rule)) .corner_radius(t.radius_lg) .inner_margin(t.space_3) .show(ui, |ui| { @@ -178,7 +178,7 @@ pub(crate) fn assistant_tool_detail(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> let t = Tokens::get(); egui::Frame::NONE .fill(t.control_fill) - .stroke(egui::Stroke::new(1.0, t.dialog_rule)) + .stroke(t.container_border(t.dialog_rule)) .corner_radius(t.control_radius) .inner_margin(egui::Margin::symmetric((t.space_6 + t.space_3) as i8, t.space_6 as i8)) .show(ui, add) @@ -251,7 +251,7 @@ pub(crate) fn assistant_composer(ui: &mut Ui, focused: bool, add: impl FnOnce let t = Tokens::get(); egui::Frame::NONE .fill(t.control_fill) - .stroke(egui::Stroke::new(1.0, if focused { t.form_primary } else { t.control_border })) + .stroke(if focused { egui::Stroke::new(1.0, t.form_primary) } else { t.control_border_stroke(t.control_border) }) .corner_radius(t.radius_xl) .inner_margin(egui::Margin::symmetric(t.space_10 as i8, t.space_8 as i8)) .show(ui, |ui| { @@ -293,7 +293,7 @@ pub(crate) fn assistant_composer_footer(ui: &mut Ui, add: impl FnOnce(&mut Ui .corner_radius(egui::CornerRadius { nw: 0, ne: 0, sw: t.radius_xl.saturating_sub(1), se: t.radius_xl.saturating_sub(1) }) .inner_margin(t.space_10) .show(ui, add); - ui.painter().hline(output.response.rect.x_range(), output.response.rect.top(), egui::Stroke::new(1.0, t.dialog_rule)); + t.secondary_hline(ui.painter(), output.response.rect.x_range(), output.response.rect.top(), t.dialog_rule); output } @@ -321,7 +321,7 @@ pub(crate) fn assistant_empty(ui: &mut Ui, title: &str, body: &str, add: impl pub(crate) fn assistant_banner(ui: &mut Ui, tone: AssistantTone, add: impl FnOnce(&mut Ui) -> R) -> InnerResponse { let t = Tokens::get(); let (_, fill) = tone.colors(&t); - egui::Frame::NONE.fill(fill).stroke(egui::Stroke::new(1.0, t.dialog_rule)).corner_radius(t.radius_lg).inner_margin(t.space_10).show(ui, |ui| { + egui::Frame::NONE.fill(fill).stroke(t.container_border(t.dialog_rule)).corner_radius(t.radius_lg).inner_margin(t.space_10).show(ui, |ui| { ui.set_min_width(ui.available_width().max(0.0)); ui.spacing_mut().item_spacing.y = t.space_8; add(ui) diff --git a/crates/ui-egui/src/ui_kit/assistant_entry.rs b/crates/ui-egui/src/ui_kit/assistant_entry.rs index 80e6f2fd22..5bf5fac7c4 100644 --- a/crates/ui-egui/src/ui_kit/assistant_entry.rs +++ b/crates/ui-egui/src/ui_kit/assistant_entry.rs @@ -59,8 +59,8 @@ pub(crate) fn assistant_entry(ui: &egui::Ui, response: &Response, state: Assista let font = FontId::proportional(t.font_badge); let galley = ui.painter().layout_no_wrap(text, font, t.app_bar); let badge = Rect::from_center_size(c, vec2((galley.size().x + t.space_6).max(t.space_14), t.space_14)); - ui.painter().rect_filled(badge.expand(1.5), 8.5, ring); - ui.painter().rect_filled(badge, 7.0, t.warning); + ui.painter().rect_filled(badge.expand(1.5), Tokens::outset_radius(f32::from(t.sketch_mode_radius), 1.5), ring); + ui.painter().rect_filled(badge, t.sketch_mode_radius, t.warning); ui.painter().galley(badge.center() - galley.size() / 2.0, galley, t.app_bar); } } diff --git a/crates/ui-egui/src/ui_kit/checks.rs b/crates/ui-egui/src/ui_kit/checks.rs index bb42827e46..e17bd2c47e 100644 --- a/crates/ui-egui/src/ui_kit/checks.rs +++ b/crates/ui-egui/src/ui_kit/checks.rs @@ -23,16 +23,27 @@ pub(super) fn paint_box(ui: &Ui, square: Rect, response: &Response, on: bool, mi let t = Tokens::get(); let selected = on || mixed; let border = if selected || response.hovered() || response.has_focus() { t.form_primary } else { t.control_border }; - let fill = if selected { + let fill = if t.borders.bevel.is_some() { + t.input_fill() + } else if selected { t.form_primary } else if response.hovered() { t.hover } else { t.control_fill }; - ui.painter().rect(square, t.radius_sm, fill, egui::Stroke::new(1.0, border), egui::StrokeKind::Inside); + ui.painter().rect( + square, + t.radius_sm, + fill, + if selected || response.hovered() || response.has_focus() { egui::Stroke::new(1.0, border) } else { t.control_border_stroke(border) }, + egui::StrokeKind::Inside, + ); + if let Some(bevel) = t.borders.bevel { + bevel.paint(ui.painter(), square, t.radius_sm, true, response.enabled()); + } if selected { - paint_check_glyph(ui.painter(), square, mixed, t.on_accent); + paint_check_glyph(ui.painter(), square, mixed, if t.borders.bevel.is_some() { t.form_ink } else { t.on_accent }); } if response.has_focus() { ui.painter().rect_stroke(square.expand(t.space_2), t.radius_sm, egui::Stroke::new(1.0, t.form_primary), egui::StrokeKind::Outside); diff --git a/crates/ui-egui/src/ui_kit/choice.rs b/crates/ui-egui/src/ui_kit/choice.rs index a3bc35a7b5..82595ea9f8 100644 --- a/crates/ui-egui/src/ui_kit/choice.rs +++ b/crates/ui-egui/src/ui_kit/choice.rs @@ -29,7 +29,12 @@ pub(crate) fn segmented(ui: &mut Ui, selected: &mut usize, options: &[&str], con ui.painter().layout_no_wrap(crate::i18n::tr_label(option).to_string(), crate::fonts::semibold(t.font_label), t.form_ink).size().x }) .fold(0.0, f32::max); - if !intrinsic && !proportional && !compact && widest + 2.0 * t.space_8 > segment_width { + // Judged on the whole row, which is what a scrollbar narrows, not on one segment. + if !intrinsic + && !proportional + && !compact + && !super::fits_steadily(ui, label(ui), (widest + 2.0 * t.space_8) * options.len() as f32, segment_width * options.len() as f32) + { let before = *selected; let id = format!("segments_fallback_{}", label(ui)); dropdown(ui, &id, selected, options); @@ -75,7 +80,7 @@ pub(crate) fn segmented(ui: &mut Ui, selected: &mut usize, options: &[&str], con ); } if index > 0 { - ui.painter().vline(segment.left(), segment.y_range(), egui::Stroke::new(1.0, t.control_border)); + ui.painter().vline(segment.left(), segment.y_range(), t.control_border_stroke(t.control_border)); } let font = if on { crate::fonts::semibold(t.font_label) } else { egui::FontId::proportional(t.font_label) }; let galley = ui.painter().layout_no_wrap((*option).to_string(), font.clone(), t.form_ink); @@ -96,6 +101,7 @@ pub(crate) fn segmented(ui: &mut Ui, selected: &mut usize, options: &[&str], con }, false, ); + t.button_bevel_with_radius(ui, &response, on, rounding); response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), on, *option)); scenario::publish_handle(&format!("dialog:{}:{option}", label(ui)), segment.center()); if response.on_hover_text(*option).clicked() { @@ -103,7 +109,9 @@ pub(crate) fn segmented(ui: &mut Ui, selected: &mut usize, options: &[&str], con clicked = true; } } - ui.painter().rect_stroke(rect, t.control_radius, egui::Stroke::new(1.0, t.control_border), egui::StrokeKind::Inside); + if t.borders.bevel.is_none() { + ui.painter().rect_stroke(rect, t.control_radius, t.control_border_stroke(t.control_border), egui::StrokeKind::Inside); + } scenario::publish_target_as(&format!("dialog:{}", label(ui)), &format!("dialog:segments:{}", label(ui)), rect); clicked } @@ -140,12 +148,13 @@ pub(crate) fn segmented_icons(ui: &mut Ui, selected: &mut usize, options: &[Icon ui.painter().rect_filled(segment, rounding, if on { t.selected_bg } else { t.hover }); } if index > 0 { - ui.painter().vline(segment.left(), segment.y_range(), egui::Stroke::new(1.0, t.control_border)); + ui.painter().vline(segment.left(), segment.y_range(), t.control_border_stroke(t.control_border)); } // A square icon area inside the segment: dash gaps and dots need the room to read apart. let side = (segment.width().min(segment.height()) - t.space_4).max(t.space_12); let icon_rect = egui::Rect::from_center_size(segment.center(), vec2(side, side)); crate::icons::paint_icon(ui.painter(), icon_rect, option.icon, f32::from(t.icon.a()) / 255.0); + t.button_bevel_with_radius(ui, &response, on, rounding); response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), on, option.key)); scenario::publish_handle(&format!("dialog:{}:{}", label(ui), option.key), segment.center()); let response = response.on_hover_text(&option.tip); @@ -154,7 +163,9 @@ pub(crate) fn segmented_icons(ui: &mut Ui, selected: &mut usize, options: &[Icon clicked = true; } } - ui.painter().rect_stroke(rect, t.control_radius, egui::Stroke::new(1.0, t.control_border), egui::StrokeKind::Inside); + if t.borders.bevel.is_none() { + ui.painter().rect_stroke(rect, t.control_radius, t.control_border_stroke(t.control_border), egui::StrokeKind::Inside); + } scenario::publish_target_as(&format!("dialog:{}", label(ui)), &format!("dialog:segments:{}", label(ui)), rect); clicked } @@ -250,11 +261,12 @@ pub(crate) fn tool_choices<'a>(ui: &mut Ui, label: &str, current: &str, choices: enabled, egui::Button::new("") .fill(fill) - .stroke(egui::Stroke::new(if on { t.sketch_mode_border } else { 1.0 }, if on { t.sketch_accent } else { t.control_border })) + .stroke(if on { egui::Stroke::new(t.sketch_mode_border, t.sketch_accent) } else { t.control_border_stroke(t.control_border) }) .corner_radius(t.sketch_mode_radius) .min_size(vec2(t.sketch_mode_size, t.sketch_mode_size)), ); crate::icons::paint_icon(ui.painter(), response.rect.shrink(t.space_6), choice.icon, f32::from(t.icon.a()) / 255.0); + t.button_bevel(ui, &response, on); scenario::publish_target(&format!("sketch_palette:feature:{}", choice.key), response.rect); response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, enabled, on, choice.label)); if response.on_hover_text(choice.label).clicked() && enabled { @@ -274,6 +286,7 @@ pub(crate) fn toggle_icon(ui: &mut Ui, row: &str, label: &str, selected: &mut bo ui.add(egui::Button::new("").selected(*selected).min_size(vec2(t.space_24 + t.space_4, t.control_height)).sense(egui::Sense::CLICK)); crate::icons::paint_icon(ui.painter(), response.rect.shrink(t.space_3), icon, f32::from(t.icon.a()) / 255.0); response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, *selected, crate::i18n::tr_label(label))); + t.button_bevel(ui, &response, *selected); crate::scenario::publish_handle(&format!("dialog:{row}:{label}"), response.rect.center()); if response.clone().on_hover_text(crate::i18n::tr_label(label)).clicked() { *selected = !*selected; @@ -329,7 +342,7 @@ pub(crate) fn color_swatch( } } } - ui.painter().rect(rect, t.control_radius, color, egui::Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + ui.painter().rect(rect, t.control_radius, color, t.control_border_stroke(t.border), egui::StrokeKind::Inside); }) .response; let response = ui.interact(response.rect, id.with("click"), egui::Sense::click()).on_hover_text(tip); @@ -382,7 +395,7 @@ pub(crate) fn popup_list( let area = popup.order(egui::Order::Tooltip).fixed_pos(anchor.left_bottom() + egui::vec2(0.0, t.space_2)).show(ctx, |ui| { Container::popup_frame(ui.style()) .fill(t.control_fill) - .stroke(egui::Stroke::new(1.0, t.dialog_border)) + .stroke(t.primary_border(t.dialog_border)) .corner_radius(t.radius_lg) .inner_margin(t.space_4) .show(ui, |ui| { @@ -444,7 +457,7 @@ pub(crate) fn color_thumbnail(ui: &mut Ui, id: egui::I let t = Tokens::get(); ui.dnd_drag_source(id, payload, |ui| { let (rect, _) = ui.allocate_exact_size(vec2(t.space_16, t.space_16), egui::Sense::hover()); - ui.painter().rect(rect, t.radius_sm, color, egui::Stroke::new(1.0, t.border), egui::StrokeKind::Inside); + ui.painter().rect(rect, t.radius_sm, color, t.control_border_stroke(t.border), egui::StrokeKind::Inside); }) .response .on_hover_text(tip) diff --git a/crates/ui-egui/src/ui_kit/choice_tests.rs b/crates/ui-egui/src/ui_kit/choice_tests.rs index 913ee95d08..1e9aafcdc5 100644 --- a/crates/ui-egui/src/ui_kit/choice_tests.rs +++ b/crates/ui-egui/src/ui_kit/choice_tests.rs @@ -43,3 +43,87 @@ fn optional_field_keeps_empty_default_and_options_use_two_columns() { frame(&ctx, pointer(first, false), &mut draw); assert!(flip && copy); } + +/// A scrollbar that takes its width from the page, coming and going, narrows the row by that width; the +/// choice between the segments and the dropdown must not flip because of that, only because the row +/// really is too narrow. +#[test] +fn segments_stay_segments_when_a_scrollbar_appears_and_disappears() { + let options = ["Origin", "View centre", "Point under the cursor"]; + let solid = egui::style::ScrollStyle::solid(); + let scrollbar = solid.allocated_width(); + assert!(scrollbar > 0.0); + let fresh = || { + let ctx = crate::fonts::test_context(); + ctx.all_styles_mut(|style| style.spacing.scroll = solid); + ctx + }; + let form = |ctx: &egui::Context, width: f32| { + scenario::clear_handles(); + let mut selected = 0; + frame(ctx, vec![], &mut |ui: &mut Ui| { + ui.allocate_ui(egui::vec2(width, 60.0), |ui| { + row(ui, "Orbit around", |ui| segmented(ui, &mut selected, &options, Default::default())); + }); + }); + scenario::rect_of("dialog:segments:Orbit around").is_some() + }; + // The width where the segments just fit, found without any memory of an earlier frame being + // anything but the generous start (so it is the width less one scrollbar). + let strict = (200..1200).map(|w| w as f32).find(|&w| form(&fresh(), w) && form(&fresh(), w + scrollbar + 1.0)).expect("a width fits"); + // From wherever it starts, with and without the scrollbar's width taken off, the form holds. + for start in [strict + scrollbar, strict + scrollbar + 3.0] { + let ctx = fresh(); + for width in [start, start - scrollbar, start, start - scrollbar, start - scrollbar] { + assert!(form(&ctx, width), "start {start}: segments at {width}"); + } + } + // Really too narrow (more than the scrollbar short), it is the dropdown, and it comes back when widened. + let ctx = fresh(); + assert!(!form(&ctx, strict - 3.0 * scrollbar) && !form(&ctx, strict - 3.0 * scrollbar), "far too narrow: the dropdown"); + assert!(form(&ctx, strict + scrollbar + 3.0), "wide again: segments"); +} + +#[test] +fn beveled_segments_share_fill_corners_at_internal_joins() { + use crate::theme::Tokens; + use egui::{Color32, Shape}; + let mut t = Tokens::light(); + t.control_radius = 8; + t.borders.bevel = Some(crate::theme::Bevel { + width: 0.5, + highlight: Color32::WHITE, + light: Color32::LIGHT_GRAY, + shadow: Color32::GRAY, + dark_shadow: Color32::BLACK, + }); + let _restore = crate::theme::scoped_tokens(t); + for selected in 0..3 { + let ctx = crate::fonts::test_context(); + let mut selected = selected; + let output = crate::test_support::frame(&ctx, egui::RawInput::default(), |ui| { + ui.set_width(300.0); + segmented(ui, &mut selected, &["Left", "Middle", "Right"], Default::default()); + }); + let fill = output + .shapes + .iter() + .find_map(|s| match &s.shape { + Shape::Rect(r) if r.fill == t.selected_bg => Some(r), + _ => None, + }) + .expect("selected segment fill"); + for edge in [fill.rect.left(), fill.rect.right()] { + let internal = if edge == fill.rect.left() { selected > 0 } else { selected < 2 }; + if internal { + // A rounded bevel used to curve away while selection filled the square + // internal corner. Its top band must now reach the segment join. + assert!(output.shapes.iter().any(|s| match &s.shape { + Shape::LineSegment { points, .. } => + points.iter().any(|p| (p.y - fill.rect.top() - 0.25).abs() < 0.01 && (p.x - edge).abs() <= 0.26), + _ => false, + })); + } + } + } +} diff --git a/crates/ui-egui/src/ui_kit/color.rs b/crates/ui-egui/src/ui_kit/color.rs index 8ac77c2ca5..051027551e 100644 --- a/crates/ui-egui/src/ui_kit/color.rs +++ b/crates/ui-egui/src/ui_kit/color.rs @@ -17,7 +17,7 @@ pub(crate) fn color_field(ui: &mut Ui, rgb: &mut [u8; 3]) -> Response { rect, t.control_radius, crate::theme::srgb(rgb.map(|c| f32::from(c) / 255.0)), - egui::Stroke::new(1.0, t.control_border), + t.control_border_stroke(t.control_border), egui::StrokeKind::Inside, ); response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ColorButton, ui.is_enabled(), i18n::tr("color.edit"))); @@ -30,7 +30,7 @@ pub(crate) fn color_field(ui: &mut Ui, rgb: &mut [u8; 3]) -> Response { .frame( Container::popup_frame(ui.style()) .fill(t.control_fill) - .stroke(egui::Stroke::new(1.0, t.border)) + .stroke(t.control_border_stroke(t.border)) .corner_radius(t.radius_xl) .inner_margin(t.space_12), ) @@ -165,7 +165,7 @@ pub(crate) fn color_popover(ui: &mut Ui, rgb: &mut [u8; 3]) -> bool { strip, t.control_radius, crate::theme::srgb(rgb.map(|c| f32::from(c) / 255.0)), - egui::Stroke::new(1.0, t.control_border), + t.control_border_stroke(t.control_border), egui::StrokeKind::Inside, ); publish("color:current", &response); @@ -210,7 +210,12 @@ fn gradient(ui: &Ui, rect: egui::Rect, color: impl Fn(f32, f32) -> Color32) { } } ui.painter().add(egui::Shape::mesh(mesh)); - ui.painter().rect_stroke(rect, Tokens::get().control_radius, egui::Stroke::new(1.0, Tokens::get().control_border), egui::StrokeKind::Inside); + ui.painter().rect_stroke( + rect, + Tokens::get().control_radius, + Tokens::get().control_border_stroke(Tokens::get().control_border), + egui::StrokeKind::Inside, + ); } #[cfg(test)] diff --git a/crates/ui-egui/src/ui_kit/confirm.rs b/crates/ui-egui/src/ui_kit/confirm.rs index 64fa7cfad5..c28e235c8b 100644 --- a/crates/ui-egui/src/ui_kit/confirm.rs +++ b/crates/ui-egui/src/ui_kit/confirm.rs @@ -18,6 +18,7 @@ pub(crate) struct Confirm<'a> { ok: &'a str, cancel: &'a str, left: Option<&'a str>, + secondary_left: bool, selected: Option, detail: Option<&'a str>, } @@ -32,6 +33,7 @@ impl<'a> Confirm<'a> { ok: "OK", cancel: "Cancel", left: None, + secondary_left: false, selected: None, detail: None, } @@ -47,6 +49,12 @@ impl<'a> Confirm<'a> { self } + /// Frame the optional left action as a secondary button instead of a low-emphasis action. + pub(crate) fn secondary_left(mut self) -> Self { + self.secondary_left = true; + self + } + pub(crate) fn selected(mut self, selected: Action) -> Self { self.selected = Some(selected); self @@ -126,7 +134,14 @@ impl<'a> Confirm<'a> { self.ok, self.cancel, valid, - Footer { apply: self.left, selected: self.selected, danger: matches!(self.tone, Tone::Danger), confirm: true, ..Default::default() }, + Footer { + apply: self.left, + selected: self.selected, + danger: matches!(self.tone, Tone::Danger), + confirm: true, + secondary_left: self.secondary_left, + ..Default::default() + }, |_| {}, ); let action = if action == Action::Cancel { diff --git a/crates/ui-egui/src/ui_kit/container.rs b/crates/ui-egui/src/ui_kit/container.rs index 8432e90afe..518f1a0a83 100644 --- a/crates/ui-egui/src/ui_kit/container.rs +++ b/crates/ui-egui/src/ui_kit/container.rs @@ -96,16 +96,16 @@ impl Container { match self.surface { Surface::Feature | Surface::Palette if !self.floating() => { let edges = self.joined.unwrap_or(DockEdges { left: side == Side::Left, right: side == Side::Right, ..Default::default() }); - Self::compact_frame(fill, t.dialog_border, edges) + Self::compact_frame(fill, t.primary_border(t.dialog_border), edges) } - Surface::Panel | Surface::Chrome if !self.floating() => Self::compact_frame(fill, t.border, self.joined.unwrap_or(DockEdges::ALL)), - Surface::Feature | Surface::Window => { - flat.stroke(egui::Stroke::new(1.0, t.dialog_border)).corner_radius(t.radius_xl).shadow(t.shell_shadow()) + Surface::Panel | Surface::Chrome if !self.floating() => { + Self::compact_frame(fill, t.primary_border(t.border), self.joined.unwrap_or(DockEdges::ALL)) } - Surface::Palette => flat.stroke(egui::Stroke::new(1.0, t.border)).corner_radius(t.radius_xl).shadow(t.shell_shadow()), - Surface::Panel | Surface::Chrome => flat.stroke(egui::Stroke::new(1.0, t.border)).corner_radius(t.radius_lg).shadow(t.shell_shadow()), - Surface::Navigation => flat.stroke(egui::Stroke::new(1.0, t.border)).corner_radius(t.radius_md), - Surface::Toast => flat.stroke(egui::Stroke::new(1.0, t.border)).corner_radius(t.radius_md).shadow(t.shell_shadow()), + Surface::Feature | Surface::Window => flat.stroke(t.primary_border(t.dialog_border)).corner_radius(t.radius_xl).shadow(t.shell_shadow()), + Surface::Palette => flat.stroke(t.primary_border(t.border)).corner_radius(t.radius_xl).shadow(t.shell_shadow()), + Surface::Panel | Surface::Chrome => flat.stroke(t.primary_border(t.border)).corner_radius(t.radius_lg).shadow(t.shell_shadow()), + Surface::Navigation => flat.stroke(t.primary_border(t.border)).corner_radius(t.radius_md), + Surface::Toast => flat.stroke(t.primary_border(t.border)).corner_radius(t.radius_md).shadow(t.shell_shadow()), Surface::Popup => Self::popup_frame(&ctx.global_style()).fill(fill), Surface::ApplicationBar | Surface::Canvas | Surface::Viewport | Surface::InputShield => flat, } @@ -123,7 +123,7 @@ impl Container { /// supplied by `frame`; edge-hosted panes must not acquire outer shadows or card gaps. pub fn edge_frame(self, fill: Color32) -> Frame { if self.adaptive() && !self.floating() { - Self::compact_frame(fill, Tokens::get().border, self.joined.unwrap_or(DockEdges::ALL)) + Self::compact_frame(fill, Tokens::get().primary_border(Tokens::get().border), self.joined.unwrap_or(DockEdges::ALL)) } else { Frame::NONE.fill(fill) } @@ -143,14 +143,14 @@ impl Container { } } - fn compact_frame(fill: Color32, border: Color32, edges: DockEdges) -> Frame { - // Collapse adjoining borders onto the same one-point strip. North/west outlines + fn compact_frame(fill: Color32, border: Stroke, edges: DockEdges) -> Frame { + // Collapse adjoining borders onto the same themed-width strip. North/west outlines // sit outside the allocation; south/east outlines sit inside it. Logical panel // rectangles still touch exactly, with neither gaps nor doubled strokes. Frame::NONE .fill(fill) - .stroke(Stroke::new(1.0, border)) - .outer_margin(egui::Margin { left: -1, top: -1, right: 0, bottom: 0 }) + .stroke(border) + .outer_margin(egui::Margin { left: -(border.width.ceil() as i8), top: -(border.width.ceil() as i8), right: 0, bottom: 0 }) .corner_radius(Self::compact_rounding(edges)) } @@ -174,7 +174,52 @@ impl Container { edges.bottom |= rect.bottom() >= bounds.bottom(); prepared.frame.corner_radius = Self::compact_rounding(edges); } - egui::InnerResponse { inner, response: prepared.end(ui) } + let frame = prepared.frame; + let response = prepared.end(ui); + Self::paint_inset(ui.painter(), response.rect - frame.outer_margin, frame); + egui::InnerResponse { inner, response } + } + + /// Optional companion to a primary outline, painted inside the allocated surface. + /// It consumes no layout space or input target. Tiny/collapsed surfaces omit it. + pub(crate) fn paint_inset(painter: &egui::Painter, widget: egui::Rect, frame: Frame) { + let t = Tokens::get(); + if let Some(bevel) = t.borders.bevel { + let mut chrome = painter.clone(); + chrome.set_clip_rect(widget.intersect(painter.ctx().content_rect())); + bevel.paint(&chrome, widget, frame.corner_radius, false, true); + } + let Some(inset) = t.borders.primary_inset else { + return; + }; + let distance = frame.stroke.width + inset.gap; + let rect = widget.shrink(distance); + if inset.stroke.width <= 0.0 || !rect.is_finite() || rect.width() <= 2.0 * inset.stroke.width || rect.height() <= 2.0 * inset.stroke.width { + return; + } + let reduce = |r: u8| r.saturating_sub(distance.ceil() as u8); + let radius = frame.corner_radius; + // A host's content clip excludes its padding. The companion belongs to the whole + // host, so clip to that known surface and the viewport instead of the content well. + let mut painter = painter.clone(); + painter.set_clip_rect(widget.intersect(painter.ctx().content_rect())); + painter.rect_stroke( + rect, + egui::CornerRadius { nw: reduce(radius.nw), ne: reduce(radius.ne), sw: reduce(radius.sw), se: reduce(radius.se) }, + inset.stroke, + egui::StrokeKind::Inside, + ); + } + + /// Manually painted chrome uses exactly the same outer and inset policy as hosts. + pub(crate) fn paint_frame(painter: &egui::Painter, frame: Frame, content: egui::Rect) { + painter.add(frame.paint(content)); + Self::paint_inset(painter, frame.widget_rect(content), frame); + } + + /// Decorate the content-hugging frame of a popup or feature sheet after its contents. + pub(crate) fn finish_content(ui: &egui::Ui, frame: Frame) { + Self::paint_inset(ui.painter(), frame.widget_rect(ui.min_rect()), frame); } pub fn area(self, id: Id) -> egui::Area { @@ -198,14 +243,16 @@ impl Container { /// Anchored editors inherit their containing widget's local style. Their host stays a /// popup in both layouts; only the universal module constructs the egui popup frame. pub fn popup_frame(style: &egui::Style) -> Frame { - Frame::popup(style) + let mut frame = Frame::popup(style); + frame.stroke = Tokens::get().borders.stroke(crate::theme::BorderRole::Primary, frame.stroke); + frame } pub(crate) fn menu_frame() -> Frame { let t = Tokens::get(); egui::Frame::NONE .fill(t.dialog_body) - .stroke(Stroke::new(1.0, t.dialog_border)) + .stroke(t.primary_border(t.dialog_border)) .corner_radius(t.radius_xl) .inner_margin(egui::Margin::symmetric(t.space_6 as i8, t.space_6 as i8)) .shadow(egui::Shadow { @@ -231,7 +278,7 @@ impl Container { pub(crate) fn tooltip_frame(self, ctx: &Context) -> Frame { let t = Tokens::get(); self.frame(ctx, t.dialog_body, crate::frame::Side::Right) - .stroke(egui::Stroke::new(1.0, t.dialog_border)) + .stroke(t.primary_border(t.dialog_border)) .inner_margin(egui::Margin::symmetric(t.space_8 as i8, t.space_4 as i8)) } diff --git a/crates/ui-egui/src/ui_kit/disclosure_tests.rs b/crates/ui-egui/src/ui_kit/disclosure_tests.rs index e08a14ed7d..be81716b6b 100644 --- a/crates/ui-egui/src/ui_kit/disclosure_tests.rs +++ b/crates/ui-egui/src/ui_kit/disclosure_tests.rs @@ -16,9 +16,12 @@ fn assert_glyph( // at the control's position without the former vertical flip. let id = if open { "chevron" } else { "chevron_right" }; let master = icon_svg::resolve(id).master; - let image = - icon_svg::render(icon_svg::doc_for(master, px).unwrap(), icon_svg::Look { dark: true, accent: None, vision: crate::theme::vision() }, px) - .unwrap(); + let image = icon_svg::render( + icon_svg::doc_for(master, px).unwrap(), + icon_svg::Look { dark: true, accent: None, vision: crate::theme::vision(), palette: None }, + px, + ) + .unwrap(); let mut expected = ColorImage::from_rgba_premultiplied([px as usize, px as usize], image.data()); for pixel in &mut expected.pixels { *pixel = t.form_dim.gamma_multiply(f32::from(pixel.a()) / 255.0); diff --git a/crates/ui-egui/src/ui_kit/fields.rs b/crates/ui-egui/src/ui_kit/fields.rs index 3132fc17ff..9b7277b47f 100644 --- a/crates/ui-egui/src/ui_kit/fields.rs +++ b/crates/ui-egui/src/ui_kit/fields.rs @@ -27,6 +27,87 @@ pub(crate) fn hint_field(ui: &mut Ui, value: &mut String, hint: &str) -> bool { editor(ui, value, hint, true).0 } +/// The whole numbers a count field may hold, and how its text evaluates (parameters and +/// expressions included). `eval` returns None for text that is not a value. +pub(crate) struct Count<'a> { + pub min: i64, + pub max: i64, + pub eval: &'a dyn Fn(&str) -> Option, +} + +/// Expression editor for a whole-number count (a pattern's quantity), with up and down step +/// arrows at its right edge. An arrow steps the evaluated count by one and stays within the +/// limits: a plain number is replaced, an expression keeps its parameters and gains or adjusts +/// a trailing `+ n`. The text changes as if typed, so previews follow. Returns accepted Enter. +/// Tokens: field tokens, `control_border`, `selected_bg`, `form_ink`, `form_dim`. +pub(crate) fn count_field(ui: &mut Ui, value: &mut String, limits: &Count<'_>) -> bool { + let t = Tokens::get(); + let reserve = (t.control_height * 0.75).round(); + let (enter, _, response) = editor_reserving(ui, value, "", true, reserve); + let rect = response.rect; + let column = egui::Rect::from_min_max(egui::pos2((rect.right() - reserve).max(rect.left()), rect.top()), rect.max); + let painter = ui.painter_at(rect); + painter.vline(column.left(), column.y_range().shrink(t.space_4), egui::Stroke::new(1.0, t.control_border)); + let row = label(ui).to_owned(); + for (delta, half, name) in [(1, column.split_top_bottom_at_fraction(0.5).0, "up"), (-1, column.split_top_bottom_at_fraction(0.5).1, "down")] { + let next = step_count(value, delta, limits); + let arrow = ui.interact(half, ui.id().with(("sc_count_step", &row, name)), egui::Sense::click()); + let enabled = ui.is_enabled() && next.is_some(); + if enabled && arrow.hovered() { + painter.rect_filled(half.shrink(1.0), t.control_radius, t.selected_bg); + } + let ink = if enabled { t.form_ink } else { t.form_dim }; + let (c, w, h) = (half.center(), (reserve * 0.18).max(2.0), (half.height() * 0.2).max(2.0)); + let tip = if delta > 0 { -h } else { h }; + painter.add(egui::Shape::convex_polygon( + vec![egui::pos2(c.x, c.y + tip * 0.5), egui::pos2(c.x + w, c.y - tip * 0.5), egui::pos2(c.x - w, c.y - tip * 0.5)], + ink, + egui::Stroke::NONE, + )); + let sign = if delta > 0 { "+1" } else { "−1" }; + arrow.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, format!("{} {sign}", crate::i18n::tr_label(&row)))); + scenario::publish_handle(&format!("dialog:{row} {name}"), half.center()); + if let (true, true, Some(next)) = (enabled, arrow.clicked(), next) { + *value = next; + } + } + enter +} + +/// `text` stepped by `delta` whole numbers within `limits`, or None when it does not evaluate +/// or the step would leave the limits. A typed number between whole numbers steps to the next +/// one; an expression that evaluates between them is left alone. +fn step_count(text: &str, delta: i64, limits: &Count<'_>) -> Option { + const SAFE: f64 = 1e15; + let trimmed = text.trim(); + let now = (limits.eval)(trimmed).filter(|v| v.is_finite() && v.abs() < SAFE)?; + let from = if delta > 0 { now.floor() } else { now.ceil() } as i64; + let target = from.saturating_add(delta).clamp(limits.min, limits.max); + let moves = if delta > 0 { target as f64 > now } else { (target as f64) < now }; + if !moves { + return None; + } + if trimmed.parse::().is_ok() { + return Some(target.to_string()); + } + // An expression: adjust its trailing `+ n` / `- n`, or add one, and check that it now says `target`. + let offset = |base: &str, n: i64| match n { + 0 => base.to_owned(), + n if n > 0 => format!("{base} + {n}"), + n => format!("{base} - {}", n.unsigned_abs()), + }; + let tail = trimmed.rfind(['+', '-']).and_then(|at| { + let (base, number) = (trimmed[..at].trim_end(), trimmed[at + 1..].trim()); + let n: i64 = number.bytes().all(|b| b.is_ascii_digit()).then(|| number.parse().ok()).flatten()?; + let base_ok = !base.is_empty() && !base.ends_with(['+', '-', '*', '/', '^', '(', ',']); + base_ok.then(|| (base, if trimmed[at..].starts_with('-') { -n } else { n })) + }); + let step = target - now.round() as i64; + let candidates = + [tail.map(|(base, n)| offset(base, n.saturating_add(step))), Some(offset(trimmed, step)), Some(offset(&format!("({trimmed})"), step))]; + candidates.into_iter().flatten().find(|c| (limits.eval)(c).is_some_and(|v| (v - target as f64).abs() < 1e-9)) +} + /// Names, thread designations and plain numeric counts do not accept expression completion. /// Plain text editor without expression completion; returns (Enter, changed). Tokens: `same as field`. pub(crate) fn text_field(ui: &mut Ui, value: &mut String, hint: &str) -> (bool, bool) { @@ -61,7 +142,7 @@ pub(crate) fn multiline_text_field(ui: &mut Ui, label: &str, id: egui::Id, value .id(id) .desired_rows(3) .font(egui::FontId::monospace(t.font_label)) - .background_color(t.control_fill), + .background_color(t.input_fill()), ); publish(ui, &response, egui::WidgetType::TextEdit); response @@ -141,14 +222,22 @@ pub(crate) fn searchable_dropdown( /// Completion/focus implementation shared by field variants. Tokens: `same as field`. fn editor(ui: &mut Ui, value: &mut String, hint: &str, completion: bool) -> (bool, bool, Response) { + editor_reserving(ui, value, hint, completion, 0.0) +} + +/// `editor` whose text stops `reserve` points short of its right edge, for controls drawn there. +fn editor_reserving(ui: &mut Ui, value: &mut String, hint: &str, completion: bool, reserve: f32) -> (bool, bool, Response) { let t = Tokens::get(); let width = dimension(ui.available_width()); let font = egui::FontId::monospace(t.font_label); - let response = ui.add_sized( - vec2(width, row_height()), - text_input(egui::TextEdit::singleline(value)).hint_text(hint).font(font).background_color(t.control_fill).clip_text(true), - ); + let mut edit = text_input(egui::TextEdit::singleline(value)).hint_text(hint).font(font).background_color(t.input_fill()).clip_text(true); + if reserve > 0.0 { + let inset = t.form_inset.round() as i8; + edit = edit.margin(egui::Margin { left: inset, right: inset.saturating_add(reserve.round() as i8), top: 0, bottom: 0 }); + } + let response = ui.add_sized(vec2(width, row_height()), edit); crate::theme::TabAccent::number_focus(ui, &response); + t.input_bevel(ui, &response); let label = crate::i18n::tr_label(label(ui)); response.widget_info(|| { let mut info = egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, ui.is_enabled(), label); @@ -209,7 +298,7 @@ pub(crate) fn selection_field(ui: &mut Ui, text: &str, options: Selection) -> Re if active && !filled { rect.shrink(t.space_2) } else { rect }, t.control_radius, if filled { t.selected_bg } else { t.control_fill }, - egui::Stroke::new(1.0, if active || filled { t.form_primary } else { t.control_border }), + if active || filled { egui::Stroke::new(1.0, t.form_primary) } else { t.control_border_stroke(t.control_border) }, egui::StrokeKind::Inside, ); let end = if filled { t.space_24 } else { t.space_8 }; @@ -274,7 +363,7 @@ fn cell_editor_frame(ui: &mut Ui, id: egui::Id, value: &mut String, hint: &str, .id(id) .hint_text(crate::i18n::tr_label(hint)) .font(if frame { egui::FontId::monospace(t.font_label) } else { egui::FontId::proportional(t.font_label) }) - .background_color(if invalid { t.form_error_fill } else { t.control_fill }) + .background_color(if invalid { t.form_error_fill } else { t.input_fill() }) .text_color(if invalid { t.form_error } else { t.form_ink }) .clip_text(true); let editor = if invalid { @@ -291,13 +380,18 @@ fn cell_editor_frame(ui: &mut Ui, id: egui::Id, value: &mut String, hint: &str, editor.frame(egui::Frame::NONE.inner_margin(egui::Margin::symmetric(t.space_8 as i8, 0))) }; let response = ui.add_sized(vec2(dimension(ui.available_width()), row_height()), editor); - publish(ui, &response, egui::WidgetType::TextEdit); + if frame { + publish(ui, &response, egui::WidgetType::TextEdit); + } else { + super::layout::publish_geometry(ui, &response); + } response.widget_info(|| { let mut info = egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, ui.is_enabled(), crate::i18n::tr_label(label(ui))); info.current_text_value = Some(value.clone()); info }); crate::theme::TabAccent::number_focus(ui, &response); + t.input_bevel(ui, &response); response } @@ -383,6 +477,7 @@ pub(crate) fn scalar_field(ui: &mut Ui, value: &mut f64, range: std::ops::RangeI vec2(dimension(ui.available_width()), row_height()), egui::DragValue::new(value).range(range).speed(0.01).custom_parser(crate::prefs::parse_number), ); + Tokens::get().input_bevel(ui, &response); scenario::publish_target(&format!("dialog:scalar:{}", label(ui)), response.rect); response } @@ -391,6 +486,7 @@ pub(crate) fn scalar_field(ui: &mut Ui, value: &mut f64, range: std::ops::RangeI /// Tokens: control_height, scope control/font tokens. pub(crate) fn length_field(ui: &mut Ui, value: &mut f64, units: &str, decimals: u8) -> Response { let response = ui.add(length_drag(value, units, decimals)); + Tokens::get().input_bevel(ui, &response); scenario::publish_target(&format!("dialog:length:{}", label(ui)), response.rect); response } @@ -398,6 +494,7 @@ pub(crate) fn length_field(ui: &mut Ui, value: &mut f64, units: &str, decimals: /// Angle in degrees, dragged or typed. Tokens: control_height, scope control/font tokens. pub(crate) fn angle_field(ui: &mut Ui, value: &mut f64) -> Response { let response = ui.add(egui::DragValue::new(value).speed(1.0).suffix(" deg").custom_parser(crate::prefs::parse_number)); + Tokens::get().input_bevel(ui, &response); scenario::publish_target(&format!("dialog:angle:{}", label(ui)), response.rect); response } @@ -413,8 +510,9 @@ pub(crate) fn text_area(ui: &mut Ui, label: &str, id: egui::Id, value: &mut Stri .desired_rows(rows.clamp(1, 16)) .char_limit(limit) .font(egui::FontId::monospace(t.font_label)) - .background_color(t.control_fill), + .background_color(t.input_fill()), ); + t.input_bevel(ui, &response); response.widget_info(|| { let mut info = egui::WidgetInfo::labeled(egui::WidgetType::TextEdit, ui.is_enabled(), label); info.current_text_value = Some(value.clone()); @@ -434,7 +532,7 @@ pub(crate) fn password_field(ui: &mut Ui, id: egui::Id, value: &mut String, hint .password(true) .hint_text(hint) .font(egui::FontId::monospace(t.font_label)) - .background_color(t.control_fill), + .background_color(t.input_fill()), ); publish(ui, &response, egui::WidgetType::TextEdit); response @@ -450,6 +548,7 @@ pub(crate) fn number_field( suffix: &str, ) -> Response { let response = ui.add(egui::DragValue::new(value).range(range).speed(speed).suffix(suffix).custom_parser(crate::prefs::parse_number)); + Tokens::get().input_bevel(ui, &response); response.widget_info(|| { let mut info = egui::WidgetInfo::drag_value(ui.is_enabled(), value.to_f64()); info.label = Some(crate::i18n::tr_label(label(ui)).to_string()); @@ -464,15 +563,17 @@ pub(crate) fn number_field( #[cfg(any(not(target_arch = "wasm32"), test))] pub(crate) fn message_field(ui: &mut Ui, id: egui::Id, value: &mut String, hint: &str) -> Response { let t = Tokens::get(); - ui.add( + let response = ui.add( text_input(egui::TextEdit::multiline(value)) .id(id) .hint_text(hint) .desired_rows(2) .desired_width(f32::INFINITY) .font(egui::FontId::proportional(t.font_label)) - .background_color(t.control_fill), - ) + .background_color(t.input_fill()), + ); + t.input_bevel(ui, &response); + response } /// Edit a millimetre value through a control expressed in the current design units. @@ -492,11 +593,13 @@ fn length_drag<'a>(value: &'a mut f64, units: &str, decimals: u8) -> egui::DragV /// keyboard to the next widget. pub(crate) fn canvas_value_field(ui: &mut Ui, id: egui::Id, value: &mut String, keep_tab: bool) -> Response { let t = Tokens::get(); - let mut edit = egui::TextEdit::singleline(value).id(id).desired_width(t.space_24 * 3.0); + let mut edit = egui::TextEdit::singleline(value).id(id).desired_width(t.space_24 * 3.0).background_color(t.input_fill()); if keep_tab { edit = edit.event_filter(egui::EventFilter { tab: true, horizontal_arrows: true, ..Default::default() }); } - ui.add(edit) + let response = ui.add(edit); + t.input_bevel(ui, &response); + response } /// Placement editor whose owner routes Enter and Tab (including rejected expressions). @@ -515,7 +618,7 @@ pub(crate) fn placement_field(ui: &mut Ui, id: egui::Id, value: &mut String, hin .return_key(None) .event_filter(egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, ..Default::default() }) .font(font) - .background_color(t.control_fill) + .background_color(t.input_fill()) .text_color(t.form_ink) .clip_text(true), ); @@ -526,6 +629,7 @@ pub(crate) fn placement_field(ui: &mut Ui, id: egui::Id, value: &mut String, hin info }); crate::theme::TabAccent::number_focus(ui, &response); + t.input_bevel(ui, &response); response.on_hover_text(crate::i18n::tr_label(hint)) } @@ -534,7 +638,7 @@ pub(crate) fn placement_field(ui: &mut Ui, id: egui::Id, value: &mut String, hin pub(crate) fn library_cell(ui: &mut Ui, id: egui::Id, value: &mut String, hint: &str, menu: impl FnOnce(&mut Ui)) -> Response { let t = Tokens::get(); let (rect, _) = ui.allocate_exact_size(vec2(dimension(ui.available_width()), row_height()), egui::Sense::hover()); - ui.painter().rect(rect, t.control_radius, t.control_fill, egui::Stroke::new(1.0, t.control_border), egui::StrokeKind::Inside); + ui.painter().rect(rect, t.control_radius, t.control_fill, t.control_border_stroke(t.control_border), egui::StrokeKind::Inside); let split = (rect.right() - t.space_24).max(rect.left()); let field = egui::Rect::from_min_max(rect.min, egui::pos2(split, rect.bottom())); let response = ui @@ -544,7 +648,7 @@ pub(crate) fn library_cell(ui: &mut Ui, id: egui::Id, value: &mut String, hint: }) .inner; let button = egui::Rect::from_min_max(egui::pos2(split, rect.top()), rect.max); - ui.painter().vline(split, rect.y_range(), egui::Stroke::new(1.0, t.dialog_rule)); + t.secondary_vline(ui.painter(), split, rect.y_range(), t.dialog_rule); let library = super::icon_button_at(ui, button, "chevron", "Library"); scenario::publish_handle("dialog:menu:Library", button.center()); scenario::publish_rect("dialog:menu:Library", button); diff --git a/crates/ui-egui/src/ui_kit/fields_tests.rs b/crates/ui-egui/src/ui_kit/fields_tests.rs index 032a64b788..a112b75a1f 100644 --- a/crates/ui-egui/src/ui_kit/fields_tests.rs +++ b/crates/ui-egui/src/ui_kit/fields_tests.rs @@ -240,3 +240,41 @@ fn bounded_name_editor_keeps_unicode_character_cap() { frame(&ctx, vec![egui::Event::Text("åβ界d".into())], &mut draw); assert_eq!(value, "åβ界"); } + +#[test] +fn count_steps_stay_within_limits_and_keep_expressions() { + let eval = |text: &str| -> Option { + let text = text.trim(); + match text { + "n" => Some(4.0), + "n + 2" => Some(6.0), + "n + 3" => Some(7.0), + "n + 1" => Some(5.0), + "n - 1" => Some(3.0), + "half" => Some(2.5), + _ => text.parse().ok(), + } + }; + let limits = Count { min: 2, max: 10, eval: &eval }; + let step = |text: &str, delta| super::step_count(text, delta, &limits); + assert_eq!(step("6", 1).as_deref(), Some("7")); + assert_eq!(step(" 6 ", -1).as_deref(), Some("5")); + assert_eq!(step("2", -1), None, "the minimum holds"); + assert_eq!(step("10", 1), None, "the maximum holds"); + assert_eq!(step("40", -1).as_deref(), Some("10"), "a typed count past the limit steps back inside it"); + assert_eq!(step("0", 1).as_deref(), Some("2")); + assert_eq!(step("3.5", 1).as_deref(), Some("4"), "between whole numbers: the next one up"); + assert_eq!(step("3.5", -1).as_deref(), Some("3")); + assert_eq!(step("n", 1).as_deref(), Some("n + 1")); + assert_eq!(step("n", -1).as_deref(), Some("n - 1")); + assert_eq!(step("n + 2", 1).as_deref(), Some("n + 3")); + assert_eq!(step("n + 1", -1).as_deref(), Some("n")); + assert_eq!(step("half", 1), None, "an expression between whole numbers is left alone"); + for junk in ["", "abc", "1e400", "-1e300", "NaN"] { + assert_eq!(step(junk, 1), None, "{junk}"); + } + let wide = Count { min: i64::MIN, max: i64::MAX, eval: &eval }; + assert_eq!(super::step_count("1e16", 1, &wide), None, "values past what counts can be are refused"); + assert_eq!(super::step_count("5", i64::MAX, &wide).as_deref(), Some("9223372036854775807"), "steps saturate rather than overflow"); + assert_eq!(super::step_count("5", i64::MIN, &wide).as_deref(), Some("-9223372036854775803")); +} diff --git a/crates/ui-egui/src/ui_kit/file_view.rs b/crates/ui-egui/src/ui_kit/file_view.rs index fe72260064..d9e34b3e81 100644 --- a/crates/ui-egui/src/ui_kit/file_view.rs +++ b/crates/ui-egui/src/ui_kit/file_view.rs @@ -96,7 +96,7 @@ impl FileView { pub(crate) fn recent_list(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> InnerResponse { let t = Tokens::get(); - egui::Frame::NONE.fill(t.file_surface).stroke(Stroke::new(1.0, t.file_border)).corner_radius(t.radius_xl).show(ui, |ui| { + egui::Frame::NONE.fill(t.file_surface).stroke(t.tertiary_border(t.file_border)).corner_radius(t.radius_xl).show(ui, |ui| { ui.spacing_mut().item_spacing.y = 0.0; let key = ui.id().with("file_recent_index"); ui.ctx().data_mut(|data| data.insert_temp(key, 0_usize)); @@ -221,10 +221,11 @@ impl FileView { let t = Tokens::get(); ui.add_space(t.space_10); let (rect, _) = ui.allocate_exact_size(vec2(dimension(ui.available_width()), 1.0), Sense::hover()); - ui.painter().hline( + t.secondary_hline( + ui.painter(), (rect.left() + t.space_12)..=(rect.right() - t.space_12).max(rect.left() + t.space_12), rect.center().y, - Stroke::new(1.0, t.file_rail_ink.gamma_multiply(0.25)), + t.file_rail_ink.gamma_multiply(0.25), ); ui.add_space(t.space_10); } @@ -265,7 +266,7 @@ impl FileView { }); if let Some((key, index)) = list { if index > 0 { - ui.painter().hline(rect.x_range(), rect.top(), Stroke::new(1.0, t.file_border)); + ui.painter().hline(rect.x_range(), rect.top(), t.tertiary_border(t.file_border)); } ui.ctx().data_mut(|data| data.insert_temp(key, index.saturating_add(1))); } @@ -400,12 +401,18 @@ fn card(ui: &Ui, rect: Rect, response: &Response, primary: bool) { t.file_surface }; let border = if primary || response.has_focus() { t.file_primary_border } else { t.file_border }; - ui.painter().rect(rect, t.radius_xl, fill, Stroke::new(if primary { 1.5 } else { 1.0 }, border), egui::StrokeKind::Inside); + ui.painter().rect( + rect, + t.radius_xl, + fill, + if primary || response.has_focus() { Stroke::new(if primary { 1.5 } else { 1.0 }, border) } else { t.tertiary_border(border) }, + egui::StrokeKind::Inside, + ); } fn thumbnail(ui: &Ui, rect: Rect, texture: Option<(TextureId, Vec2)>) { let t = Tokens::get(); - ui.painter().rect(rect, t.radius_md, t.file_surface, Stroke::new(1.0, t.file_border), egui::StrokeKind::Inside); + ui.painter().rect(rect, t.radius_md, t.file_surface, t.tertiary_border(t.file_border), egui::StrokeKind::Inside); if let Some((texture, size)) = texture.filter(|(_, size)| size.is_finite() && size.x > 0.0 && size.y > 0.0) { let scale = (rect.width() / size.x).min(rect.height() / size.y); let target = Align2::CENTER_CENTER.align_size_within_rect(size * scale, rect); @@ -426,7 +433,7 @@ fn recovery_panel( let border = if banner { t.file_recovery_border } else { t.file_border }; egui::Frame::NONE .fill(fill) - .stroke(Stroke::new(1.0, border)) + .stroke(if banner { Stroke::new(1.0, border) } else { t.tertiary_border(border) }) .corner_radius(t.radius_xl) .inner_margin(egui::Margin::symmetric(t.space_16 as i8, t.space_12 as i8)) .show(ui, |ui| { diff --git a/crates/ui-egui/src/ui_kit/gallery.rs b/crates/ui-egui/src/ui_kit/gallery.rs index 3c4573896d..fd9487f801 100644 --- a/crates/ui-egui/src/ui_kit/gallery.rs +++ b/crates/ui-egui/src/ui_kit/gallery.rs @@ -774,6 +774,16 @@ const ELEMENTS: &[Element] = &[ element!("expr_row", "dialog:row:Gallery", Some("dialog:Gallery"), |ui, s| { expr_row(ui, "Gallery", &mut s.value); }), + element!("count_row", "dialog:row:Gallery", Some("dialog:Gallery up"), |ui, _| { + let mut count = "6".to_owned(); + count_row(ui, "Gallery", &mut count, &Count { min: 1, max: 10, eval: &|text: &str| text.trim().parse().ok() }); + }), + element!("count_field", "dialog:row:Gallery", Some("dialog:Gallery down"), |ui, _| { + row(ui, "Gallery", |ui| { + let mut count = "1".to_owned(); + count_field(ui, &mut count, &Count { min: 1, max: 10, eval: &|text: &str| text.trim().parse().ok() }); + }); + }), element!("hint_row", "dialog:row:Gallery", Some("dialog:Gallery"), |ui, s| { hint_row(ui, "Gallery", &mut s.value, "Value"); }), @@ -1009,6 +1019,18 @@ const ELEMENTS: &[Element] = &[ element!("caption", "dialog:caption:Gallery", None, |ui, _| { caption(ui, "Gallery", "Construction"); }), + element!("figure_rows", "dialog:figure:gallery", Some("dialog:Gallery"), |ui, s| { + let Sample { value, second, .. } = s; + figure_rows(ui, "gallery", &mut [("Gallery", value), ("Second", second)], |painter, rect, slots| { + let t = Tokens::get(); + painter.rect_stroke(rect.shrink(t.space_4), t.control_radius, egui::Stroke::new(1.0, t.form_dim), egui::StrokeKind::Inside); + for slot in slots { + let ink = if slot.hot { t.form_primary } else { t.form_dim }; + let y = slot.rect.center().y; + painter.line_segment([egui::pos2(rect.center().x, y), egui::pos2(slot.rect.left(), y)], egui::Stroke::new(1.0, ink)); + } + }); + }), element!("section", "dialog:section:Advanced", Some("dialog:section:Advanced"), |ui, _| { section(ui, "gallery", "Advanced", false, |_| {}); }), @@ -1724,7 +1746,7 @@ fn confirm_radial_and_history_rows_keep_multitone_artwork() { assert!(colours.len() >= 2, "{surface}/{icon}/{kind}/{tab}, dark={dark}: opaque artwork is a silhouette"); let resolved = crate::icon_svg::resolve(icon); assert!(!resolved.placeholder, "{icon} needs a master"); - let look = crate::icon_svg::Look { dark, accent: ramp.and_then(crate::icon_svg::accent_index), vision: crate::theme::vision() }; + let look = crate::icon_svg::Look::themed(dark, ramp.and_then(crate::icon_svg::accent_index), crate::theme::vision()); let doc = crate::icon_svg::doc_for(resolved.master, side as u32).unwrap(); let expected = if matches!(surface, "danger" | "danger_menu") { crate::icon_svg::render_tinted(doc, look, side as u32) diff --git a/crates/ui-egui/src/ui_kit/history.rs b/crates/ui-egui/src/ui_kit/history.rs index bae10ccbb6..a74b9872f4 100644 --- a/crates/ui-egui/src/ui_kit/history.rs +++ b/crates/ui-egui/src/ui_kit/history.rs @@ -85,7 +85,13 @@ impl HistoryChip<'_> { let radius = if style == HistoryStyle::Icons { t.radius_lg } else { t.radius_sm }; if style != HistoryStyle::Names || self.error { let weight = if style == HistoryStyle::Icons && (self.selected || self.error) { 1.5 } else { 1.0 }; - p.rect(rect, radius, fill, Stroke::new(weight, edge), egui::StrokeKind::Inside); + p.rect( + rect, + radius, + fill, + if self.error || self.selected || self.hovered { Stroke::new(weight, edge) } else { t.tertiary_border(edge) }, + egui::StrokeKind::Inside, + ); } else { p.rect_filled(rect, radius, fill); } diff --git a/crates/ui-egui/src/ui_kit/layout.rs b/crates/ui-egui/src/ui_kit/layout.rs index aa1c32f3b7..f9d0357e34 100644 --- a/crates/ui-egui/src/ui_kit/layout.rs +++ b/crates/ui-egui/src/ui_kit/layout.rs @@ -29,11 +29,11 @@ pub(crate) fn scope(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R { visuals.weak_text_color = Some(t.form_dim); visuals.selection.stroke = egui::Stroke::new(1.0, t.form_primary); visuals.selection.bg_fill = t.selected_bg; - visuals.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, t.dialog_rule); + visuals.widgets.noninteractive.bg_stroke = t.tertiary_border(t.dialog_rule); for state in [&mut visuals.widgets.inactive, &mut visuals.widgets.hovered, &mut visuals.widgets.active, &mut visuals.widgets.open] { state.bg_fill = t.control_fill; state.weak_bg_fill = t.control_fill; - state.bg_stroke = egui::Stroke::new(1.0, t.control_border); + state.bg_stroke = t.control_border_stroke(t.control_border); state.corner_radius = t.control_radius.into(); state.expansion = 0.0; state.fg_stroke.color = t.form_ink; @@ -51,6 +51,12 @@ pub(crate) fn label(ui: &Ui) -> &str { /// Publish accessibility and scenario geometry. Tokens: `none`. pub(super) fn publish(ui: &Ui, response: &Response, kind: egui::WidgetType) { + let t = Tokens::get(); + match kind { + egui::WidgetType::TextEdit => t.input_bevel(ui, response), + egui::WidgetType::ComboBox | egui::WidgetType::Button => t.button_bevel(ui, response, false), + _ => {} + } let label = label(ui); response.widget_info(|| egui::WidgetInfo::labeled(kind, ui.is_enabled(), crate::i18n::tr_label(label))); publish_geometry(ui, response); @@ -66,6 +72,11 @@ pub(crate) fn expr_row(ui: &mut Ui, label: &str, value: &mut String) -> bool { row(ui, label, |ui| super::field(ui, value)) } +/// Whole-number count row with step arrows; retains completion and accepted Enter. Tokens: `row, count_field`. +pub(crate) fn count_row(ui: &mut Ui, label: &str, value: &mut String, limits: &super::Count<'_>) -> bool { + row(ui, label, |ui| super::count_field(ui, value, limits)) +} + /// Expression row with an empty-value hint. Tokens: `row, hint_field`. pub(crate) fn hint_row(ui: &mut Ui, label: &str, value: &mut String, hint: &str) -> bool { row(ui, label, |ui| super::hint_field(ui, value, hint)) @@ -131,10 +142,67 @@ pub(crate) fn caption(ui: &mut Ui, aligned_to: &str, text: &str) { scenario::publish_rect(&format!("dialog:caption:{aligned_to}"), text_rect); } +/// Where a [`figure_rows`] field went (in the order given): the field's rect, and whether it is +/// hovered or has the keyboard (its part of the figure lights up). +pub(crate) struct FigureSlot { + pub rect: egui::Rect, + pub hot: bool, +} + +/// A figure in the label column beside a stack of expression fields, each under its own caption, +/// for a picture whose parts the fields size (the Hole dialog's section): the caller paints the +/// figure where the fields went, with leaders to them. At least four rows tall; the fields keep +/// their labels' handles and accessible names. Returns whether Enter was accepted in a field. +/// Tokens: `control_height, form_label_width, font_small, form_dim, space_2/4, label gap`. +pub(crate) fn figure_rows( + ui: &mut Ui, + id: &str, + fields: &mut [(&str, &mut String)], + paint: impl FnOnce(&egui::Painter, egui::Rect, &[FigureSlot]), +) -> bool { + let t = Tokens::get(); + let width = dimension(ui.available_width()); + let slot = t.font_small + t.space_2 + row_height(); + let stack = fields.len() as f32 * slot + fields.len().saturating_sub(1) as f32 * t.space_4; + let height = stack.max(row_height() * 4.0); + let (rect, _) = ui.allocate_exact_size(vec2(width, height), egui::Sense::hover()); + let figure = egui::Rect::from_min_size(rect.min, vec2((t.form_label_width * 1.4).min(width * 0.45), height)); + let left = (figure.right() + label_gap(ui, width)).min(rect.right()); + let focused = ui.ctx().memory(|m| m.focused()).and_then(|f| ui.ctx().read_response(f)).map(|r| r.rect); + let mut enter = false; + let mut top = rect.top() + (height - stack) / 2.0; + let mut slots = Vec::with_capacity(fields.len()); + for (label, value) in fields.iter_mut() { + let caption = egui::Rect::from_min_max(egui::pos2(left, top), egui::pos2(rect.right(), top + t.font_small)); + paint_text(ui, caption, crate::i18n::tr_label(label), egui::FontId::proportional(t.font_small), t.form_dim, false); + let field = egui::Rect::from_min_max(egui::pos2(left, caption.bottom() + t.space_2), egui::pos2(rect.right(), top + slot)); + enter |= ui + .scope_builder( + egui::UiBuilder::new() + .id_salt((id, *label)) + .max_rect(field) + .layout(egui::Layout::left_to_right(egui::Align::Center)) + .ui_stack_info(egui::UiStackInfo::default().with_tag_value(LABEL, label.to_string())), + |ui| { + ui.spacing_mut().text_edit_width = field.width(); + super::field(ui, value) + }, + ) + .inner; + let hot = ui.rect_contains_pointer(field) || focused.is_some_and(|f| field.contains_rect(f)); + slots.push(FigureSlot { rect: field, hot }); + top += slot + t.space_4; + } + paint(ui.painter(), figure, &slots); + scenario::publish_rect(&format!("dialog:figure:{id}"), rect); + scenario::publish_rect(&format!("dialog:figure_picture:{id}"), figure); + enter +} + /// Memory belongs to the enclosing dialog ID; animation is avoided so each row keeps its fixed height. /// Persisted disclosure with a rule and uppercase title. Tokens: `control_height, space_2, radius_sm, hover, dialog_rule, form_section_icon_size, form_gap_y, font_caption, font_section_tracking, text_dim`. pub(crate) fn section(ui: &mut Ui, id: impl egui::AsIdSalt, title: &str, default_open: bool, add: impl FnOnce(&mut Ui) -> R) -> Option { - section_count(ui, id, title, default_open, None, add) + section_count(ui, id, title, default_open, None::, add) } /// Section heading with an optional count badge; disclosure state uses the same identity. @@ -143,7 +211,7 @@ pub(crate) fn section_count( id: impl egui::AsIdSalt, title: &str, default_open: bool, - count: Option, + count: Option, add: impl FnOnce(&mut Ui) -> R, ) -> Option { let t = Tokens::get(); @@ -190,7 +258,7 @@ pub(crate) fn section_count( rule_left = badge.right() + t.space_12; } if rule_left < rect.right() { - ui.painter().hline(rule_left..=rect.right(), rect.center().y, egui::Stroke::new(1.0, t.dialog_rule)); + t.secondary_hline(ui.painter(), rule_left..=rect.right(), rect.center().y, t.dialog_rule); } response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::CollapsingHeader, ui.is_enabled(), open, crate::i18n::tr_label(title))); scenario::publish_handle(&format!("dialog:section:{title}"), response.rect.center()); @@ -221,7 +289,7 @@ pub(crate) fn list_row(ui: &mut Ui, key: &str, text: &str) -> Response { /// Full-width rule without extra padding. Tokens: `dialog_rule`. pub(crate) fn divider(ui: &mut Ui, key: &str) { let (rect, _) = ui.allocate_exact_size(vec2(dimension(ui.available_width()), 1.0), egui::Sense::hover()); - ui.painter().hline(rect.x_range(), rect.top(), egui::Stroke::new(1.0, Tokens::get().dialog_rule)); + Tokens::get().secondary_hline(ui.painter(), rect.x_range(), rect.top(), Tokens::get().dialog_rule); scenario::publish_rect(&format!("dialog:divider:{key}"), rect); } @@ -342,7 +410,7 @@ pub(crate) fn text(ui: &mut Ui, text: &str, style: Text<'_>) -> Response { pub(crate) fn summary_row(ui: &mut Ui, name: &str, summary: &str, enabled: &mut bool, actions_width: f32, actions: impl FnOnce(&mut Ui)) -> bool { let t = Tokens::get(); let (rect, _) = ui.allocate_exact_size(vec2(dimension(ui.available_width()), row_height() + t.space_16), egui::Sense::hover()); - ui.painter().rect(rect, t.radius_lg, t.dialog_body, egui::Stroke::new(1.0, t.dialog_rule), egui::StrokeKind::Inside); + t.container_rect(ui.painter(), rect, t.radius_lg, t.dialog_body, t.dialog_rule); let check = egui::Rect::from_min_size(rect.min + vec2(t.space_6, 0.0), vec2(t.space_24, rect.height())); let changed = ui .scope_builder(egui::UiBuilder::new().max_rect(check).layout(egui::Layout::left_to_right(egui::Align::Center)), |ui| { diff --git a/crates/ui-egui/src/ui_kit/menu.rs b/crates/ui-egui/src/ui_kit/menu.rs index cd49e87d09..431c68a758 100644 --- a/crates/ui-egui/src/ui_kit/menu.rs +++ b/crates/ui-egui/src/ui_kit/menu.rs @@ -38,7 +38,9 @@ impl Menu { ui.spacing_mut().item_spacing.y = 0.0; // The opening gesture belongs to the parent even when a narrow popup overlaps it. ui.ctx().data_mut(|d| d.insert_temp(gate_id, first_click && !released)); - ui.add_enabled_ui(!opening_press, add).inner + let result = ui.add_enabled_ui(!opening_press, add).inner; + Container::finish_content(ui, frame); + result }; let popup = if nested { let submenu = Container::new(ui.ctx(), Surface::Popup).submenu(); @@ -60,20 +62,24 @@ impl Menu { super::Container::menu_frame() } + pub(crate) fn show_frame(ui: &mut Ui, frame: egui::Frame, add: impl FnOnce(&mut Ui) -> R) -> egui::InnerResponse { + let bounds = ui.max_rect(); + Container::new(ui.ctx(), Surface::Popup).show_frame(ui, frame, bounds, add) + } + pub(crate) fn show(ui: &mut Ui, width: f32, add: impl FnOnce(&mut Ui) -> R) -> R { - Self::frame() - .show(ui, |ui| { - ui.set_width((width - Tokens::get().space_6 * 2.0 - 2.0).max(0.0)); - ui.spacing_mut().item_spacing.y = 0.0; - ui.with_layout(egui::Layout::top_down(egui::Align::Min), add).inner - }) - .inner + Self::show_frame(ui, Self::frame(), |ui| { + ui.set_width((width - Tokens::get().space_6 * 2.0 - 2.0).max(0.0)); + ui.spacing_mut().item_spacing.y = 0.0; + ui.with_layout(egui::Layout::top_down(egui::Align::Min), add).inner + }) + .inner } pub(crate) fn separator(ui: &mut Ui) { let t = Tokens::get(); let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), t.space_10 + 1.0), Sense::hover()); - ui.painter().hline(r.x_range(), r.center().y, Stroke::new(1.0, t.dialog_rule)); + ui.painter().hline(r.x_range(), r.center().y, t.tertiary_border(t.dialog_rule)); } pub(crate) fn heading(ui: &mut Ui, label: &str) { @@ -187,7 +193,7 @@ impl<'a> MenuItem<'a> { super::checks::paint_check_glyph(&painter, icon, false, t.form_primary); } } else if let Some(color) = self.swatch { - painter.rect(icon, t.radius_sm, color, Stroke::new(1.0, t.control_border), egui::StrokeKind::Inside); + painter.rect(icon, t.radius_sm, color, t.control_border_stroke(t.control_border), egui::StrokeKind::Inside); } else if !self.icon.is_empty() { if self.danger { icons::paint_danger(&painter, icon, self.icon, 1.0); @@ -230,7 +236,7 @@ pub(crate) fn workspace_menu(ui: &mut egui::Ui, items: &[Item], key_row: Option< let t = Tokens::get(); let width = t.form_card_width + t.form_label_width * 2.0 + t.space_24 + t.space_16 + t.space_6; let mut picked = None; - Menu::frame().inner_margin(t.space_8 + 1.0).show(ui, |ui| { + Menu::show_frame(ui, Menu::frame().inner_margin(t.space_8 + 1.0), |ui| { ui.set_width(width); ui.spacing_mut().item_spacing.y = 0.0; ui.add_space(t.space_4); @@ -268,18 +274,18 @@ pub(crate) fn workspace_menu(ui: &mut egui::Ui, items: &[Item], key_row: Option< crate::scenario::publish_target(&handle, r); } if current || (item.enabled && (resp.hovered() || key_row == Some(k))) { - ui.painter().rect_filled(r, 5.0, soft); + ui.painter().rect_filled(r, t.control_radius.saturating_sub(1), soft); } ui.painter().rect_filled( Rect::from_min_max(r.min + vec2(t.space_2, t.space_6 + 1.0), pos2(r.left() + 6.0, r.bottom() - 7.0)), - 2.0, + t.radius_sm / 2, accent, ); let tile = Rect::from_center_size( pos2(r.left() + 40.0, r.center().y), vec2(t.space_24 + t.space_16 + t.space_4, t.space_24 + t.space_16 + t.space_4), ); - ui.painter().rect_filled(tile, 9.0, if planned { t.panel_header } else { soft }); + ui.painter().rect_filled(tile, t.radius_xl.saturating_sub(1), if planned { t.panel_header } else { soft }); if !item.icon.is_empty() { icons::paint_icon(ui.painter(), tile.shrink(t.space_6 + 1.0), &item.icon, if item.enabled { 1.0 } else { 0.35 }); } @@ -307,7 +313,7 @@ pub(crate) fn workspace_menu(ui: &mut egui::Ui, items: &[Item], key_row: Option< if current { t.on_accent } else { accent }, ); let badge_rect = Rect::from_min_size(pos2(badge_x, y - 8.0), badge.size() + vec2(t.space_12, t.space_3)); - ui.painter().rect_filled(badge_rect, 8.0, if current { accent } else { soft }); + ui.painter().rect_filled(badge_rect, t.radius_lg, if current { accent } else { soft }); ui.painter().galley(badge_rect.min + vec2(t.space_6, t.space_3 / 2.0), badge, accent); let line = |content: String, y: f32| { let mut job = @@ -353,6 +359,7 @@ pub(crate) fn rename_menu(ui: &mut Ui, value: &mut String, focused: &mut bool) - Menu::heading(ui, "Rename"); let id = egui::Id::new("sc_rename_text"); let response = ui.add(super::fields::text_input(egui::TextEdit::singleline(value).id(id)).desired_width(ui.available_width())); + t.input_bevel(ui, &response); crate::scenario::publish_target("rename:name", response.rect); if !*focused { if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), id) { diff --git a/crates/ui-egui/src/ui_kit/mod.rs b/crates/ui-egui/src/ui_kit/mod.rs index 4e5a0c7223..02b892bc37 100644 --- a/crates/ui-egui/src/ui_kit/mod.rs +++ b/crates/ui-egui/src/ui_kit/mod.rs @@ -71,3 +71,17 @@ mod slider_tests; mod test_support; pub(crate) use layout::label as row_label; + +/// Whether `need` points fit in `have`, answered so that a scrollbar coming and going (it takes +/// `ui.spacing().scroll.allocated_width()` of the width) does not flip it: what has fitted keeps +/// fitting until it misses by more than the scrollbar's width, and what has not fitted must fit +/// outright. With nothing remembered yet it is the generous answer, so the first frame with a +/// scrollbar and the first without agree. Keyed by `key` within `ui`. +pub(crate) fn fits_steadily(ui: &egui::Ui, key: impl std::hash::Hash + std::fmt::Debug, need: f32, have: f32) -> bool { + let slack = ui.spacing().scroll.allocated_width(); + let id = ui.make_persistent_id(("fits_steadily", key)); + let was = ui.data(|data| data.get_temp::(id)).unwrap_or(true); + let fits = need <= have + if was { slack } else { 0.0 }; + ui.data_mut(|data| data.insert_temp(id, fits)); + fits +} diff --git a/crates/ui-egui/src/ui_kit/panel.rs b/crates/ui-egui/src/ui_kit/panel.rs index f8f20e8dae..4d7e4d43f3 100644 --- a/crates/ui-egui/src/ui_kit/panel.rs +++ b/crates/ui-egui/src/ui_kit/panel.rs @@ -59,7 +59,7 @@ fn header_impl(ui: &mut Ui, icon: &str, title: &str, collapsed: Option<&mut bool let title_response = ui.interact(title_rect, ui.make_persistent_id("title_text"), egui::Sense::hover()); title_response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, ui.is_enabled(), title)); scenario::publish_rect("dialog:header", rect); - ui.painter().hline(rect.x_range(), rect.bottom(), egui::Stroke::new(1.0, t.dialog_rule)); + t.secondary_hline(ui.painter(), rect.x_range(), rect.bottom(), t.dialog_rule); } /// A bounded docked or floating card. Uses form_card_width and the caller's tokenised frame. @@ -312,6 +312,7 @@ impl<'a> Panel<'a> { ui.interact(rect, ui.id().with("compute_status"), egui::Sense::hover()).on_hover_text(status); ui.ctx().request_repaint_after(std::time::Duration::from_millis(100)); } + super::Container::finish_content(ui, self.positioned_frame(ui.ctx())); (close, action) }) } @@ -360,6 +361,7 @@ impl<'a> Panel<'a> { } else { contents(ui); } + super::Container::finish_content(ui, self.positioned_frame(ui.ctx())); }); } } @@ -560,7 +562,9 @@ impl<'a> Window<'a> { .frame(frame) .show(ctx, |ui| { ui.set_width(inner_width); - add(ui) + let result = add(ui); + super::Container::finish_content(ui, frame); + result }); scenario::publish_rect("dialog:window", shown.response.rect); return shown.inner; @@ -583,7 +587,11 @@ impl<'a> Window<'a> { (None, Some((pivot, pos))) => window.pivot(pivot).default_pos(pos), (None, None) => window.pivot(egui::Align2::CENTER_CENTER).default_pos(ctx.content_rect().center() - vec2(0.0, row_height() * 2.5)), }; - let shown = window.show(ctx, add); + let shown = window.show(ctx, |ui| { + let result = add(ui); + super::Container::finish_content(ui, frame); + result + }); if let Some(shown) = &shown { scenario::publish_rect("dialog:window", shown.response.rect); } @@ -614,7 +622,7 @@ fn page_list_sized(ui: &mut Ui, selected: &mut usize, pages: &[WindowPage] let width = (t.form_label_width * 2.0 + t.space_24 * 2.0).min(body.width()); let nav = egui::Rect::from_min_size(body.min, vec2(width, body.height())); ui.painter().rect_filled(nav, 0.0, t.dialog_footer); - ui.painter().vline(nav.right(), nav.y_range(), egui::Stroke::new(1.0, t.dialog_rule)); + t.secondary_vline(ui.painter(), nav.right(), nav.y_range(), t.dialog_rule); scenario::publish_rect("dialog:pages", nav); *selected = (*selected).min(pages.len().saturating_sub(1)); ui.scope_builder(egui::UiBuilder::new().max_rect(nav.shrink2(vec2(t.space_8, t.space_6))), |ui| { diff --git a/crates/ui-egui/src/ui_kit/panel_tests.rs b/crates/ui-egui/src/ui_kit/panel_tests.rs index 35d4a67054..36e974f174 100644 --- a/crates/ui-egui/src/ui_kit/panel_tests.rs +++ b/crates/ui-egui/src/ui_kit/panel_tests.rs @@ -428,6 +428,7 @@ fn confirm_routes_enter_escape_selected_answers_and_blocks_background_clicks() { action = Confirm::new(ui.id().with("modal"), "status_warning", "Save changes?") .tone(Tone::Warning) .actions("Save", "Cancel", Some("Don't Save")) + .secondary_left() .selected(selected) .detail("Sample Plate") .show(ui.ctx(), valid, |ui| { @@ -447,6 +448,76 @@ fn confirm_routes_enter_escape_selected_answers_and_blocks_background_clicks() { } } +#[test] +fn save_prompt_frames_dont_save_and_clicking_it_discards_for_close_and_quit() { + for quit in [false, true] { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.app.run("solid.box", serde_json::json!({"length": 10, "width": 10, "height": 10})).unwrap(); + if quit { + crate::documents::request_quit(&mut h.app); + } else { + let active = h.app.docs.active; + crate::documents::close(&mut h.app, active, false); + } + h.ctx = crate::fonts::test_context(); + h.ctx.enable_accesskit(); + let mut textures = std::collections::HashMap::new(); + let mut output = egui::FullOutput::default(); + for _ in 0..3 { + output = h.ctx.run_ui( + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0))), + focused: true, + ..Default::default() + }, + |ui| { + h.app.logic(ui.ctx()); + h.app.ui(ui); + }, + ); + super::super::checks::tests::textures_update(&mut textures, &output.textures_delta); + output.textures_delta.clear(); + } + h.output = output.platform_output.clone(); + let discard = scenario::rect_of("dialog:apply").unwrap(); + assert!(discard.contains(scenario::handle_at("dialog:Don't Save").unwrap())); + assert!( + h.output + .accesskit_update + .as_ref() + .unwrap() + .nodes + .iter() + .any(|(_, node)| { node.label() == Some("Don't Save") && node.role() == egui::accesskit::Role::Button }), + "discard remains an accessible button" + ); + let t = crate::theme::Tokens::get(); + let mut framed = false; + for shape in &output.shapes { + crate::test_support::each_shape(&shape.shape, &mut |s| { + framed |= matches!(s, egui::Shape::Rect(r) if r.rect == discard + && r.fill == t.control_fill && r.stroke.width > 0.0 && r.stroke.color == t.control_border); + }); + } + assert!(framed, "Don't Save needs a visible secondary button frame before hover or selection"); + if let Ok(dir) = std::env::var("SOLIDCRAFT_SAVE_PROMPT_EVIDENCE") { + let bounds = scenario::rect_of("dialog:header").unwrap().union(scenario::rect_of("dialog:footer").unwrap()).expand(t.space_24); + let image = super::super::checks::tests::raster(&h.ctx, output.shapes, output.pixels_per_point, bounds, &textures); + std::fs::create_dir_all(&dir).unwrap(); + image.save(std::path::Path::new(&dir).join(if quit { "save-quit.png" } else { "save-close.png" })).unwrap(); + } + h.step(&serde_json::json!({"click": {"handle": "dialog:Don't Save"}})).unwrap(); + h.frames(2); + assert!(h.app.docs.closing.is_none()); + assert!(!h.app.session.is_dirty(), "the changes were discarded"); + assert_eq!(h.app.quit_requested, quit, "continue the requested close or quit"); + if !quit { + assert!(h.app.session.doc.features.is_empty(), "closing the last tab leaves a blank design"); + } + } +} + #[test] fn destructive_confirm_paints_error_primary_with_readable_on_colour_in_both_themes() { let previous = crate::theme::is_dark(); @@ -645,3 +716,230 @@ fn compact_tool_sheet_corners_follow_its_right_edge_contact() { assert_eq!(container.tool_frame(frame, false).corner_radius, egui::CornerRadius::same(t.radius_lg)); assert_eq!(container.tool_frame(frame, true).corner_radius, egui::CornerRadius { nw: t.radius_lg, ne: 0, sw: t.radius_lg, se: 0 }); } + +#[test] +fn theme_shape_changes_preserve_panel_geometry_and_dock_contacts() { + use crate::{frame::Side, shell::LayoutMode}; + let ctx = crate::fonts::test_context(); + let mut results = Vec::new(); + for name in ["solidcraft-95", "blueprint", "cupertino"] { + let t = crate::theme_files::resolve(crate::theme_files::find(name, &[]).unwrap()); + let _theme = crate::theme::scoped_tokens(t); + for surface in [Surface::Panel, Surface::Feature, Surface::Window, Surface::Popup, Surface::Navigation, Surface::Toast] { + crate::theme::apply(&ctx); + let floating = Container::for_layout(LayoutMode::Floating, surface).frame(&ctx, t.panel, Side::Left); + let bounds = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(240.0, 180.0)); + results.push((name, surface, floating.corner_radius, floating.outer_rect(bounds), t)); + } + let docked = Container::for_layout(LayoutMode::Docked, Surface::Panel) + .joined(DockEdges { left: true, top: true, ..Default::default() }) + .frame(&ctx, t.panel, Side::Left); + assert_eq!(docked.corner_radius, egui::CornerRadius { nw: 0, ne: 0, sw: 0, se: t.radius_lg }); + } + for (name, surface, radius, bounds, t) in &results { + let expected = match surface { + Surface::Panel => t.radius_lg, + Surface::Feature | Surface::Window => t.radius_xl, + _ => t.radius_md, + }; + assert_eq!(*radius, egui::CornerRadius::same(expected), "{name}: {surface:?}"); + assert_eq!(*bounds, results.iter().find(|row| row.1 == *surface).unwrap().3, "shape must not change layout"); + } +} + +#[test] +fn border_hierarchy_reaches_hosts_sections_controls_and_inset_without_new_targets() { + use crate::theme::{Borders, InsetBorder}; + use egui::{Color32, Stroke}; + for dark in [false, true] { + let mut t = if dark { Tokens::dark() } else { Tokens::light() }; + t.borders = Borders { + primary: Some(Stroke::new(4.0, Color32::WHITE)), + secondary: Some(Stroke::new(2.0, Color32::RED)), + secondary_dotted: false, + tertiary: Some(Stroke::new(0.5, Color32::GREEN)), + container: None, + bevel: None, + control: Some(Stroke::new(1.5, Color32::BLUE)), + primary_inset: Some(InsetBorder { stroke: Stroke::new(0.75, Color32::YELLOW), gap: 3.0 }), + }; + let _restore = crate::theme::scoped_tokens(t); + let ctx = crate::fonts::test_context(); + let mut value = "12 mm".to_string(); + let output = crate::test_support::frame(&ctx, egui::RawInput::default(), |ui| { + let container = Container::for_layout(crate::shell::LayoutMode::Floating, Surface::Panel); + let frame = container.frame(ui.ctx(), t.panel, crate::frame::Side::Left).inner_margin(12); + let bounds = ui.max_rect(); + container.show_frame(ui, frame, bounds, |ui| { + ui.set_width(280.0); + scope(ui, |ui| { + header(ui, "box", "Box", || {}); + row(ui, "Width", |ui| { + field(ui, &mut value); + }); + ui.separator(); + }); + }); + }); + let mut found = [false; 5]; + for shape in &output.shapes { + crate::test_support::each_shape(&shape.shape, &mut |shape| { + let stroke = match shape { + egui::Shape::Rect(rect) => Some(rect.stroke), + egui::Shape::LineSegment { stroke, .. } => Some(*stroke), + _ => None, + }; + if let Some(stroke) = stroke { + for (index, expected) in [ + t.borders.primary.unwrap(), + t.borders.secondary.unwrap(), + t.borders.tertiary.unwrap(), + t.borders.control.unwrap(), + t.borders.primary_inset.unwrap().stroke, + ] + .iter() + .enumerate() + { + found[index] |= stroke == *expected; + } + } + }); + } + assert!(found.into_iter().all(|v| v), "missing role: {found:?}"); + for layout in [crate::shell::LayoutMode::Floating, crate::shell::LayoutMode::Docked] { + for surface in [ + Surface::Panel, + Surface::Chrome, + Surface::Feature, + Surface::Palette, + Surface::Window, + Surface::Popup, + Surface::Toast, + Surface::Navigation, + ] { + let frame = Container::for_layout(layout, surface).frame(&ctx, t.panel, crate::frame::Side::Left); + assert_eq!(frame.stroke, t.borders.primary.unwrap(), "{layout:?} {surface:?}"); + assert!(frame.total_margin().sum().is_finite()); + } + } + } +} + +#[test] +fn inset_border_skips_tiny_or_invalid_surfaces_and_keeps_dock_contacts_square() { + let mut t = Tokens::dark(); + t.borders.primary = Some(egui::Stroke::new(4.0, egui::Color32::WHITE)); + t.borders.primary_inset = Some(crate::theme::InsetBorder { stroke: egui::Stroke::new(4.0, egui::Color32::YELLOW), gap: 12.0 }); + let _restore = crate::theme::scoped_tokens(t); + let ctx = crate::fonts::test_context(); + let output = crate::test_support::frame(&ctx, egui::RawInput::default(), |ui| { + let frame = Container::for_layout(crate::shell::LayoutMode::Docked, Surface::Panel).joined(DockEdges::ALL).frame( + &ctx, + t.panel, + crate::frame::Side::Left, + ); + assert_eq!(frame.corner_radius, egui::CornerRadius::ZERO); + assert_eq!(frame.outer_margin.left, -4); + for rect in [egui::Rect::from_min_size(egui::pos2(20.0, 20.0), egui::vec2(20.0, 20.0)), egui::Rect::NOTHING] { + Container::paint_inset(ui.painter(), rect, frame); + } + }); + for shape in &output.shapes { + crate::test_support::each_shape(&shape.shape, &mut |s| { + if let egui::Shape::Rect(rect) = s { + assert_ne!(rect.stroke.color, egui::Color32::YELLOW); + } + }); + } +} + +#[test] +fn menu_companion_is_visible_in_frame_padding_and_bounded_by_its_surface() { + let mut t = Tokens::dark(); + t.borders.primary = Some(egui::Stroke::new(2.0, egui::Color32::WHITE)); + t.borders.primary_inset = Some(crate::theme::InsetBorder { stroke: egui::Stroke::new(0.75, egui::Color32::YELLOW), gap: 3.0 }); + let _restore = crate::theme::scoped_tokens(t); + let ctx = crate::fonts::test_context(); + let mut surface = egui::Rect::NOTHING; + let output = crate::test_support::frame(&ctx, egui::RawInput::default(), |ui| { + surface = Menu::show_frame(ui, Menu::frame(), |ui| { + ui.set_width(200.0); + ui.label("New Design"); + Menu::separator(ui); + ui.label("Open"); + }) + .response + .rect; + }); + let mut found = false; + for clipped in &output.shapes { + crate::test_support::each_shape(&clipped.shape, &mut |shape| { + if let egui::Shape::Rect(rect) = shape + && rect.stroke.color == egui::Color32::YELLOW + { + found = true; + assert!(surface.contains_rect(rect.rect)); + assert!(clipped.clip_rect.contains_rect(rect.rect)); + assert!((rect.rect.left() - surface.left() - 5.0).abs() < 0.01); + } + }); + } + assert!(found, "the companion must be painted outside the menu content clip"); +} + +#[test] +fn bevel_lighting_reverses_for_fields_and_pressed_buttons_without_changing_geometry() { + use egui::{Color32, Pos2, Rect}; + let mut t = Tokens::light(); + let bevel = + crate::theme::Bevel { width: 1.0, highlight: Color32::WHITE, light: Color32::LIGHT_GRAY, shadow: Color32::GRAY, dark_shadow: Color32::BLACK }; + t.borders.bevel = Some(bevel); + t.control_radius = 0; + let _restore = crate::theme::scoped_tokens(t); + let ctx = crate::fonts::test_context(); + let mut button_rect = Rect::NOTHING; + let mut field_rect = Rect::NOTHING; + let mut text = "12 mm".to_string(); + let mut draw = |ui: &mut Ui| { + ui.set_width(240.0); + scope(ui, |ui| { + button_rect = button(ui, "Review", Button::Normal).rect; + field_rect = cell_editor(ui, egui::Id::new("bevel-field"), &mut text, "", false).rect; + }); + }; + fn top_color(output: &egui::FullOutput, rect: Rect, inset: f32) -> Color32 { + output + .shapes + .iter() + .find_map(|shape| match &shape.shape { + egui::Shape::LineSegment { points: [a, b], stroke } + if (a.y - rect.top() - inset).abs() < 0.01 && (b.y - a.y).abs() < 0.01 && a.x >= rect.left() && b.x <= rect.right() => + { + Some(stroke.color) + } + _ => None, + }) + .expect("horizontal bevel edge") + } + let raised = crate::test_support::frame(&ctx, egui::RawInput::default(), &mut draw); + assert_eq!(top_color(&raised, button_rect, 0.5), bevel.highlight); + assert_eq!(top_color(&raised, field_rect, 1.5), bevel.shadow); + let original_rect = button_rect; + let mut draw = |ui: &mut Ui| { + ui.set_width(240.0); + scope(ui, |ui| { + button_rect = button(ui, "Review", Button::Normal).rect; + cell_editor(ui, egui::Id::new("bevel-field"), &mut text, "", false); + }); + }; + let down = crate::test_support::frame( + &ctx, + egui::RawInput { events: crate::test_support::pointer(original_rect.center(), true), ..Default::default() }, + &mut draw, + ); + assert_eq!(button_rect, original_rect); + assert_eq!(top_color(&down, button_rect, 0.5), bevel.shadow); + let tiny = Rect::from_min_size(Pos2::ZERO, egui::vec2(2.0, 2.0)); + let output = crate::test_support::frame(&ctx, egui::RawInput::default(), |ui| bevel.paint(ui.painter(), tiny, 0, false, true)); + assert!(!output.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::LineSegment { .. }))); +} diff --git a/crates/ui-egui/src/ui_kit/preferences.rs b/crates/ui-egui/src/ui_kit/preferences.rs index a3f920d72c..3237f5c27b 100644 --- a/crates/ui-egui/src/ui_kit/preferences.rs +++ b/crates/ui-egui/src/ui_kit/preferences.rs @@ -67,10 +67,11 @@ pub(crate) struct ThemeSwatch { panel: Color32, accent: Color32, dim: Color32, + radius: u8, } impl From for ThemeSwatch { fn from(t: Tokens) -> Self { - Self { background: t.viewport_top, panel: t.panel, accent: t.accent, dim: t.text_dim } + Self { background: t.viewport_top, panel: t.panel, accent: t.accent, dim: t.text_dim, radius: t.control_radius } } } @@ -223,7 +224,7 @@ pub(crate) fn option_card(ui: &mut Ui, label: &str, note: Option<&str>, selected } else { t.control_fill }, - Stroke::new(if selected { 2.0 } else { 1.0 }, if selected { t.form_primary } else { t.control_border }), + if selected { Stroke::new(2.0, t.form_primary) } else { t.control_border_stroke(t.control_border) }, egui::StrokeKind::Inside, ); let mut preview = Rect::from_min_size(rect.min + vec2(t.space_6, t.space_6), vec2((rect.width() - t.space_12).max(0.0), preview_height)); @@ -295,7 +296,7 @@ fn paint_preference_figure(p: &egui::Painter, rect: Rect, figure: PreferenceFigu return; } p.rect_filled(rect, t.control_radius, t.viewport_top); - p.rect_stroke(rect, t.control_radius, Stroke::new(1.0, t.dialog_rule), egui::StrokeKind::Inside); + t.container_rect(&p, rect, t.control_radius, egui::Color32::TRANSPARENT, t.dialog_rule); match figure { PreferenceFigure::Theme { .. } => {} PreferenceFigure::Quality(sides) => { @@ -308,7 +309,7 @@ fn paint_preference_figure(p: &egui::Painter, rect: Rect, figure: PreferenceFigu centre + vec2(a.cos(), a.sin()) * radius }) .collect(); - p.extend(egui::Shape::dashed_line(&guide, Stroke::new(1.0, t.control_border), t.space_2, t.space_3)); + p.extend(egui::Shape::dashed_line(&guide, t.control_border_stroke(t.control_border), t.space_2, t.space_3)); let points = (0..n) .map(|i| { let a = std::f32::consts::TAU * i as f32 / n as f32; @@ -384,7 +385,7 @@ fn clip_half_plane(vertices: Vec, side: impl Fn(Pos2) -> f32) -> Vec } fn theme_swatch(p: &egui::Painter, r: Rect, c: ThemeSwatch, diagonal: bool) { - let boundary = rounded_perimeter(r, f32::from(Tokens::get().control_radius)); + let boundary = rounded_perimeter(r, f32::from(c.radius)); let fill = |mut vertices: Vec, color: Color32| { if diagonal { vertices = clip_half_plane(vertices, |v| (v.x - r.left()) / r.width() + (v.y - r.top()) / r.height() - 1.0); @@ -453,7 +454,7 @@ pub(crate) fn scale_specimen(ui: &mut Ui, scale: f32) { let t = Tokens::get(); let scale = if scale.is_finite() { scale.clamp(0.5, 2.5) / ui.ctx().zoom_factor().max(0.5) } else { 1.0 }; let (r, _) = ui.allocate_exact_size(vec2(dimension(ui.available_width()), t.space_24 * 4.0 + t.space_16), Sense::hover()); - ui.painter().rect(r, t.radius_lg, t.control_fill, Stroke::new(1.0, t.dialog_rule), egui::StrokeKind::Inside); + t.container_rect(ui.painter(), r, t.radius_lg, t.control_fill, t.dialog_rule); let p = ui.painter().with_clip_rect(r.shrink(t.space_8)); let font = FontId::proportional(t.font_label * scale); p.text( @@ -469,11 +470,23 @@ pub(crate) fn scale_specimen(ui: &mut Ui, scale: f32) { let label = p.layout_no_wrap(distance.to_owned(), font.clone(), t.form_dim); p.galley(origin + vec2(0.0, (line_h - label.size().y) / 2.0), label.clone(), t.form_dim); let field = Rect::from_min_size(origin + vec2(label.size().x + t.space_8 * scale, 0.0), vec2((t.form_label_width + t.space_6) * scale, line_h)); - p.rect(field, t.control_radius, t.control_fill, Stroke::new(1.0, t.control_border), egui::StrokeKind::Inside); + p.rect(field, t.control_radius, t.input_fill(), t.control_border_stroke(t.control_border), egui::StrokeKind::Inside); + if let Some(bevel) = t.borders.bevel { + bevel.paint(&p, field, t.control_radius, true, true); + } centred_text(&p, field, "12.50", FontId::monospace(t.font_body * scale), t.form_ink); let button = Rect::from_min_size(field.right_top() + vec2(t.space_8 * scale, 0.0), vec2(t.space_24 * 1.5 * scale, line_h)); - p.rect_filled(button, t.control_radius, t.form_primary); - centred_text(&p, button, crate::i18n::tr_label("OK"), crate::fonts::semibold(t.font_label * scale), t.on_accent); + p.rect_filled(button, t.control_radius, if t.borders.bevel.is_some() { t.control_fill } else { t.form_primary }); + if let Some(bevel) = t.borders.bevel { + bevel.paint(&p, button, t.control_radius, false, true); + } + centred_text( + &p, + button, + crate::i18n::tr_label("OK"), + crate::fonts::semibold(t.font_label * scale), + if t.borders.bevel.is_some() { t.form_ink } else { t.on_accent }, + ); let feature = Rect::from_min_size(origin + vec2(0.0, line_h + t.space_8 * scale), vec2(r.width() - t.space_10 * 2.0, line_h)); p.rect_filled(feature, t.control_radius, t.selected_bg); let icon = Rect::from_center_size(pos2(feature.left() + t.space_16 * scale, feature.center().y), vec2(t.space_16, t.space_16) * scale); @@ -485,7 +498,7 @@ pub(crate) fn scale_specimen(ui: &mut Ui, scale: f32) { crate::fonts::semibold(t.font_label * scale), t.selected_ink, ); - p.text(feature.right_center() - vec2(t.space_8, 0.0), egui::Align2::RIGHT_CENTER, "12.5 mm", font, t.form_dim); + p.text(feature.right_center() - vec2(t.space_8, 0.0), egui::Align2::RIGHT_CENTER, "12.5 mm", font, t.selected_ink); } /// Font picker and auxiliary actions share one line, with the specimen below. @@ -515,7 +528,7 @@ pub(crate) fn settings_inline_note(ui: &mut Ui, text: &str) { pub(crate) fn font_specimen(ui: &mut Ui, text: &str) { let t = Tokens::get(); let (r, _) = ui.allocate_exact_size(vec2(dimension(ui.available_width()), t.control_height + t.space_10), Sense::hover()); - ui.painter().rect(r, t.control_radius, t.control_fill, Stroke::new(1.0, t.dialog_rule), egui::StrokeKind::Inside); + t.container_rect(ui.painter(), r, t.control_radius, t.control_fill, t.dialog_rule); paint_text(ui, r.shrink2(vec2(t.space_12, 0.0)), text, FontId::proportional(t.font_button), t.form_ink, false); } diff --git a/crates/ui-egui/src/ui_kit/preferences_assistant.rs b/crates/ui-egui/src/ui_kit/preferences_assistant.rs index ed9e1de36d..6e559b45bc 100644 --- a/crates/ui-egui/src/ui_kit/preferences_assistant.rs +++ b/crates/ui-egui/src/ui_kit/preferences_assistant.rs @@ -44,11 +44,16 @@ fn choice_card(ui: &mut Ui, width: f32, choice: &SettingsChoice<'_>, selected: b } else { t.control_fill }, - Stroke::new(if selected { 2.0 } else { 1.0 }, if selected { t.form_primary } else { t.control_border }), + if selected { Stroke::new(2.0, t.form_primary) } else { t.control_border_stroke(t.control_border) }, egui::StrokeKind::Inside, ); let radio = pos2(rect.left() + t.space_16, rect.top() + t.space_16); - ui.painter().circle(radio, t.space_6, t.control_fill, Stroke::new(1.0, if selected { t.form_primary } else { t.control_border })); + ui.painter().circle( + radio, + t.space_6, + t.control_fill, + if selected { Stroke::new(1.0, t.form_primary) } else { t.control_border_stroke(t.control_border) }, + ); if selected { ui.painter().circle_filled(radio, t.space_3, t.form_primary); } diff --git a/crates/ui-egui/src/ui_kit/preferences_figures.rs b/crates/ui-egui/src/ui_kit/preferences_figures.rs index 2c90f458f6..ef46340ee7 100644 --- a/crates/ui-egui/src/ui_kit/preferences_figures.rs +++ b/crates/ui-egui/src/ui_kit/preferences_figures.rs @@ -184,10 +184,10 @@ pub(crate) fn paint_layout(ui: &mut egui::Ui, state: super::preferences::LayoutP let size = vec2(super::metrics::dimension(ui.available_width()), t.space_24 * 7.0 + t.space_6); let (outer, _) = ui.allocate_exact_size(size, egui::Sense::hover()); let p = ui.painter().with_clip_rect(outer); - p.rect(outer, t.radius_lg, t.control_fill, Stroke::new(1.0, t.dialog_rule), egui::StrokeKind::Inside); + t.container_rect(&p, outer, t.radius_lg, t.control_fill, t.dialog_rule); let r = outer.shrink(t.space_8); let c = Canvas { rect: r }; - p.rect(r, t.control_radius, t.viewport_top, Stroke::new(1.0, t.control_border), egui::StrokeKind::Inside); + p.rect(r, t.control_radius, t.viewport_top, t.control_border_stroke(t.control_border), egui::StrokeKind::Inside); let inset = if state.compact { 0.0 } else { 4.0 }; let gap = if state.compact { 0.0 } else { 3.0 }; let ribbon_height = if state.labels { 16.0 } else { 10.0 }; @@ -205,7 +205,7 @@ pub(crate) fn paint_layout(ui: &mut egui::Ui, state: super::preferences::LayoutP }; let panel = |x: f32, y: f32, w: f32, h: f32, label: &str, fill: Color32| { let rect = Rect::from_min_max(c.at(x, y), c.at(x + w, y + h)); - p.rect(rect, if state.compact { 0 } else { t.radius_sm }, fill, Stroke::new(1.0, t.control_border), egui::StrokeKind::Inside); + p.rect(rect, if state.compact { 0 } else { t.radius_sm }, fill, t.control_border_stroke(t.control_border), egui::StrokeKind::Inside); if !label.is_empty() { let centre = if h > 20.0 { c.at(x + w * 0.5, y + 7.0) } else { rect.center() }; p.text(centre, egui::Align2::CENTER_CENTER, crate::i18n::tr_label(label), egui::FontId::proportional(t.font_small), t.form_dim); diff --git a/crates/ui-egui/src/ui_kit/preferences_navigation.rs b/crates/ui-egui/src/ui_kit/preferences_navigation.rs index c314002a64..819c487727 100644 --- a/crates/ui-egui/src/ui_kit/preferences_navigation.rs +++ b/crates/ui-egui/src/ui_kit/preferences_navigation.rs @@ -42,7 +42,7 @@ fn settings_gesture(ui: &mut Ui, keys: &[String], action: &str) { let width = dimension(ui.available_width()); let (rect, response) = ui.allocate_exact_size(vec2(width, t.control_height + t.space_2), Sense::hover()); response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, ui.is_enabled(), format!("{}: {action}", keys.join(" + ")))); - ui.painter().hline(rect.x_range(), rect.bottom(), Stroke::new(1.0, t.dialog_rule)); + t.secondary_hline(ui.painter(), rect.x_range(), rect.bottom(), t.dialog_rule); let key_width = (t.form_label_width * 2.0 + t.space_24 + t.space_6).min(width * 0.57); let key_rect = Rect::from_min_size(rect.min, vec2(key_width, rect.height())); let p = ui.painter().with_clip_rect(key_rect); @@ -58,7 +58,7 @@ fn settings_gesture(ui: &mut Ui, keys: &[String], action: &str) { pos2(x + (galley.size().x + t.space_12) / 2.0, rect.center().y), vec2(galley.size().x + t.space_12, t.space_24 - t.space_2), ); - p.rect(cap, t.radius_sm, t.control_fill, Stroke::new(1.0, t.control_border), egui::StrokeKind::Inside); + p.rect(cap, t.radius_sm, t.control_fill, t.container_border(t.control_border), egui::StrokeKind::Inside); p.galley(pos2(cap.center().x - galley.size().x / 2.0, cap.center().y - galley.size().y / 2.0), galley, t.form_ink); x = cap.right() + t.space_4; } @@ -107,7 +107,7 @@ pub(crate) fn settings_axis_row( ) { let t = Tokens::get(); let (rect, _) = ui.allocate_exact_size(vec2(dimension(ui.available_width()), t.control_height + t.space_4), Sense::hover()); - ui.painter().hline(rect.x_range(), rect.bottom(), Stroke::new(1.0, t.dialog_rule)); + t.secondary_hline(ui.painter(), rect.x_range(), rect.bottom(), t.dialog_rule); let [label_rect, live_rect, invert_rect, speed_rect, source_rect] = axis_columns(rect); paint_text(ui, label_rect, name, FontId::proportional(t.font_label), t.form_ink, false); ui.interact(label_rect, ui.id().with("axis_name"), Sense::hover()).on_hover_text(name); @@ -145,7 +145,7 @@ pub(crate) fn live_axis_bar_in(ui: &Ui, live_rect: Rect, value: f32, dead_zone: ); let mut x = zone.left() - zone.height(); while x < zone.right() { - p.with_clip_rect(zone).line_segment([pos2(x, zone.bottom()), pos2(x + zone.height(), zone.top())], Stroke::new(1.0, t.control_border)); + p.with_clip_rect(zone).line_segment([pos2(x, zone.bottom()), pos2(x + zone.height(), zone.top())], t.control_border_stroke(t.control_border)); x += t.space_4; } let value = if value.is_finite() { value.clamp(-1.0, 1.0) } else { 0.0 }; diff --git a/crates/ui-egui/src/ui_kit/preferences_small.rs b/crates/ui-egui/src/ui_kit/preferences_small.rs index 74d12d0611..d322d8d7a3 100644 --- a/crates/ui-egui/src/ui_kit/preferences_small.rs +++ b/crates/ui-egui/src/ui_kit/preferences_small.rs @@ -97,7 +97,7 @@ pub(crate) fn settings_bindings(ui: &mut Ui, bindings: &[(&[&str], &str)]) { for (keys, action) in bindings { let width = super::metrics::dimension(ui.available_width()); let (r, _) = ui.allocate_exact_size(vec2(width, t.control_height + t.space_2), Sense::hover()); - ui.painter().hline(r.x_range(), r.bottom(), Stroke::new(1.0, t.dialog_rule)); + t.secondary_hline(ui.painter(), r.x_range(), r.bottom(), t.dialog_rule); let mut left = r.left(); for key in *keys { let galley = ui.painter().layout_no_wrap((*key).into(), FontId::proportional(t.font_small), t.form_ink); @@ -105,7 +105,7 @@ pub(crate) fn settings_bindings(ui: &mut Ui, bindings: &[(&[&str], &str)]) { pos2(left + (galley.size().x + t.space_12) * 0.5, r.center().y), vec2(galley.size().x + t.space_12, t.control_height - t.space_8), ); - ui.painter().rect(key_rect, t.radius_sm, t.control_fill, Stroke::new(1.0, t.control_border), egui::StrokeKind::Inside); + ui.painter().rect(key_rect, t.radius_sm, t.control_fill, t.container_border(t.control_border), egui::StrokeKind::Inside); ui.painter().galley(key_rect.center() - galley.size() * 0.5, galley, t.form_ink); left = key_rect.right() + t.space_4; } diff --git a/crates/ui-egui/src/ui_kit/radial.rs b/crates/ui-egui/src/ui_kit/radial.rs index 5ff653bb4e..884ebc8e38 100644 --- a/crates/ui-egui/src/ui_kit/radial.rs +++ b/crates/ui-egui/src/ui_kit/radial.rs @@ -6,7 +6,6 @@ use std::f32::consts::{FRAC_PI_2, FRAC_PI_4, PI, TAU}; pub(crate) const RADIAL_RADIUS: f32 = 150.0; const INNER: f32 = 56.0; const GAP: f32 = 4.0; -const CORNER: f32 = 9.0; const STEPS: usize = 16; fn unit(a: f32) -> Vec2 { @@ -14,14 +13,17 @@ fn unit(a: f32) -> Vec2 { } /// Two equally sampled boundaries; their ends are joined by parallel straight sides. -/// The four joins are circular arcs of radius CORNER, tangent to both adjacent edges. +/// The four joins are circular arcs of the theme radius, tangent to both adjacent edges. fn boundaries(k: usize) -> (Vec, Vec) { + // The outer and inner circles are functional geometry; only the corner joins vary. + // Bound the radius so the inner arc remains ordered, even for hostile theme data. + let corner = f32::from(Tokens::get().radius_xl.saturating_sub(1)).min(12.0); let am = -FRAC_PI_2 + (k % 8) as f32 * FRAC_PI_4; let a0 = am - FRAC_PI_4 / 2.0; let a1 = am + FRAC_PI_4 / 2.0; - let d = GAP / 2.0 + CORNER; - let ro = RADIAL_RADIUS - CORNER; - let ri = INNER + CORNER; + let d = GAP / 2.0 + corner; + let ro = RADIAL_RADIUS - corner; + let ri = INNER + corner; let outer0 = a0 + (d / ro).asin(); let outer1 = a1 - (d / ro).asin(); let inner0 = a0 + (d / ri).asin(); @@ -36,13 +38,13 @@ fn boundaries(k: usize) -> (Vec, Vec) { } }; let mut outer = Vec::new(); - arc(&mut outer, po0, CORNER, a0 - FRAC_PI_2, outer0); + arc(&mut outer, po0, corner, a0 - FRAC_PI_2, outer0); arc(&mut outer, Pos2::ZERO, RADIAL_RADIUS, outer0, outer1); - arc(&mut outer, po1, CORNER, outer1, a1 + FRAC_PI_2); + arc(&mut outer, po1, corner, outer1, a1 + FRAC_PI_2); let mut inner = Vec::new(); - arc(&mut inner, pi0, CORNER, a0 - FRAC_PI_2, inner0 - PI); + arc(&mut inner, pi0, corner, a0 - FRAC_PI_2, inner0 - PI); arc(&mut inner, Pos2::ZERO, INNER, inner0, inner1); - arc(&mut inner, pi1, CORNER, inner1 - PI, a1 + FRAC_PI_2 - TAU); + arc(&mut inner, pi1, corner, inner1 - PI, a1 + FRAC_PI_2 - TAU); (outer, inner) } @@ -192,4 +194,19 @@ mod tests { } } } + + #[test] + fn theme_corner_extremes_keep_radial_geometry_finite_and_clicks_in_their_sectors() { + for radius in [0, 1, 2, 16, 32] { + let _theme = crate::theme::scoped_tokens(Tokens { radius_xl: radius, ..Tokens::dark() }); + for k in 0..8 { + let (outer, inner) = boundaries(k); + assert_eq!(outer.len(), inner.len()); + assert!(outer.iter().chain(&inner).all(|p| p.is_finite())); + let a = -FRAC_PI_2 + k as f32 * FRAC_PI_4; + assert!(contains(k, unit(a) * 104.0)); + assert!(!contains(k, unit(a + FRAC_PI_4 / 2.0) * 104.0)); + } + } + } } diff --git a/crates/ui-egui/src/ui_kit/reports.rs b/crates/ui-egui/src/ui_kit/reports.rs index e52f5f4e06..a67665b377 100644 --- a/crates/ui-egui/src/ui_kit/reports.rs +++ b/crates/ui-egui/src/ui_kit/reports.rs @@ -24,7 +24,7 @@ pub(crate) fn report_divider(ui: &mut Ui, key: &str) { let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width().max(0.0), 1.0), egui::Sense::hover()); let rect = rect.expand2(vec2(t.space_10, 0.0)); let clip = ui.clip_rect().expand2(vec2(t.space_10, 0.0)); - ui.painter().with_clip_rect(clip).hline(rect.x_range(), rect.top(), egui::Stroke::new(1.0, t.dialog_rule)); + ui.painter().with_clip_rect(clip).hline(rect.x_range(), rect.top(), t.tertiary_border(t.dialog_rule)); scenario::publish_rect(&format!("dialog:divider:{key}"), rect); } @@ -32,7 +32,7 @@ pub(crate) fn report_divider(ui: &mut Ui, key: &str) { pub(crate) fn list_frame(ui: &mut Ui, key: &str, add: impl FnOnce(&mut Ui) -> R) -> R { let t = Tokens::get(); let response = egui::Frame::NONE - .stroke(egui::Stroke::new(1.0, t.dialog_rule)) + .stroke(t.container_border(t.dialog_rule)) .corner_radius(t.control_radius) .inner_margin(egui::Margin::symmetric(t.space_10 as i8, 0)) .show(ui, |ui| { @@ -56,7 +56,7 @@ pub(crate) fn path_chip(ui: &mut Ui, path: &str, add: impl FnOnce(&mut Ui)) { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { add(ui); let (rect, response) = ui.allocate_exact_size(vec2(ui.available_width().max(0.0), t.control_height), egui::Sense::hover()); - ui.painter().rect(rect, t.control_radius, t.dialog_footer, egui::Stroke::new(1.0, t.dialog_rule), egui::StrokeKind::Inside); + ui.painter().rect(rect, t.control_radius, t.dialog_footer, t.tertiary_border(t.dialog_rule), egui::StrokeKind::Inside); super::text::paint_text(ui, rect.shrink2(vec2(t.space_8, 0.0)), path, egui::FontId::monospace(t.font_small), t.form_dim, false); response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, ui.is_enabled(), path)); response.on_hover_text(path); @@ -76,7 +76,7 @@ pub(crate) fn empty_box(ui: &mut Ui, key: &str, message: &str) { if let Some(first) = points.first().copied() { points.push(first); } - ui.painter().extend(egui::Shape::dashed_line(&points, egui::Stroke::new(1.0, t.control_border), t.space_4, t.space_3)); + ui.painter().extend(egui::Shape::dashed_line(&points, t.control_border_stroke(t.control_border), t.space_4, t.space_3)); ui.painter().text(rect.center(), egui::Align2::CENTER_CENTER, message, egui::FontId::proportional(t.font_label), t.form_dim); scenario::publish_rect(&format!("dialog:note:{key}"), rect); } diff --git a/crates/ui-egui/src/ui_kit/search.rs b/crates/ui-egui/src/ui_kit/search.rs index 4b821fda8c..0eea5586c8 100644 --- a/crates/ui-egui/src/ui_kit/search.rs +++ b/crates/ui-egui/src/ui_kit/search.rs @@ -97,9 +97,22 @@ impl CommandSearch { t.control_border }; if focused { - ui.painter().rect_filled(r.expand(t.space_3), radius + t.space_3, if invalid { t.form_error_fill } else { t.accent_soft }); + ui.painter().rect_filled( + r.expand(t.space_3), + Tokens::outset_radius(radius, t.space_3), + if invalid { t.form_error_fill } else { t.accent_soft }, + ); + } + ui.painter().rect( + r, + radius, + t.input_fill(), + if focused || invalid { Stroke::new(1.0, ink) } else { t.control_border_stroke(ink) }, + egui::StrokeKind::Inside, + ); + if let Some(bevel) = t.borders.bevel { + bevel.paint(ui.painter(), r.shrink(if focused || invalid { 1.0 } else { 0.0 }), radius, true, ui.is_enabled()); } - ui.painter().rect(r, radius, t.control_fill, Stroke::new(1.0, ink), egui::StrokeKind::Inside); icons::paint_icon( ui.painter(), Rect::from_center_size(r.left_center() + vec2((spacing.left + t.space_16 / 2.0).min(r.width() / 2.0), 0.0), vec2(t.space_16, t.space_16)), @@ -152,7 +165,7 @@ impl CommandSearch { let galley = ui.painter().layout_no_wrap(label.to_owned(), FontId::monospace(t.font_small), t.form_dim); let size = galley.size() + vec2(padding, t.space_4); let r = Rect::from_min_size(pos2(right.x - size.x, right.y - size.y / 2.0), size); - ui.painter().rect(r, Self::inner_radius(), t.dialog_footer, Stroke::new(1.0, t.dialog_rule), egui::StrokeKind::Inside); + t.container_rect(ui.painter(), r, Self::inner_radius(), t.dialog_footer, t.dialog_rule); ui.painter().galley(r.min + vec2(padding / 2.0, t.space_2), galley, t.form_dim); size.x } @@ -163,12 +176,14 @@ impl CommandSearch { pub(crate) fn detail(ui: &mut Ui, text: &str) { let t = Tokens::get(); - egui::Frame::NONE.fill(t.control_fill).stroke(Stroke::new(1.0, t.dialog_rule)).corner_radius(t.control_radius).inner_margin(t.space_8).show( - ui, - |ui| { + egui::Frame::NONE + .fill(t.control_fill) + .stroke(t.container_border(t.dialog_rule)) + .corner_radius(t.control_radius) + .inner_margin(t.space_8) + .show(ui, |ui| { super::text(ui, text, super::Text::MonoNote("command_parameters")); - }, - ); + }); } /// Lay out the full sheet, but allocate and paint only its unfolding height. The @@ -204,6 +219,7 @@ impl CommandSearch { // The edge-to-edge footer shares the card silhouette; keep the outer rule // above its fill, as an overflow-clipped footer would in the board. ui.painter().rect_stroke(sheet, t.radius_xl, frame.stroke, egui::StrokeKind::Inside); + super::Container::paint_inset(ui.painter(), sheet, frame); ui.allocate_rect(sheet, Sense::hover()); crate::scenario::publish_target("ribbon:search_popup", sheet); crate::scenario::publish_target("palette:outer", sheet); @@ -222,7 +238,7 @@ impl CommandSearch { let r = Rect::from_min_max(start, pos2(start.x + width, ui.min_rect().bottom())); ui.painter() .set(background, egui::Shape::rect_filled(r, egui::CornerRadius { nw: 0, ne: 0, sw: t.radius_xl, se: t.radius_xl }, t.dialog_footer)); - ui.painter().hline(r.x_range(), r.top(), Stroke::new(1.0, t.dialog_rule)); + t.secondary_hline(ui.painter(), r.x_range(), r.top(), t.dialog_rule); crate::scenario::publish_rect("ribbon:search_footer", r); } } @@ -373,4 +389,30 @@ mod tests { assert_eq!(CommandSearch::inner_radius(), 4); assert_eq!(f32::from(CommandSearch::inner_radius()), f32::from(t.radius_xl) - t.space_6); } + + #[test] + fn square_theme_search_and_focus_ring_stay_square_through_expansion() { + let tokens = Tokens { radius_sm: 0, radius_md: 0, radius_lg: 0, radius_xl: 0, control_radius: 0, sketch_mode_radius: 0, ..Tokens::dark() }; + let _theme = crate::theme::scoped_tokens(tokens); + let mut radii = Vec::new(); + for floating in [false, true] { + for progress in [0.0, 0.5, 1.0] { + let radius = CommandSearch::field_radius(progress, floating); + let ctx = crate::fonts::test_context(); + let mut query = String::new(); + let output = ctx.run_ui(egui::RawInput::default(), |ui| { + CommandSearch::field(ui, &mut query, false, true, true, SearchFieldGeometry { height: 40.0, radius, progress }, 4096); + }); + for shape in output.shapes { + if let egui::Shape::Rect(rect) = shape.shape + && (rect.fill == tokens.control_fill || rect.fill == tokens.accent_soft) + { + radii.push(rect.corner_radius); + } + } + } + } + assert_eq!(radii.len(), 12, "field and focus ring in both layouts at three expansion stages"); + assert!(radii.iter().all(|radius| *radius == egui::CornerRadius::ZERO)); + } } diff --git a/crates/ui-egui/src/ui_kit/shortcuts.rs b/crates/ui-egui/src/ui_kit/shortcuts.rs index 7e2f98060b..88e324f675 100644 --- a/crates/ui-egui/src/ui_kit/shortcuts.rs +++ b/crates/ui-egui/src/ui_kit/shortcuts.rs @@ -67,7 +67,7 @@ fn search_with_icon(ui: &mut Ui, id: egui::Id, value: &mut String, hint: &str) - r, t.control_radius, t.control_fill, - Stroke::new(1.0, if focused { t.form_primary } else { t.control_border }), + if focused { Stroke::new(1.0, t.form_primary) } else { t.control_border_stroke(t.control_border) }, egui::StrokeKind::Inside, ); let icon = Rect::from_center_size(pos2(r.left() + t.space_8 + t.space_16 * 0.5, r.center().y), vec2(t.space_16, t.space_16)); diff --git a/crates/ui-egui/src/ui_kit/slider_tests.rs b/crates/ui-egui/src/ui_kit/slider_tests.rs index b9323a9b86..e01be89c94 100644 --- a/crates/ui-egui/src/ui_kit/slider_tests.rs +++ b/crates/ui-egui/src/ui_kit/slider_tests.rs @@ -130,3 +130,26 @@ fn formatted_slider_editor_fits_without_shifting_the_track_during_a_scale_drag() } assert!(widths.windows(2).all(|pair| pair[0] == pair[1]), "dragging must not move the input range: {widths:?}"); } + +/// The value box of a slider takes typed numbers (the per-axis speeds among them): Tab order reaches it +/// and Enter commits; a number past the range is held to the range. +#[test] +fn a_sliders_value_box_takes_typed_numbers_and_clamps_them_to_its_range() { + use crate::ui_kit::test_support::*; + for (typed, expected) in [("1.5", 1.5), ("9", 3.0), ("-4", 0.0), ("0", 0.0)] { + let ctx = crate::fonts::test_context(); + let mut value = 1.0_f32; + let mut draw = |ui: &mut Ui| { + slider(ui, &mut value, 0.0..=3.0, Slider { step: Some(0.05), format: |v| format!("{v:.2}×"), ..Default::default() }); + }; + frame(&ctx, vec![], &mut draw); + let track = scenario::rect_of("dialog:slider_track:").unwrap_or_else(|| scenario::rects("dialog:slider").first().expect("the slider").1); + let at = egui::pos2(track.right() + 30.0, track.center().y); + frame(&ctx, pointer(at, true), &mut draw); + frame(&ctx, pointer(at, false), &mut draw); + frame(&ctx, vec![egui::Event::Text(typed.into())], &mut draw); + frame(&ctx, key(egui::Key::Enter), &mut draw); + frame(&ctx, vec![], &mut draw); + assert!((value - expected).abs() < 1e-3, "typed {typed}: {value}"); + } +} diff --git a/crates/ui-egui/src/ui_kit/status.rs b/crates/ui-egui/src/ui_kit/status.rs index 1451fc85ee..b98851908e 100644 --- a/crates/ui-egui/src/ui_kit/status.rs +++ b/crates/ui-egui/src/ui_kit/status.rs @@ -1,6 +1,6 @@ //! Status heading and constraint meter; Tokens: font_label, text, text_dim, accent, dialog_rule, sketch_accent, spacing. use crate::{fonts::RichTextExt as _, theme::Tokens}; -use egui::{RichText, Stroke, vec2}; +use egui::{RichText, vec2}; /// Status content for a named card. `key` is the scenario namespace, `fraction` is clamped. pub(crate) struct StatusHeader<'a> { @@ -40,7 +40,7 @@ pub(crate) fn status_header(ui: &mut egui::Ui, key: &str, name: &str, status: St crate::scenario::publish_count(&format!("{key}:constrained_fraction"), f64::from(fraction)); }) .response; - ui.painter().hline(ui.max_rect().x_range(), ui.cursor().top(), Stroke::new(1.0, t.dialog_rule)); + t.secondary_hline(ui.painter(), ui.max_rect().x_range(), ui.cursor().top(), t.dialog_rule); crate::scenario::publish_target(&format!("{key}:status_header"), response.rect); response } diff --git a/crates/ui-egui/src/ui_kit/table.rs b/crates/ui-egui/src/ui_kit/table.rs index 224d11efab..32ceb8f790 100644 --- a/crates/ui-egui/src/ui_kit/table.rs +++ b/crates/ui-egui/src/ui_kit/table.rs @@ -176,7 +176,7 @@ impl Table<'_> { scenario::publish_rect(&format!("dialog:column:{}:{}", self.id, column.label), cell); left = cell.right() + t.space_8; } - ui.painter().hline(rect.x_range(), rect.bottom(), egui::Stroke::new(1.0, t.dialog_rule)); + ui.painter().hline(rect.x_range(), rect.bottom(), t.tertiary_border(t.dialog_rule)); ui.advance_cursor_after_rect(rect); } @@ -204,7 +204,7 @@ impl Table<'_> { self.empty_shown = true; } let (rect, response) = ui.allocate_exact_size(vec2(self.width(), row_height() - t.space_4), egui::Sense::click()); - let stroke = egui::Stroke::new(1.0, t.control_border); + let stroke = t.control_border_stroke(t.control_border); let mut outline = Vec::new(); egui::epaint::tessellator::path::rounded_rectangle(&mut outline, rect.shrink(stroke.width * 0.5), f32::from(t.control_radius).into()); if let Some(first) = outline.first().copied() { @@ -372,7 +372,7 @@ pub(crate) fn active_pill(ui: &mut Ui) { let label = crate::i18n::tr_label("Active"); let width = label_width(ui, label) + t.space_16; let (rect, response) = ui.allocate_exact_size(vec2(width, t.font_small + t.space_6), egui::Sense::hover()); - ui.painter().rect(rect, t.radius_xl, t.control_fill, egui::Stroke::new(1.0, t.control_border), egui::StrokeKind::Inside); + ui.painter().rect(rect, t.radius_xl, t.control_fill, t.control_border_stroke(t.control_border), egui::StrokeKind::Inside); super::text::paint_text(ui, rect.shrink2(vec2(t.space_8, 0.0)), label, crate::fonts::semibold(t.font_small), t.form_primary, false); response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, ui.is_enabled(), label)); scenario::publish_handle(&format!("dialog:inline:{label}"), rect.center()); diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 1868470f97..b38cc09281 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -154,6 +154,8 @@ pub struct ViewportState { pub(crate) text_drag: Option<(u64, String)>, /// A part that joints place, being dragged. pub(crate) joint_drag: Option, + /// A free part being dragged to a new place (#2804). + pub(crate) occurrence_drag: Option, /// The plane the grid was last drawn on while Preferences ▸ Grid follows the view is on /// (#1470), kept so the choice is hysteretic near the thresholds (#1993). pub(crate) grid_plane: std::sync::Mutex, @@ -343,6 +345,8 @@ fn scene_key(app: &SolveApp) -> u64 { app.tree.redefine.hash(&mut h); crate::theme::is_dark().hash(&mut h); crate::theme::vision().hash(&mut h); + // Two themes may share a light/dark base while drawing different geometry colours. + crate::theme_files::color_key(&Tokens::get()).hash(&mut h); app.session.active_sketch.hash(&mut h); h.finish() } @@ -353,6 +357,8 @@ fn sketch_scene_key(app: &SolveApp, proj: &Proj) -> u64 { // The grid this scene draws follows the view when the preference says so (#1470): turning it // on or off redraws even while the camera holds still. app.preferences.grid_follows_view.hash(&mut h); + // Finished sketches are drawn over the bodies when the preference says so. + app.preferences.reference_geometry_through_bodies.hash(&mut h); (std::sync::Arc::as_ptr(&crate::open_meshing::shown_world(app)) as usize).hash(&mut h); crate::sketch_display::projection_key(proj, app.viewport.pixels_per_point()).hash(&mut h); h.finish() @@ -1055,7 +1061,10 @@ fn build_sketch_scene(app: &SolveApp, proj: &Proj) -> GpuScene { unit, ); for (a, b) in segs { - sc.line(a.to_f32(), b.to_f32(), sketch_color(&tk, col), if cons { 1.2 } else { 2.0 }, active); + // Active sketches retain their existing through-body display. Inactive sketches + // can opt into the same behaviour in Preferences. + let through = active || app.preferences.reference_geometry_through_bodies; + sc.line(a.to_f32(), b.to_f32(), sketch_color(&tk, col), if cons { 1.2 } else { 2.0 }, through); } } } @@ -1380,6 +1389,8 @@ pub(crate) fn pick_cached(app: &mut SolveApp, proj: &Proj, pos: Pos2) -> Vec Vec { } fn pick_with_sketch_points(app: &SolveApp, proj: &Proj, pos: Pos2, other_sketch_points: bool) -> Vec { - pick_hits(app, proj, pos, other_sketch_points, false) + let mut hits = pick_hits(app, proj, pos, other_sketch_points, false); + // A Single hole's face place snaps to the face's centre and its round edges' centres. + crate::hole_place::snap_hits(app, proj, pos, &mut hits); + hits } /// The same thing picked twice (one row per target in [`pick_all`]). @@ -1919,7 +1933,7 @@ fn analysis_legend(app: &SolveApp, painter: &egui::Painter, rect: Rect) { } for (c, text, hatched) in rows { let sw = Rect::from_min_size(pos2(r.left() + pad, y + 2.0), vec2(12.0, 12.0)); - painter.rect_filled(sw, 2.0, c); + painter.rect_filled(sw, t.radius_sm / 2, c); if hatched { // The stripes the viewport adds to these faces (colour vision modes). for k in [3.0, 7.0, 11.0] { @@ -2030,7 +2044,12 @@ pub(crate) fn dialog_pick(app: &mut SolveApp, hit: &Hit, sel: Sel) { return; } let Some(mut d) = app.dialog.take() else { return }; - d.pick(&app.session, sel); + // With holes moving by clicking (Preferences), a Single hole picked again on its face moves there. + if app.preferences.hole_moves && crate::hole_place::pick_moves(&mut d, &app.session, &sel) { + d.focus = true; + } else { + d.pick(&app.session, sel); + } if let crate::dialogs::Kind::PathPlane { sketch, .. } = &mut d.kind { *sketch = match hit { Hit::SketchCurve { sketch, .. } => Some(*sketch), @@ -2139,7 +2158,8 @@ pub(crate) fn profile_fill(sc: &mut GpuScene, app: &SolveApp, sketch: u64, index /// An origin or construction plane: reference geometry, which a section does not cut (#1229). /// A reference plane's square. `through` draws it over the model (Create Sketch: a plane inside -/// a body stays visible and pickable), else it is depth-tested like the rest of the scene. +/// a body stays visible and pickable; or the Preferences setting to show reference geometry +/// through bodies), else it is depth-tested like the rest of the scene. fn quad(sc: &mut GpuScene, q: &[Vec3; 4], fill: Color32, edge: Color32, through: bool) { let col = c4(fill); for i in [0, 1, 2, 0, 2, 3] { @@ -2153,11 +2173,17 @@ fn quad(sc: &mut GpuScene, q: &[Vec3; 4], fill: Color32, edge: Color32, through: } for i in 0..4 { let (a, b) = (q[i].to_f32(), q[(i + 1) % 4].to_f32()); - if through { - sc.line(a, b, c4(edge), 1.2, true); - } else { - sc.guide_line(a, b, c4(edge), 1.2); - } + reference_line(sc, a, b, c4(edge), 1.2, through); + } +} + +/// A reference line (plane edge, axis): over the model when `through` (as [`quad`]), else depth-tested and +/// left whole by a section. +fn reference_line(sc: &mut GpuScene, a: [f32; 3], b: [f32; 3], col: [u8; 4], width: f32, through: bool) { + if through { + sc.line(a, b, col, width, true); + } else { + sc.guide_line(a, b, col, width); } } @@ -2198,10 +2224,13 @@ fn highlight_geometry_key(app: &SolveApp) -> u64 { (app.dialog.is_some(), app.tool.is_some()).hash(&mut h); crate::theme::is_dark().hash(&mut h); crate::theme::vision().hash(&mut h); + // Two themes may share a light/dark base while drawing different geometry colours. + crate::theme_files::color_key(&Tokens::get()).hash(&mut h); app.preview.effective_replaced().hash(&mut h); app.preview.rolled_back.hash(&mut h); app.origin_visible().hash(&mut h); app.picking_plane().hash(&mut h); + app.preferences.reference_geometry_through_bodies.hash(&mut h); app.session.visibility.hidden_origin.hash(&mut h); app.session.visibility.hidden_bodies.hash(&mut h); app.session.visibility.hidden_occurrences.hash(&mut h); @@ -2368,7 +2397,7 @@ pub(crate) fn build_highlight(app: &SolveApp) -> GpuScene { }; let mut planes: Vec<(String, [Vec3; 4], Color32)> = origin_planes(app).into_iter().map(|(n, _, q)| (n.to_string(), q, t.origin_plane)).collect(); planes.extend(construction_quads(app).into_iter().map(|(n, _, q)| (n, q, t.construction_plane))); - let through = app.picking_plane(); + let through = app.picking_plane() || app.preferences.reference_geometry_through_bodies; for (name, q, base) in &planes { let (selected, hovered) = plane_state(name); let fill = if hovered { @@ -2389,11 +2418,11 @@ pub(crate) fn build_highlight(app: &SolveApp) -> GpuScene { }; let on = sel.iter().any(|x| matches!(x, Sel::Axis { name: n } if n == name)) || matches!(hover, Some(Hit::Axis { name: n }) if n == name); let w = if on { 4.0 } else { 2.0 }; - sc.guide_line([0.0; 3], (dir * size).to_f32(), c4(col), w); + reference_line(&mut sc, [0.0; 3], (dir * size).to_f32(), c4(col), w, through); let dash = size * 0.08; let mut a = size + dash; while a < size * AXIS_LEN { - sc.guide_line((dir * a).to_f32(), (dir * (a + dash)).to_f32(), c4(col), w * 0.75); + reference_line(&mut sc, (dir * a).to_f32(), (dir * (a + dash)).to_f32(), c4(col), w * 0.75, through); a += dash * 2.0; } } @@ -2408,7 +2437,7 @@ pub(crate) fn build_highlight(app: &SolveApp) -> GpuScene { let mut s = 0.0; while s < len { let e = (s + dash).min(len); - sc.guide_line(a.lerp(b, s / len).to_f32(), a.lerp(b, e / len).to_f32(), col, w); + reference_line(&mut sc, a.lerp(b, s / len).to_f32(), a.lerp(b, e / len).to_f32(), col, w, through); s += dash * 1.6; } } @@ -2483,6 +2512,11 @@ pub(crate) fn build_highlight(app: &SolveApp) -> GpuScene { feedback.line(a.to_f32(), b.to_f32(), c4(t.sel_edge), 3.5, true); } } + } else if let Some(w) = st.sketch(*sketch).and_then(|ss| ss.sketch.wire_index(id).and_then(|i| ss.sketch.wires.get(i))) { + // A 3D sketch curve of that sketch. + for q in w.pts.windows(2) { + feedback.line(q[0].to_f32(), q[1].to_f32(), c4(t.sel_edge), 3.5, true); + } } } // A 3D sketch curve, in whichever sketch has it. @@ -2856,7 +2890,11 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { || app.viewport.axis_drag.is_some() || app.viewport.spin || app.cam_anim.is_some(); - let hits = hover.filter(|_| inside && app.viewport.boxsel.is_none() && !navigating).map(|p| pick_cached(app, &proj, p)).unwrap_or_default(); + // A part dragged along its joint owns the pointer: nothing under it lights up (#5137). + let hits = hover + .filter(|_| inside && app.viewport.boxsel.is_none() && !crate::joint_drag::active(app) && !navigating) + .map(|p| pick_cached(app, &proj, p)) + .unwrap_or_default(); // The Fillet and Chamfer window resolves its own corners: the nearest sketch point to a // corner drawn from the origin is the fixed origin, which ends no two curves. let corner = hover.filter(|_| inside && !navigating).and_then(|p| crate::sketch_modify_ui::corner_hit(app, &proj, p)); @@ -3072,7 +3110,14 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { // (#2448): its release clicks. app.viewport.tool_click = true; } + // While acquiring a midpoint dimension's second entity, geometry takes precedence + // over existing dimension lines ending on it. Dimension text can still supply a value. + let midpoint_geometry = crate::dim_view::live() == Some(false) + && app.tool.as_ref().is_some_and(|t| t.kind == crate::tools::Kind::Dimension && t.picks.len() == 1 && !t.point_picks.is_empty()) + && matches!(cand.as_ref().map(|c| &c.0), Some(Hit::SketchPoint { .. } | Hit::SketchCurve { .. })) + && crate::dim_view::hit_text(p).is_none(); let reference_pick = (resp.clicked() || resp.double_clicked()) + && !midpoint_geometry && !held && app.dialog.is_none() && crate::dim_view::pick_reference(ui.ctx(), p, resp.double_clicked()); @@ -3340,26 +3385,40 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { if crate::sketch_text_handles::dragging(app) && !crate::issue_report::cad_input_blocked() && !ui.input(|i| i.pointer.primary_down()) { crate::sketch_text_handles::drag_end(app); } - // Dragging a part that joints place moves it as far as they allow (outside sketches). + // Dragging a part moves its free chain (outside sketches), or reaches within its joints + // when the chain is anchored. Shift, Ctrl/Cmd or Alt held as the drag starts + // draws a selection box instead, as a drag from empty space does. if app.tool.is_none() && app.dialog.is_none() && primary_nav.is_none() && mode.is_none() && app.session.active_sketch.is_none() && !held { if resp.drag_started_by(egui::PointerButton::Primary) + // Physical Ctrl on macOS is not the primary click-selection modifier, but must + // still keep a modified drag from moving a part. + && !(mods.shift || mods.command || mods.ctrl || mods.alt) && let Some(a) = ui.input(|i| i.pointer.press_origin()) && rect.contains(a) && !crate::shell::covered(app, a) && !axes.contains(a) + && !crate::occurrence_drag::start(app, &proj, a) { crate::joint_drag::start(app, &proj, a); } - if crate::joint_drag::active(app) - && resp.dragged_by(egui::PointerButton::Primary) + if resp.dragged_by(egui::PointerButton::Primary) && let Some(p) = hover { - crate::joint_drag::to(app, &proj, p); + if crate::joint_drag::active(app) { + crate::joint_drag::to(app, &proj, p); + } else if crate::occurrence_drag::active(app) { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + crate::occurrence_drag::to(app, &proj, p); + } } } if crate::joint_drag::active(app) && (resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down())) { crate::joint_drag::end(app); } + // A drag whose release never came (focus lost, a tool or dialog took over) ends where it is. + if crate::occurrence_drag::active(app) && (resp.drag_stopped() || !ui.input(|i| i.pointer.primary_down())) { + crate::occurrence_drag::end(app); + } // Box selection: a primary drag on the model when no navigation mode is on. if app.tool.is_none() && !held @@ -3367,6 +3426,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { && mode.is_none() && app.viewport.point_drag.is_none() && !crate::joint_drag::active(app) + && !crate::occurrence_drag::active(app) && !crate::dim_view::dragging() && !crate::sketch3d::dragging() && !crate::sketch_text_handles::dragging(app) @@ -3430,7 +3490,27 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { axis_view(app, ui, rect); nav_bar(app, ui, rect); agent_chip(app, ui, overlay_rect); - transient_messages(app, ui, rect); + // The notices first, so the 3D mouse picture can keep clear of them. + let toasts = transient_messages(app, ui, rect); + if app.preferences.space_mouse_in_view + && app.space_mouse.calibrating.is_none() + && let Some(device) = &app.space_mouse.device + { + use crate::prefs::SpaceMouseGhost; + let calibration = app.preferences.space_mouse_calibrations.get(device); + let position = app.preferences.space_mouse_ghost_position; + // The agent chip (an agent command computing off the window thread) sits at the lower left + // too: the picture stays clear of it. + let chip = app.agent_computing().map(|_| Rect::from_min_size(agent_chip_anchor(app, overlay_rect), vec2(400.0, 34.0))); + let navigation = app.viewport.layout.as_ref().map(|layout| layout.navigation); + let obstacles = [navigation, chip].into_iter().flatten().chain(toasts).collect::>(); + // The Viewport positions sit in the content area (right of the browser), Left Window in the + // corner of the whole window; both stay above the floating History card, like the legend. + let area = if position == SpaceMouseGhost::LeftWindow { rect } else { crate::shell::content_rect(app, rect) }; + let area = crate::shell::bottom_overlay_rect(app, area); + let picture = crate::space_mouse_model::corner_rect(area, position, &obstacles); + crate::space_mouse_model::corner(ui, picture, calibration, app.space_mouse.raw.into()); + } } fn paint_box_selection(painter: &egui::Painter, bx: BoxSel, sketch: bool) { @@ -3453,6 +3533,13 @@ fn paint_box_selection(painter: &egui::Painter, bx: BoxSel, sketch: bool) { } } +/// Where the agent chip's top left is: above the History bar, and above the computing notice when +/// that is up too. +fn agent_chip_anchor(app: &SolveApp, rect: Rect) -> Pos2 { + let above = if app.computing_since().is_some() { 106.0 } else { 76.0 }; + pos2(rect.left() + 12.0, rect.bottom() - above) +} + /// An agent's command computing off the window thread (#1989): which one, in the agent cursor's /// colour, and a button that stops waiting for it. fn agent_chip(app: &mut SolveApp, ui: &egui::Ui, rect: Rect) { @@ -3461,13 +3548,12 @@ fn agent_chip(app: &mut SolveApp, ui: &egui::Ui, rect: Rect) { if let Some((since, label)) = app.agent_computing().map(|(since, label)| (since, label.to_string())) { let seconds = ((crate::now_ms() - since) / 1000.0).floor().max(0.0); let computing = crate::i18n::format("dialog.computing", &[("seconds", &format!("{seconds:.0}"))]); - let above = if app.computing_since().is_some() { 106.0 } else { 76.0 }; - let pos = pos2(rect.left() + 12.0, rect.bottom() - above); + let pos = agent_chip_anchor(app, rect); let galley = painter.layout_no_wrap(format!("{label} · {computing}"), FontId::proportional(t.font_label), t.text); let stop = painter.layout_no_wrap(crate::i18n::tr("ui.agent_chat_stop").to_string(), FontId::proportional(t.font_label), t.text); let r = Rect::from_min_size(pos, vec2(galley.size().x + stop.size().x + 54.0, galley.size().y.max(stop.size().y) + 8.0)); - painter.rect_filled(r, 4.0, t.overlay); - painter.rect_stroke(r, 4.0, Stroke::new(1.0, crate::icons::pointer::agent()), egui::StrokeKind::Inside); + painter.rect_filled(r, t.radius_sm, t.overlay); + painter.rect_stroke(r, t.radius_sm, Stroke::new(1.0, crate::icons::pointer::agent()), egui::StrokeKind::Inside); egui::Spinner::new() .color(crate::icons::pointer::agent()) .size(t.space_12) @@ -3475,7 +3561,7 @@ fn agent_chip(app: &mut SolveApp, ui: &egui::Ui, rect: Rect) { painter.galley(pos + vec2(26.0, 4.0), galley.clone(), t.text); let button = Rect::from_min_size(pos2(pos.x + galley.size().x + 34.0, r.top() + 2.0), vec2(stop.size().x + 12.0, r.height() - 4.0)); let clicked = ui.interact(button, egui::Id::new("sc_agent_stop"), Sense::click()); - painter.rect_filled(button, 3.0, if clicked.hovered() { t.hover } else { t.overlay }); + painter.rect_filled(button, t.radius_sm.saturating_sub(1), if clicked.hovered() { t.hover } else { t.overlay }); painter.galley(button.min + vec2(6.0, 2.0), stop, t.text); crate::scenario::publish_handle("agent:stop", button.center()); if clicked.clicked() { @@ -3488,23 +3574,43 @@ fn agent_chip(app: &mut SolveApp, ui: &egui::Ui, rect: Rect) { /// How long a status message stays in the view (milliseconds). const STATUS_TOAST_MS: f64 = 6000.0; -/// A single visual pattern for ongoing work and the existing six-second status. -fn transient_messages(app: &mut SolveApp, ui: &egui::Ui, viewport: Rect) { - if app.status.is_none() && app.computing_since().is_none() { - return; +/// A single visual pattern for ongoing work, the existing six-second status and the 3D mouse +/// calibration offer, stacked from the bottom of the free view. Returns the toasts' rects. +fn transient_messages(app: &mut SolveApp, ui: &egui::Ui, viewport: Rect) -> Vec { + // Nothing to show or time: an idle window skips the layout. The offer is checked too, as its clock + // runs from the frames `due` sees. + if app.status.is_none() && app.computing_since().is_none() && crate::space_mouse_offer::offerable(app).is_none() { + return Vec::new(); } let t = Tokens::get(); + let mut shown = Vec::new(); let mut obstacles = crate::shell::covered_rects(app); if let Some(layout) = &app.viewport.layout { obstacles.extend([layout.navigation, layout.axis_view]); } let mut free = crate::viewport_layout::free_rect(crate::shell::content_rect(app, viewport), &obstacles).shrink(t.space_8); + // The 3D mouse calibration offer: text and actions from its module, drawn by the shared toast. + if let Some(offer) = crate::space_mouse_offer::due(app, ui.ctx()) { + let labels: Vec = offer.actions.iter().map(|action| crate::i18n::tr(action.label()).to_string()).collect(); + let labels: Vec<(&str, ToastKind)> = labels.iter().map(String::as_str).zip(offer.actions.iter().map(|action| action.kind())).collect(); + if let Some((r, clicked)) = paint_toast_with(ui, free, crate::i18n::tr("ui.3d_mouse_offer").to_string(), ToastLead::Dot(false), &labels) { + crate::scenario::publish_rect("toast:space_mouse_offer", r); + free.max.y = r.top() - t.space_8; + shown.push(r); + crate::space_mouse_offer::keep(app, ui.ctx(), r); + if let Some(action) = clicked.and_then(|i| offer.actions.get(i)) { + crate::space_mouse_offer::act(app, ui.ctx(), &offer, *action); + } + ui.ctx().request_repaint_after(std::time::Duration::from_millis(500)); + } + } if let Some((msg, at, error)) = app.status.clone() { let shown_ms = crate::now_ms() - at; if shown_ms < STATUS_TOAST_MS { if let Some(r) = paint_toast(ui, free, msg, error, false) { crate::scenario::publish_rect("toast", r); free.max.y = r.top() - t.space_8; + shown.push(r); // Open the run's messages from the tokenised status toast. crate::scenario::publish_handle("status:chip", r.center()); let hint = if app.status_undo { crate::i18n::tr_label("Undo") } else { crate::i18n::tr("messages.chip_hint") }; @@ -3533,19 +3639,95 @@ fn transient_messages(app: &mut SolveApp, ui: &egui::Ui, viewport: Rect) { if app.status.is_none() { crate::scenario::publish_rect("toast", r); } + shown.push(r); } ui.ctx().request_repaint_after(std::time::Duration::from_millis(100)); } + shown +} + +/// What a toast's button is for, which sets its look and where it goes: the main action first +/// (the accent button), then an alternative, then the quiet dismissals. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ToastKind { + Primary, + Secondary, + Dismiss, +} + +/// Where each button of a toast goes, relative to the row's top left: `buttons` are (width, kind) in +/// the order given; the primary and the alternative form one group and the dismissals another. +/// Groups go side by side while they fit in `row_width` and wrap as a whole, so a button is not +/// left alone on a row while its group-mate fits beside it; a group wider than the row wraps +/// button by button. +fn toast_action_spots(buttons: &[(f32, ToastKind)], row_width: f32, gap: f32, height: f32) -> Vec { + let mut spots = vec![Rect::NOTHING; buttons.len()]; + let groups: [Vec; 2] = [ + (0..buttons.len()).filter(|&i| buttons[i].1 != ToastKind::Dismiss).collect(), + (0..buttons.len()).filter(|&i| buttons[i].1 == ToastKind::Dismiss).collect(), + ]; + let (mut x, mut y) = (0.0_f32, 0.0_f32); + for group in groups.iter().filter(|g| !g.is_empty()) { + let whole: f32 = group.iter().map(|&i| buttons[i].0).sum::() + gap * (group.len() - 1) as f32; + if x > 0.0 && x + whole > row_width { + (x, y) = (0.0, y + height + gap); + } + for &i in group { + let width = buttons[i].0; + // Only a group wider than a whole row wraps inside itself. + if x > 0.0 && x + width > row_width.max(width) && whole > row_width { + (x, y) = (0.0, y + height + gap); + } + spots[i] = Rect::from_min_size(pos2(x, y), vec2(width, height)); + x += width + gap; + } + x += gap; + } + spots +} + +/// What leads a toast's text: a dot (red for an error) or a spinner. +#[derive(Clone, Copy)] +enum ToastLead { + Dot(bool), + Spinner, } fn paint_toast(ui: &egui::Ui, free: Rect, text: String, error: bool, computing: bool) -> Option { + let lead = if computing { ToastLead::Spinner } else { ToastLead::Dot(error) }; + paint_toast_with(ui, free, text, lead, &[]).map(|(rect, _)| rect) +} + +/// The one toast painter: its text in a card at the bottom centre of `free`, led by a dot or a +/// spinner, and, below the text, a wrapping row of buttons (`actions`, kit buttons). Returns the +/// card's rect and which button was clicked, if any. With no actions the card is exactly the status +/// and computing toast it always was. The buttons are not live while an issue report holds CAD +/// input; none is laid outside the card, whose width is bounded by `free`. +fn paint_toast_with(ui: &egui::Ui, free: Rect, text: String, lead: ToastLead, actions: &[(&str, ToastKind)]) -> Option<(Rect, Option)> { if !free.is_finite() || !free.is_positive() || free.width() <= 40.0 { return None; } let t = Tokens::get(); let painter = ui.painter().with_clip_rect(free); let galley = painter.layout(text, crate::fonts::medium(t.font_label), t.text, (free.width() - 40.0).min(440.0)); - let size = galley.size() + vec2(40.0, 16.0); + // The buttons: each as wide as its text and a margin, in rows no wider than the text may be. + let gap = t.space_8; + let button_height = crate::ui_kit::row_height(); + let row_width = (free.width() - 40.0).clamp(0.0, 440.0); + let sizes: Vec<(f32, ToastKind)> = actions + .iter() + .map(|(label, kind)| { + let padding = if *kind == ToastKind::Primary { t.space_16 } else { t.space_12 }; + let width = painter.layout_no_wrap((*label).to_string(), crate::fonts::medium(t.font_label), t.text).size().x + 2.0 * padding; + (width.min(row_width), *kind) + }) + .collect(); + let spots = toast_action_spots(&sizes, row_width, gap, button_height); + let widest = spots.iter().map(|spot| spot.right()).fold(0.0, f32::max); + let y = spots.iter().map(|spot| spot.top()).fold(0.0, f32::max); + let actions_height = if spots.is_empty() { 0.0 } else { y + button_height + gap }; + let content = vec2(galley.size().x.max(widest), galley.size().y + actions_height); + let size = content + vec2(40.0, 16.0); if !size.is_finite() || size.x > free.width() || size.y > free.height() { return None; } @@ -3554,15 +3736,37 @@ fn paint_toast(ui: &egui::Ui, free: Rect, text: String, error: bool, computing: return None; } let frame = Container::new(ui.ctx(), Surface::Toast).frame(ui.ctx(), t.panel, crate::frame::Side::Left); - ui.painter().add(frame.paint(r.shrink(frame.stroke.width))); - let dot = pos2(r.left() + 14.0, r.center().y); - if computing { - egui::Spinner::new().size(12.0).color(t.text).paint_at(ui, Rect::from_center_size(dot, vec2(12.0, 12.0))); - } else { - painter.circle_filled(dot, 3.0, if error { t.error } else { t.text }); + Container::paint_frame(ui.painter(), frame, r.shrink(frame.stroke.width)); + let dot = pos2(r.left() + 14.0, r.center().y.min(r.top() + 8.0 + galley.size().y / 2.0)); + match lead { + ToastLead::Spinner => egui::Spinner::new().size(12.0).color(t.text).paint_at(ui, Rect::from_center_size(dot, vec2(12.0, 12.0))), + ToastLead::Dot(error) => drop(painter.circle_filled(dot, 3.0, if error { t.error } else { t.text })), } - painter.galley(r.min + vec2(28.0, 8.0), galley, t.text); - Some(r) + let text_top = r.min + vec2(28.0, 8.0); + painter.galley(text_top, galley.clone(), t.text); + let mut clicked = None; + let origin = text_top + vec2(0.0, galley.size().y + gap); + for (index, ((label, kind), spot)) in actions.iter().zip(&spots).enumerate() { + let at = spot.translate(origin.to_vec2()); + // A UI of its own on the viewport's layer, so the toast needs no `&mut Ui`. + let mut button_ui = egui::Ui::new( + ui.ctx().clone(), + egui::Id::new(("sc_toast_action", index)), + egui::UiBuilder::new().layer_id(ui.layer_id()).max_rect(at).layout(egui::Layout::left_to_right(egui::Align::Center)), + ); + button_ui.set_clip_rect(button_ui.clip_rect().intersect(r)); + let response = button_ui + .add_enabled_ui(!crate::issue_report::cad_input_blocked(), |ui| match kind { + ToastKind::Primary => crate::ui_kit::primary_button(ui, label), + ToastKind::Secondary => crate::ui_kit::button(ui, *label, crate::ui_kit::Button::Normal), + ToastKind::Dismiss => crate::ui_kit::button(ui, *label, crate::ui_kit::Button::Small), + }) + .inner; + if response.clicked() { + clicked = Some(index); + } + } + Some((r, clicked)) } /// A click without a command: select the candidate (replacing the selection, or adding / @@ -3586,6 +3790,7 @@ pub(crate) fn select(app: &mut SolveApp, sel: Option, add: bool, toggle: bo let _ = app.run("select.set", json!({ "items": rest })); } else { let _ = app.run("select.set", json!({"items": items, "add": add})); + crate::browser::reveal_pick(app); } } None if !add => { @@ -3779,8 +3984,13 @@ fn box_select(app: &mut SolveApp, proj: &Proj, bx: BoxSel, add: bool) { } } else { let accept = app.dialog.as_ref().and_then(|d| d.active_input()).map(|i| i.accept).unwrap_or(BODIES); + // Bulk body selection follows the active component, just like its unfaded bodies. + // Keep other dialogs and Move's reference inputs free to pick across components. + let scoped_bodies = accept == BODIES && app.dialog.as_ref().is_none_or(|d| matches!(d.kind, crate::dialogs::Kind::Move { .. })); + let active_bodies = + app.session.active_occurrence_path.as_ref().filter(|_| scoped_bodies).and_then(|p| app.session.occurrence_bodies(p, true).ok()); for b in &st.bodies { - if !app.session.body_visible(&b.name) { + if !app.session.body_visible(&b.name) || (scoped_bodies && body_faded(app, b, &active_bodies)) { continue; } let m = b.mesh(); @@ -4324,7 +4534,7 @@ fn nav_bar(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) { let mut painter = ui.painter_at(rect); painter.set_opacity(if active || app.preferences.navigator_style == crate::prefs::NavigatorStyle::Axes { 1.0 } else { 0.45 }); let surface = Container::new(ui.ctx(), Surface::Navigation).frame(ui.ctx(), t.overlay, crate::frame::Side::Left); - painter.add(surface.paint(bar.shrink(surface.stroke.width))); + Container::paint_frame(&painter, surface, bar.shrink(surface.stroke.width)); let columns = ((bar.width() - 10.0) / 30.0).round().clamp(1.0, 10.0) as usize; for (i, (icon, tip)) in items.iter().enumerate() { let br = Rect::from_min_size( @@ -5075,7 +5285,7 @@ mod tests { #[test] fn timeline_modifier_highlight_follows_component_instances() { let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); - app.run("component.create", json!({"name":"Part"})).unwrap(); + app.run("component.create", json!({"name":"Part","grounded":false})).unwrap(); app.run("solid.box", json!({"length":20,"width":20,"height":20})).unwrap(); let id = app.run("solid.fillet", json!({"edges":[[0,0,10]],"radius":2})).unwrap()["feature"].as_u64().unwrap(); app.run("occurrence.move", json!({"occurrence":"Part:1","translate":[30,0,0]})).unwrap(); @@ -5898,10 +6108,12 @@ mod tests { let modifiers = egui::Modifiers { shift, ..Default::default() }; let frame = |app: &mut SolveApp, mut events: Vec| { events.insert(0, egui::Event::ModifiersChanged(modifiers)); - let _ = ctx.run_ui( + ctx.run_ui( egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), events, ..Default::default() }, |ui| show(app, ui), - ); + ) + .textures_delta + .clear(); }; frame(&mut app, vec![egui::Event::PointerMoved(p), egui::Event::PointerButton { pos: p, button, pressed: true, modifiers }]); let end = p + vec2(30.0, 20.0); @@ -6736,6 +6948,13 @@ mod tests { assert!(sc.trans.is_empty(), "no origin plane is drawn where the section clips"); // Each origin axis: a solid part and dashes beyond, plus each plane's four edges. assert!(sc.guide_lines.len() >= (planes * 4 + 3) * crate::gpu::LINE_SIZE); + assert!(sc.xray.is_empty(), "depth-tested unless the preference is on"); + // Shown through bodies (Preferences), they are drawn over everything, also unclipped. + h.app.preferences.reference_geometry_through_bodies = true; + let sc = build_highlight(&h.app).merged(); + assert_eq!(sc.xray.len(), planes * 6 * crate::gpu::TRI_SIZE); + assert!(sc.trans.is_empty() && sc.guide_trans.is_empty() && sc.guide_lines.is_empty()); + assert!(sc.overlay.len() >= (planes * 4 + 3) * crate::gpu::LINE_SIZE); } #[test] @@ -6978,6 +7197,152 @@ mod tests { assert_ne!(h.app.cam.target, legacy_target); } + #[test] + fn fusion_shift_two_finger_scroll_orbits_without_pan_zoom_or_drift() { + let mut h = crate::scenario::Harness::new(); + h.app.preferences.nav = "fusion".into(); + h.app.preferences.orbit_around = crate::prefs::OrbitAround::ViewCentre; + h.call("engine.execute", json!({"command":"solid.box","params":{"length":40,"width":30,"height":20}})); + h.call("ui.camera", json!({"fit":true})); + h.call("ui.move", json!({"x":800,"y":500})); + h.step(&json!({"shot":"fusion-trackpad-before"})).unwrap(); + let before = h.app.cam; + for phase in [egui::TouchPhase::Start, egui::TouchPhase::Move, egui::TouchPhase::End] { + h.app.synthetic.push(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(60.0, -40.0), + modifiers: egui::Modifiers::SHIFT, + phase, + }); + h.frame(); + } + assert_ne!(h.app.cam.back(), before.back(), "Shift + two fingers must orbit"); + assert_eq!(h.app.cam.target, before.target, "orbit must not pan"); + assert_eq!(h.app.cam.half_height(), before.half_height(), "orbit must not zoom"); + let after = h.app.cam; + h.frames(90); + assert_eq!(h.app.cam, after, "orbit must not drift once input stops"); + h.step(&json!({"shot":"fusion-trackpad-orbit"})).unwrap(); + for phase in [egui::TouchPhase::Start, egui::TouchPhase::Move, egui::TouchPhase::End] { + h.app.synthetic.push(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(60.0, -40.0), + modifiers: egui::Modifiers::NONE, + phase, + }); + h.frame(); + } + assert_eq!(h.app.cam.back(), after.back(), "unmodified two fingers must not orbit"); + assert_ne!(h.app.cam.target, after.target, "unmodified two fingers must pan"); + h.step(&json!({"shot":"fusion-trackpad-pan"})).unwrap(); + } + + #[test] + fn fusion_trackpad_uses_each_events_modifiers_and_can_switch_mid_gesture() { + let mut h = crate::scenario::Harness::new(); + h.app.preferences.nav = "fusion".into(); + h.app.preferences.orbit_around = crate::prefs::OrbitAround::ViewCentre; + h.call("ui.move", json!({"x":800,"y":500})); + let gesture = |phase, modifiers| egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(20.0, -10.0), modifiers, phase }; + h.app.synthetic.push(gesture(egui::TouchPhase::Start, egui::Modifiers::NONE)); + h.frame(); + for modifiers in [egui::Modifiers::NONE, egui::Modifiers::SHIFT, egui::Modifiers::NONE, egui::Modifiers::SHIFT] { + let before = h.app.cam; + h.app.synthetic.push(gesture(egui::TouchPhase::Move, modifiers)); + h.frame(); + assert_eq!(h.app.cam.half_height(), before.half_height()); + if modifiers.shift { + let mut expected = before; + expected.orbit(20.0, -10.0); + assert_eq!(h.app.cam, expected, "Shift must orbit continuously, without snapping"); + } else { + assert_eq!(h.app.cam.back(), before.back()); + assert_ne!(h.app.cam.target, before.target); + } + } + let before = h.app.cam; + let raw = egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0))), + time: Some(h.ctx.input(|input| input.time) + 1.0 / 60.0), + focused: true, + events: vec![gesture(egui::TouchPhase::Move, egui::Modifiers::NONE), gesture(egui::TouchPhase::Move, egui::Modifiers::SHIFT)], + ..Default::default() + }; + let mut output = h.ctx.run_ui(raw, |ui| { + h.app.logic(ui.ctx()); + h.app.ui(ui); + }); + output.textures_delta.clear(); + assert_ne!(h.app.cam.target, before.target, "unmodified events in the same frame still pan"); + assert_ne!(h.app.cam.back(), before.back(), "Shift events in the same frame still orbit"); + h.app.synthetic.push(gesture(egui::TouchPhase::Cancel, egui::Modifiers::SHIFT)); + h.frame(); + assert!(!h.app.viewport.trackpad_gesture); + assert!(h.app.viewport.orbit_pivot.is_none()); + assert!(h.app.viewport.orbit_from.is_none()); + let after = h.app.cam; + h.frames(90); + assert_eq!(h.app.cam, after); + } + + #[test] + fn shift_trackpad_orbit_preserves_other_presets_and_legacy_scroll() { + for preset in crate::prefs::NAV_PRESETS.iter().map(|preset| preset.id) { + for trackpad_pan in [true, false] { + let mut h = crate::scenario::Harness::new(); + h.app.preferences.nav = preset.into(); + h.app.preferences.trackpad_pan = trackpad_pan; + h.app.preferences.zoom_to_cursor = false; + h.call("ui.move", json!({"x":800,"y":500})); + let before = h.app.cam; + for phase in [egui::TouchPhase::Start, egui::TouchPhase::Move, egui::TouchPhase::End] { + h.app.synthetic.push(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(20.0, -10.0), + modifiers: egui::Modifiers::SHIFT, + phase, + }); + h.frame(); + } + if preset == "fusion" && trackpad_pan { + assert_ne!(h.app.cam.back(), before.back()); + } else { + assert_eq!(h.app.cam.back(), before.back(), "{preset}, pan={trackpad_pan}: must not orbit"); + if h.app.preferences.trackpad_pans() { + assert_ne!(h.app.cam.target, before.target, "{preset}: Shift still pans"); + assert_eq!(h.app.cam.half_height(), before.half_height()); + } + } + } + } + } + + #[test] + fn fusion_shift_trackpad_respects_orbit_centre_and_rejects_invalid_motion() { + let mut h = crate::scenario::Harness::new(); + h.app.preferences.nav = "fusion".into(); + h.app.preferences.orbit_around = crate::prefs::OrbitAround::Origin; + h.app.cam.target = Vec3::new(30.0, 20.0, 10.0); + h.call("ui.move", json!({"x":800,"y":500})); + let before = h.app.cam; + let gesture = |phase, delta| egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta, modifiers: egui::Modifiers::SHIFT, phase }; + h.app.synthetic.push(gesture(egui::TouchPhase::Start, egui::Vec2::ZERO)); + h.frame(); + h.app.synthetic.push(gesture(egui::TouchPhase::Move, vec2(20.0, -10.0))); + h.frame(); + let mut expected = before; + expected.orbit_about(20.0, -10.0, Vec3::ZERO); + assert_eq!(h.app.cam, expected); + for delta in [vec2(f32::NAN, 1.0), vec2(1.0, f32::INFINITY)] { + h.app.synthetic.push(gesture(egui::TouchPhase::Move, delta)); + h.frame(); + assert_eq!(h.app.cam, expected); + } + h.app.synthetic.push(gesture(egui::TouchPhase::End, egui::Vec2::ZERO)); + h.frame(); + assert!(h.app.viewport.orbit_from.is_none()); + } + #[test] fn pinch_gesture_zooms_and_ignores_noise() { let mut h = crate::scenario::Harness::new(); @@ -7012,8 +7377,7 @@ mod tests { assert!(app.preferences.trackpad_pan); crate::help::run(&mut app, &ctx, "shortcuts"); let mut time = 0.0; - let pan = - |phase| egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(40.0, -20.0), modifiers: egui::Modifiers::NONE, phase }; + let pan = |phase, modifiers| egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(40.0, -20.0), modifiers, phase }; let mut frame = |app: &mut SolveApp, pointer: Option, events: Vec| { time += 1.0 / 60.0; let mut input = events; @@ -7041,24 +7405,30 @@ mod tests { let over = window.center(); let height = app.cam.half_height(); let target = app.cam.target; + let direction = app.cam.back(); // A full gesture plus pinch over the dialog stays in the popup. - for events in - [vec![pan(egui::TouchPhase::Start)], vec![pan(egui::TouchPhase::Move), egui::Event::Zoom(1.2)], vec![pan(egui::TouchPhase::End)]] - { - frame(&mut app, Some(over), events); - assert_eq!(app.cam.half_height(), height, "trackpad input over a dialog must stay in the popup"); - assert_eq!(app.cam.target, target); + for modifiers in [egui::Modifiers::NONE, egui::Modifiers::SHIFT] { + for events in [ + vec![pan(egui::TouchPhase::Start, modifiers)], + vec![pan(egui::TouchPhase::Move, modifiers), egui::Event::Zoom(1.2)], + vec![pan(egui::TouchPhase::End, modifiers)], + ] { + frame(&mut app, Some(over), events); + assert_eq!(app.cam.half_height(), height, "trackpad input over a dialog must stay in the popup"); + assert_eq!(app.cam.target, target); + assert_eq!(app.cam.back(), direction, "Shift + trackpad over a dialog must not orbit"); + } } let exposed = pos2(40.0, 700.0); assert!(!window.contains(exposed)); frame(&mut app, Some(exposed), Vec::new()); - frame(&mut app, Some(exposed), vec![pan(egui::TouchPhase::Start)]); - frame(&mut app, Some(exposed), vec![pan(egui::TouchPhase::Move)]); + frame(&mut app, Some(exposed), vec![pan(egui::TouchPhase::Start, egui::Modifiers::NONE)]); + frame(&mut app, Some(exposed), vec![pan(egui::TouchPhase::Move, egui::Modifiers::NONE)]); assert_eq!(app.cam.half_height(), height); assert_ne!(app.cam.target, target, "the exposed viewport still pans"); frame(&mut app, Some(exposed), vec![egui::Event::Zoom(1.2)]); assert_ne!(app.cam.half_height(), height, "the exposed viewport still pinch-zooms"); - frame(&mut app, Some(exposed), vec![pan(egui::TouchPhase::End)]); + frame(&mut app, Some(exposed), vec![pan(egui::TouchPhase::End, egui::Modifiers::NONE)]); } #[test] @@ -7984,9 +8354,8 @@ mod grid_plane_tests { assert!(projection(&app, rect).cam.fov > 0.0); } - /// Create Sketch shows the Origin even when the browser hides it, and draws its planes - /// through the model so a plane inside a body can be seen and picked; closing it restores - /// the hidden, depth-tested Origin. + /// Create Sketch shows the Origin through bodies; the preference opts into that outside the + /// tool while remaining off by default. #[test] fn create_sketch_shows_the_origin_planes_through_the_model() { let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); @@ -8004,7 +8373,31 @@ mod grid_plane_tests { assert!(origin_planes(&app).is_empty()); app.ui.show_origin = true; let sc = build_highlight(&app); - assert!(sc.xray.is_empty() && !sc.guide_trans.is_empty(), "outside Create Sketch the planes are depth-tested"); + assert!(sc.xray.is_empty(), "through-body reference geometry is off by default"); + assert_eq!(sc.guide_trans.len(), 3 * 6 * crate::gpu::TRI_SIZE, "planes use the ordinary depth-tested path by default"); + app.preferences.reference_geometry_through_bodies = true; + let sc = build_highlight(&app); + assert_eq!(sc.xray.len(), 3 * 6 * crate::gpu::TRI_SIZE, "the preference draws reference planes through bodies"); + assert!(sc.guide_trans.is_empty()); + } + + /// A finished sketch is depth-tested unless the preference draws reference geometry through + /// bodies; switching it redraws the sketch scene while nothing else changes. + #[test] + fn the_preference_draws_finished_sketches_through_bodies() { + let rect = Rect::from_min_size(Pos2::ZERO, egui::vec2(800.0, 600.0)); + let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); + app.run("sketch.create", json!({"plane": "XY"})).unwrap(); + app.run("sketch.rectangle.two_point", json!({"p0": [-10, -10], "p1": [10, 10]})).unwrap(); + app.run("sketch.finish", json!({})).unwrap(); + let proj = projection(&app, rect); + let off = build_sketch_scene(&app, &proj); + assert!(!off.lines.is_empty() && off.overlay.is_empty(), "off by default: the sketch is depth-tested"); + let key = sketch_scene_key(&app, &proj); + app.preferences.reference_geometry_through_bodies = true; + assert_ne!(sketch_scene_key(&app, &proj), key, "the cached sketch scene is rebuilt"); + let on = build_sketch_scene(&app, &proj); + assert!(on.lines.is_empty() && on.overlay.len() == off.lines.len(), "the sketch is drawn over the bodies"); } /// #2581: a dialog waiting for a plane (here Mirror's plane) shows the origin planes in the diff --git a/crates/ui-egui/src/viewport_appearance_tests.rs b/crates/ui-egui/src/viewport_appearance_tests.rs index 05d88d33c4..a898f82156 100644 --- a/crates/ui-egui/src/viewport_appearance_tests.rs +++ b/crates/ui-egui/src/viewport_appearance_tests.rs @@ -54,3 +54,78 @@ fn placed_step_body_uses_imported_opacity_until_a_design_look_overrides_it() { assert!(remainder.is_empty()); assert!(vertices.iter().all(|v| v[27] == 102)); } + +/// Exercise the live GPU scene slots: a fresh headless render always rebuilds and would hide +/// stale cached geometry. Theme changes must update bytes while retaining the model's mesh. +#[test] +fn theme_switches_refresh_cached_viewport_colours_without_remeshing() { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.app.ui.ground_shadow = false; + h.app.ui.show_origin = false; + h.app.run("solid.box", json!({"length": 20, "width": 15, "height": 10})).unwrap(); + h.app.viewport.gpu = Some(super::GpuTarget { format: egui_wgpu::wgpu::TextureFormat::Rgba8Unorm, depth: None, samples: 1, tess: false }); + let body = h.app.session.world_state().bodies[0].name.clone(); + h.app.session.selection = vec![solidcraft_engine::Sel::Body { name: body.clone() }]; + h.frame(); + let revision = h.app.session.revision; + let mesh = h.app.session.world_state().bodies[0].cached_mesh().unwrap(); + let mut previous: Option<(u64, u64, u64)> = None; + for theme in ["dark", "gruvbox-dark", "blueprint", "dark", "light", "solidcraft-95", "cupertino", "light"] { + let result = h.call("ui.prefs", json!({"theme": theme})); + assert!(result.get("error").is_none(), "{result}"); + h.frame(); + let keys = (h.app.viewport.key, h.app.viewport.sketch_key, h.app.viewport.hl_key); + if let Some(old) = previous { + assert_ne!(keys.0, old.0, "{theme}: model cache"); + assert_ne!(keys.1, old.1, "{theme}: sketch cache"); + assert_ne!(keys.2, old.2, "{theme}: highlight cache"); + } + previous = Some(keys); + let scene = h.app.viewport.slot.lock().unwrap().take().expect("new model colours uploaded").merged(); + let (vertices, remainder) = scene.tris.as_chunks::<{ crate::gpu::TRI_SIZE }>(); + assert!(remainder.is_empty() && !vertices.is_empty()); + let colour = super::c4(crate::theme::Tokens::get().body); + assert!(vertices.iter().all(|v| v[24..28] == colour), "{theme}: stale body colour"); + assert_eq!(h.app.session.revision, revision, "theme changes must not edit the design"); + assert!(std::sync::Arc::ptr_eq(&mesh, &h.app.session.world_state().bodies[0].cached_mesh().unwrap())); + h.frame(); + assert_eq!((h.app.viewport.key, h.app.viewport.sketch_key, h.app.viewport.hl_key), keys); + assert!(h.app.viewport.slot.lock().unwrap().is_none(), "an unchanged theme reuses its scene"); + } + h.app.run("appearance.assign", json!({"bodies": [body], "color": "#336699"})).unwrap(); + for theme in ["gruvbox-dark", "blueprint", "solidcraft-95"] { + h.call("ui.prefs", json!({"theme": theme})); + h.frame(); + let scene = h.app.viewport.slot.lock().unwrap().take().unwrap().merged(); + let (vertices, _) = scene.tris.as_chunks::<{ crate::gpu::TRI_SIZE }>(); + assert!(!vertices.is_empty()); + assert!(vertices.iter().all(|v| v[24..28] == [51, 102, 153, 255]), "{theme}: explicit appearance preserved"); + } +} + +#[test] +fn cancelling_theme_preview_restores_viewport_palette_keys() { + let mut h = crate::scenario::Harness::new(); + h.app.home.open = false; + h.call("ui.prefs", json!({"theme": "gruvbox-dark"})); + h.frame(); + let keys = |app: &SolveApp| (super::scene_key(app), super::highlight_geometry_key(app)); + let before = keys(&h.app); + h.call("ui.prefs", json!({"open": "Interface"})); + h.call("ui.prefs", json!({"theme": "blueprint"})); + h.frame(); + assert_ne!(keys(&h.app), before); + h.call("ui.prefs", json!({"button": "cancel"})); + h.frame(); + assert_eq!(keys(&h.app), before); + // Replacing an imported palette keeps its id and base but still changes geometry colours. + let custom = |color| json!({"format":"solidcraft-theme/1","name":"Viewport test","base":"dark","colors":{"body":color}}); + h.call("ui.prefs", json!({"theme_import": custom("#abcdef")})); + h.call("ui.prefs", json!({"theme": "viewport-test"})); + h.frame(); + let first = keys(&h.app); + h.call("ui.prefs", json!({"theme_import": custom("#fedcba")})); + h.frame(); + assert_ne!(keys(&h.app), first); +} diff --git a/crates/ui-egui/src/viewport_layout_tests.rs b/crates/ui-egui/src/viewport_layout_tests.rs index 7372feffa5..2ae2151d97 100644 --- a/crates/ui-egui/src/viewport_layout_tests.rs +++ b/crates/ui-egui/src/viewport_layout_tests.rs @@ -1,5 +1,6 @@ use super::*; use crate::scenario::Harness; +use crate::viewport::NavMode; use serde_json::json; use solidcraft_engine::render::StandardView; @@ -352,3 +353,78 @@ fn floating_fit_uses_the_measured_browser_and_history_cards_at_each_size() { } } } + +fn click_nav_mode(h: &mut Harness, mode: NavMode) { + let nav = crate::scenario::rect_of("nav:outer").unwrap(); + let column = match mode { + NavMode::Orbit => 0, + NavMode::Pan => 2, + NavMode::Zoom => 3, + }; + let p = pos2(nav.left() + 5.0 + column as f32 * 30.0 + 13.0, nav.top() + 15.0); + h.call("ui.click", json!({"x":p.x,"y":p.y})); +} + +#[test] +fn escape_ends_nav_bar_modes_and_left_drag_stops_moving_the_camera() { + for mode in [NavMode::Pan, NavMode::Orbit, NavMode::Zoom] { + let mut h = Harness::new(); + h.settle(); + click_nav_mode(&mut h, mode); + assert_eq!(h.app.viewport.nav, Some(mode)); + click_nav_mode(&mut h, mode); + assert_eq!(h.app.viewport.nav, None, "clicking the active button still toggles it off"); + click_nav_mode(&mut h, mode); + let p = h.app.viewport.layout.as_ref().unwrap().drawable.center(); + let drag = json!({"x0":p.x,"y0":p.y,"x1":p.x+40.0,"y1":p.y+25.0,"steps":8}); + let before = h.app.cam; + h.call("ui.drag", drag.clone()); + assert_ne!(h.app.cam, before, "{mode:?}: the active mode moves the camera"); + h.step(&json!({"key":"Escape"})).unwrap(); + assert_eq!(h.app.viewport.nav, None, "{mode:?}"); + let stopped = h.app.cam; + h.call("ui.drag", drag); + assert_eq!(h.app.cam, stopped, "{mode:?}: Escape restores normal left-drag behavior"); + } +} + +#[test] +fn escape_ends_navigation_before_cancelling_a_dialog() { + let mut h = Harness::new(); + h.app.start("inspect.measure"); + h.settle(); + assert!(h.app.dialog.is_some()); + click_nav_mode(&mut h, NavMode::Pan); + assert_eq!(h.app.viewport.nav, Some(NavMode::Pan)); + h.step(&json!({"key":"Escape"})).unwrap(); + assert_eq!(h.app.viewport.nav, None); + assert!(h.app.dialog.is_some(), "ending navigation keeps the dialog open"); + h.step(&json!({"key":"Escape"})).unwrap(); + assert!(h.app.dialog.is_none(), "Escape without navigation still cancels the dialog"); +} + +#[test] +fn escape_ends_navigation_before_sketch_tools_and_does_not_count_as_a_finish_press() { + let mut h = Harness::new(); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.app.run("sketch.line", json!({"points":[[0,0],[10,0]]})).unwrap(); + h.app.start("sketch.line"); + h.settle(); + let sketch = h.app.session.active_sketch; + click_nav_mode(&mut h, NavMode::Pan); + h.step(&json!({"key":"Escape"})).unwrap(); + assert_eq!(h.app.viewport.nav, None); + assert!(h.app.tool.is_some(), "ending navigation leaves the sketch tool active"); + assert!(h.app.last_escape.is_none()); + h.step(&json!({"key":"Escape"})).unwrap(); + assert!(h.app.tool.is_none(), "Escape without navigation still cancels the tool"); + assert_eq!(h.app.session.active_sketch, sketch); + // Cancel navigation with no tool: the next Escape must not finish the sketch. + click_nav_mode(&mut h, NavMode::Pan); + h.step(&json!({"key":"Escape"})).unwrap(); + assert_eq!(h.app.session.active_sketch, sketch); + h.step(&json!({"key":"Escape"})).unwrap(); + assert_eq!(h.app.session.active_sketch, sketch); + h.step(&json!({"key":"Escape"})).unwrap(); + assert!(h.app.session.active_sketch.is_none(), "two ordinary quick Escapes still finish the sketch"); +} diff --git a/crates/ui-egui/src/viewport_overlays_tests.rs b/crates/ui-egui/src/viewport_overlays_tests.rs index 298326b77a..3134920763 100644 --- a/crates/ui-egui/src/viewport_overlays_tests.rs +++ b/crates/ui-egui/src/viewport_overlays_tests.rs @@ -357,6 +357,130 @@ fn compass_circle_grabs_on_hover_and_the_home_chip_points() { assert_ne!(h.output.cursor_icon, egui::CursorIcon::Grab, "no hand away from the compass"); } +/// The status and computing toasts keep the size and place they always had: the text's size plus +/// 40 by 16, centred at the bottom of the free rect. +#[test] +fn the_shared_toast_draws_old_callers_exactly_as_before() { + let ctx = crate::fonts::test_context(); + for width in [200.0_f32, 300.0, 500.0, 900.0] { + let free = Rect::from_min_size(pos2(10.0, 20.0), vec2(width, 200.0)); + for (error, computing) in [(false, false), (true, false), (false, true)] { + draw(&ctx, |ui| { + let t = Tokens::get(); + let text = "A message that is long enough to wrap in the narrow cases, and not in the wide ones".to_string(); + let galley = ui.painter().layout(text.clone(), crate::fonts::medium(t.font_label), t.text, (free.width() - 40.0).min(440.0)); + let size = galley.size() + vec2(40.0, 16.0); + let expected = Rect::from_center_size(pos2(free.center().x, free.bottom() - size.y * 0.5), size); + let got = paint_toast(ui, free, text, error, computing); + if size.x > free.width() || size.y > free.height() { + assert!(got.is_none()); + } else { + assert_eq!(got, Some(expected), "width {width} error {error} computing {computing}"); + } + }); + } + } +} + +/// The toast's buttons stay inside its card at narrow widths, and do nothing while an issue report +/// holds CAD input. +#[test] +fn toast_buttons_wrap_inside_the_card_and_are_dead_behind_an_issue_report() { + let labels = [ + ("Calibrate…", ToastKind::Primary), + ("Swap to Hidraw (Direct Driver)", ToastKind::Secondary), + ("Close", ToastKind::Dismiss), + ("Never show again", ToastKind::Dismiss), + ]; + for width in [260.0_f32, 360.0, 600.0, 1000.0] { + for blocked in [false, true] { + crate::issue_report::set_cad_input_blocked_for_test(blocked); + let ctx = crate::fonts::test_context(); + let free = Rect::from_min_size(pos2(0.0, 0.0), vec2(width, 300.0)); + let run = |events: Vec| { + let mut seen = None; + let mut output = ctx.run_ui(egui::RawInput { events, screen_rect: Some(free), focused: true, ..Default::default() }, |ui| { + seen = paint_toast_with(ui, free, "Your 3D mouse isn't calibrated.".into(), ToastLead::Dot(false), &labels); + }); + output.textures_delta.clear(); + seen + }; + run(vec![]); + let (card, _) = run(vec![]).unwrap_or_else(|| panic!("width {width}: no card")); + assert!(free.contains_rect(card), "width {width}: {card:?}"); + let mut hits = Vec::new(); + for step_x in 1..12 { + for step_y in 1..16 { + let at = pos2(card.left() + card.width() * step_x as f32 / 12.0, card.top() + card.height() * step_y as f32 / 16.0); + run(vec![egui::Event::PointerMoved(at)]); + run(vec![egui::Event::PointerButton { + pos: at, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: Default::default(), + }]); + let clicked = run(vec![egui::Event::PointerButton { + pos: at, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: Default::default(), + }]); + if let Some((_, Some(index))) = clicked { + hits.push(index); + } + } + } + crate::issue_report::set_cad_input_blocked_for_test(false); + if blocked { + assert!(hits.is_empty(), "width {width}: a button worked behind a report: {hits:?}"); + } else { + hits.sort_unstable(); + hits.dedup(); + assert_eq!(hits, [0, 1, 2, 3], "width {width}: every button can be clicked inside the card"); + } + } + } +} + +/// The toast's buttons group by purpose and wrap as whole groups: the main action first, then the +/// alternative, then the dismissals; no button stands alone on a row while its group-mate fits. +#[test] +fn toast_buttons_group_by_purpose_and_wrap_as_groups() { + use ToastKind::{Dismiss, Primary, Secondary}; + let four = [(110.0, Primary), (220.0, Secondary), (60.0, Dismiss), (140.0, Dismiss)]; + let three = [(110.0, Primary), (60.0, Dismiss), (140.0, Dismiss)]; + for card in [260.0_f32, 360.0, 460.0, 560.0, 700.0, 1000.0] { + let row_width = (card - 40.0).min(440.0); + for buttons in [&four[..], &three[..]] { + let spots = toast_action_spots(buttons, row_width, 8.0, 28.0); + let widest = buttons.iter().map(|b| b.0).fold(0.0, f32::max); + // Nothing leaves the row (a button wider than the row is cut to it by the painter), and none overlaps. + for (i, spot) in spots.iter().enumerate() { + assert!(spot.left() >= 0.0 && spot.right() <= row_width.max(widest) + 0.01, "card {card}: {spot:?}"); + for other in &spots[i + 1..] { + assert!(!spot.intersects(*other), "card {card}: {spot:?} overlaps {other:?}"); + } + } + // The primary is first: top left, and no button is above it or left of it on its row. + assert!(spots.iter().all(|s| s.top() > spots[0].top() || s.left() >= spots[0].left()), "card {card}: primary first"); + let mut rows: std::collections::BTreeMap = Default::default(); + for spot in &spots { + *rows.entry(spot.top() as i32).or_default() += 1; + } + // Whole groups fit the row: then no row has a button on its own, except the primary of the + // three-button set (its group is only itself). + let group_fits = if buttons.len() == 4 { 110.0 + 8.0 + 220.0 <= row_width } else { 140.0 + 8.0 + 60.0 <= row_width }; + if group_fits { + let singles = rows.values().filter(|&&n| n == 1).count(); + assert!(singles <= usize::from(buttons.len() == 3), "card {card}, {} buttons: a button is left alone: {rows:?}", buttons.len()); + } + if row_width >= 110.0 + 8.0 + 220.0 + 8.0 + 60.0 + 8.0 + 140.0 { + assert_eq!(rows.len(), 1, "card {card}: one row when everything fits"); + } + } + } +} + #[test] fn bottom_readouts_clear_floating_history_on_the_first_frame() { let mut h = Harness::new(); diff --git a/crates/ui-egui/src/workspace.rs b/crates/ui-egui/src/workspace.rs index a2a4ae0465..31f0f8bb30 100644 --- a/crates/ui-egui/src/workspace.rs +++ b/crates/ui-egui/src/workspace.rs @@ -246,7 +246,7 @@ pub const SECTIONS: &[Section] = &[ }, // Sketch dimensions are driven by parameters, so they are one click away while sketching. Section { tab: "SKETCH", panel: "CONFIGURE", promoted: 2, ids: CONFIGURE }, - Section { tab: "SKETCH", panel: "FINISH SKETCH", promoted: 1, ids: &["sketch.finish"] }, + Section { tab: "SKETCH", panel: "FINISH SKETCH", promoted: 2, ids: &["sketch.finish", "sketch.cancel"] }, // New Sketch leads the tab, as on MODEL: a sheet part starts from a sketch (#3250). Section { tab: "SHEET METAL", panel: "SKETCH", promoted: 1, ids: &["sketch.create"] }, Section { @@ -301,6 +301,7 @@ pub const RIBBON_LABELS: &[(&str, &str)] = &[ ("sheet.export_dxf", "Export DXF"), ("sheet.flat_pattern", "Flat Pattern"), ("sheet.manage_rules", "Sheet Rules"), + ("sketch.cancel", "Cancel"), ("sketch.circle.center", "Circle"), ("sketch.constraint.coincident", "Coincident"), ("sketch.constraint.horizontal_vertical", "Horiz./Vert."), @@ -465,8 +466,23 @@ pub fn sync_context(app: &mut crate::SolveApp) { } let context = app.session.active_sketch.map(|id| format!("sketch:{id}")).or_else(|| sheet_body(app).map(|body| format!("sheet:{body}"))); if app.ui.ribbon_context != context { + let was_sketch = app.ui.ribbon_context.as_deref().is_some_and(|c| c.starts_with("sketch:")); + let back = if app.session.active_sketch.is_some() { + // Remember the tab the sketch was started from (#3250). + if !was_sketch && app.ui.tab != "SKETCH" { + app.ui.sketch_return_tab = Some(app.ui.tab.clone()); + } + None + } else if was_sketch { + app.ui.sketch_return_tab.take() + } else { + None + }; app.ui.tab = if app.session.active_sketch.is_some() { "SKETCH" + } else if let Some(back) = back.as_deref().filter(|t| *t != "SKETCH" && visible_tabs(app).contains(t)) { + // Finishing a sketch goes back to the tab it came from (#3250). + visible_tabs(app).into_iter().find(|t| t == &back).unwrap_or("MODEL") } else if context.is_some() { "SHEET METAL" } else { diff --git a/crates/ui-egui/tests/icon_audit.rs b/crates/ui-egui/tests/icon_audit.rs index 30b4d669e6..b47b29406f 100644 --- a/crates/ui-egui/tests/icon_audit.rs +++ b/crates/ui-egui/tests/icon_audit.rs @@ -483,7 +483,7 @@ fn offset_mode_artwork_review() { ); let mut x = column as i32 * 200 + 8; for px in [64, 24, 16] { - let look = icon_svg::Look { dark, accent: resolved.accent, vision: ColorVision::Off }; + let look = icon_svg::Look::themed(dark, resolved.accent, ColorVision::Off); let pm = icon_svg::render(icon_svg::doc_for(resolved.master, px).unwrap(), look, px).unwrap(); sheet.draw_pixmap(x, row as i32 * 88 + 8, pm.as_ref(), &tiny_skia::PixmapPaint::default(), tiny_skia::Transform::identity(), None); x += px as i32 + 16; diff --git a/crates/ui-egui/tests/scenarios.rs b/crates/ui-egui/tests/scenarios.rs index bd57530904..c1637528b2 100644 --- a/crates/ui-egui/tests/scenarios.rs +++ b/crates/ui-egui/tests/scenarios.rs @@ -44,9 +44,10 @@ fn angle_dimension_both_interactive_workflows() { {"call":"ui.view","params":{"view":"top"}} ])) .unwrap(); + // Pick the lines away from MID snaps so this remains an angular workflow. let picks = json!([ - {"click":{"sketch":[20,0]}}, - {"click":{"sketch":[15,15]},"shift":true} + {"click":{"sketch":[10,0]}}, + {"click":{"sketch":[10,10]},"shift":true} ]); if preselect { h.run(&picks).unwrap(); diff --git a/crates/ui-egui/tests/scenarios/s34_component_move_carries_sketches.json b/crates/ui-egui/tests/scenarios/s34_component_move_carries_sketches.json index fe08e28f15..4358e886fb 100644 --- a/crates/ui-egui/tests/scenarios/s34_component_move_carries_sketches.json +++ b/crates/ui-egui/tests/scenarios/s34_component_move_carries_sketches.json @@ -119,6 +119,26 @@ } } }, + { + "note": "The design's first part is made grounded (#2804): free it before moving it." + }, + { + "call": "ui.menu", + "params": { + "x": 100, + "y": 170, + "target": { + "type": "component", + "id": 1 + } + } + }, + { + "call": "ui.menuPick", + "params": { + "item": "Unground" + } + }, { "click": { "handle": "row:occurrence:2" diff --git a/crates/ui-egui/tests/scenarios/s43_root_sketch_on_moved_component_face.json b/crates/ui-egui/tests/scenarios/s43_root_sketch_on_moved_component_face.json index a039605e74..d7cdb8e74f 100644 --- a/crates/ui-egui/tests/scenarios/s43_root_sketch_on_moved_component_face.json +++ b/crates/ui-egui/tests/scenarios/s43_root_sketch_on_moved_component_face.json @@ -7,7 +7,8 @@ "params": { "command": "component.create", "params": { - "name": "C" + "name": "C", + "grounded": false } } }, diff --git a/crates/ui-egui/tests/scenarios/shortcut_recorder_grace_period.json b/crates/ui-egui/tests/scenarios/shortcut_recorder_grace_period.json new file mode 100644 index 0000000000..af282e1bc1 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/shortcut_recorder_grace_period.json @@ -0,0 +1,51 @@ +[ + {"call":"engine.execute","params":{"command":"sketch.create","params":{"plane":"XY"}}}, + {"call":"ui.prefs","params":{"open":"Keyboard Shortcuts"}}, + {"click":{"handle":"shortcut:filter"}}, + {"text":"sketch.mirror"}, + {"click":{"handle":"shortcut:sketch.mirror"}}, + {"key":"M","pressed":true}, + {"frames":20}, + {"key":"ShiftLeft","pressed":true,"shift":true}, + {"frames":20}, + {"key":"SuperLeft","pressed":true,"shift":true,"cmd":true,"ctrl":false}, + {"key":"M","pressed":false,"shift":true,"cmd":true,"ctrl":false}, + {"key":"SuperLeft","pressed":false,"shift":true}, + {"key":"ShiftLeft","pressed":false}, + {"expect":{"count":{"name":"shortcut_recording","value":1},"shortcuts":{"sketch.mirror":null},"tool":null,"dialog":null}}, + {"frames":65}, + {"expect":{"count":{"name":"shortcut_recording","value":0},"shortcuts":{"sketch.mirror":"Ctrl+Shift+M"}}}, + {"call":"ui.prefs","params":{"button":"ok"}}, + {"key":"M","shift":true,"cmd":true,"ctrl":false}, + {"expect":{"dialog":"SketchModify(Mirror)","tool":null}}, + {"key":"Escape"}, + {"key":"C"}, + {"expect":{"tool":"sketch.circle.center"}}, + {"key":"Escape"}, + {"call":"ui.prefs","params":{"open":"Keyboard Shortcuts"}}, + {"click":{"handle":"shortcut:filter"}}, + {"call":"ui.key","params":{"key":"A","cmd":true,"ctrl":false}}, + {"text":"solid.chamfer"}, + {"click":{"handle":"shortcut:solid.chamfer"}}, + {"key":"ShiftLeft","pressed":true,"shift":true}, + {"expect":{"shown":"Shift+…"}}, + {"frames":65}, + {"expect":{"count":{"name":"shortcut_recording","value":1},"shortcuts":{"solid.chamfer":null}}}, + {"expect":{"shown":"Add one ordinary key. Escape cancels."}}, + {"key":"C","shift":true}, + {"expect":{"shown":"Shift+C…"}}, + {"expect":{"shown":"Release all keys to confirm. Escape cancels."}}, + {"key":"ShiftLeft","pressed":false}, + {"expect":{"shown":"Confirming shortcut… Escape cancels."}}, + {"frames":65}, + {"expect":{"count":{"name":"shortcut_recording","value":0},"shortcuts":{"solid.chamfer":"Shift+C"}}}, + {"call":"ui.prefs","params":{"button":"ok"}}, + {"key":"C","shift":true}, + {"expect":{"tool":null,"dialog":"chamfer: true"}}, + {"click":{"handle":"dialog:Distance"}}, + {"key":"C"}, + {"expect":{"tool":null,"dialog":"chamfer: true"}}, + {"key":"Escape"}, + {"key":"C","shift":true,"cmd":true,"ctrl":false}, + {"expect":{"tool":null,"dialog":null}} +] diff --git a/crates/ui-egui/tests/scenarios/sketch_cancel.json b/crates/ui-egui/tests/scenarios/sketch_cancel.json new file mode 100644 index 0000000000..28a75c6827 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/sketch_cancel.json @@ -0,0 +1,16 @@ +[ + {"note": "#3838: Cancel Sketch leaves a sketch without keeping its changes. A new sketch is removed with what was drawn in it, and Undo goes back into it."}, + {"start": "sketch.create"}, + {"click": {"plane": "XY"}}, + {"expect": {"sketching": true}}, + {"start": "sketch.line"}, + {"click": {"sketch": [0, 0]}}, + {"click": {"sketch": [30, 10]}}, + {"key": "Escape"}, + {"expect": {"sketch_curves": {"own": 1}}}, + {"start": "sketch.cancel"}, + {"expect": {"sketching": false, "errors": 0}}, + {"expect": {"query": {"command": "document.inspect", "path": ["timeline"], "value": []}}}, + {"call": "engine.execute", "params": {"command": "edit.undo", "params": {}}}, + {"expect": {"sketching": true, "sketch_curves": {"own": 1}}} +] diff --git a/crates/ui-egui/tests/scenarios/sketch_on_moved_component_face.json b/crates/ui-egui/tests/scenarios/sketch_on_moved_component_face.json index 8de5af8ef6..a6f76317af 100644 --- a/crates/ui-egui/tests/scenarios/sketch_on_moved_component_face.json +++ b/crates/ui-egui/tests/scenarios/sketch_on_moved_component_face.json @@ -6,7 +6,9 @@ "call": "engine.execute", "params": { "command": "component.create", - "params": {} + "params": { + "grounded": false + } } }, { diff --git a/crates/ui-egui/tests/stand_up_offer.rs b/crates/ui-egui/tests/stand_up_offer.rs index a1fa594626..e450011eb0 100644 --- a/crates/ui-egui/tests/stand_up_offer.rs +++ b/crates/ui-egui/tests/stand_up_offer.rs @@ -126,6 +126,7 @@ fn turn_up_axis_in_the_component_menu_turns_and_undo_restores() { h.run(&json!([{"click": {"handle": "stand_up:leave"}}])).unwrap(); h.frames(3); let occurrence = h.app.session.doc.occurrences.iter().map(|o| o.id).max().expect("the import's occurrence"); + h.app.run("occurrence.ground", json!({"occurrence": occurrence, "grounded": false})).unwrap(); let items = solidcraft_ui_egui::context_menu::items(&h.app, &solidcraft_ui_egui::context_menu::Target::Occurrence { path: vec![occurrence] }); let turn = items.iter().find(|i| i.id == "ui.turnUpAxis").expect("Turn Up Axis is in the menu"); let labels: Vec<_> = turn.children.iter().map(|c| c.label.as_str()).collect(); diff --git a/deny.toml b/deny.toml index c5b57291cb..c24dbb4c7f 100644 --- a/deny.toml +++ b/deny.toml @@ -3,14 +3,6 @@ # The nightly command downgrades only this diagnostic with --warn unmaintained. unmaintained = "all" yanked = "warn" -# zip 8.6 comes only through cadmpeg-codec-freecad 0.6.1 (`zip = "^8"`); all three advisories -# are in ZipArchive::extract / ZipStreamReader::extract, which neither it nor solidcraft-io -# calls. Remove these when cadmpeg moves to zip 9. -ignore = [ - { id = "RUSTSEC-2026-0367", reason = "zip 8 via cadmpeg-codec-freecad; extract() is never called" }, - { id = "RUSTSEC-2026-0368", reason = "zip 8 via cadmpeg-codec-freecad; extract() is never called" }, - { id = "RUSTSEC-2026-0369", reason = "zip 8 via cadmpeg-codec-freecad; extract() is never called" }, -] [bans] multiple-versions = "warn" diff --git a/docs/book/src/guide/assemblies.md b/docs/book/src/guide/assemblies.md index b11148c338..7a02da2093 100644 --- a/docs/book/src/guide/assemblies.md +++ b/docs/book/src/guide/assemblies.md @@ -9,7 +9,31 @@ faded. A component is placed by its **occurrences**. Paste creates another instance of the same component, and **Paste New** creates an independent copy. Move an occurrence with the move triad, then **Capture Position** to keep it or **Revert** to put it back. SolidCraft asks before -the next command if a move wasn't captured. **Ground** fixes an occurrence in place. +the next command if a move wasn't captured. **Ground** fixes an occurrence in place, and +**Unground** (the same row of the browser's context menu) frees it again. + +The design's first part is grounded when it is made: the first occurrence placed directly in the +design by **New Component**, **Create Components from Bodies** or an import. Parts made after it, +standard parts and copies made with Paste or Paste New start free, so do parts made inside other +components. Any number of parts may be grounded, or none; opening a design never grounds +anything. A grounded part refuses Move until it is ungrounded, and `component.create` takes +`grounded: true` or `false` to choose for itself. + +**Drag a part to put it somewhere else.** With the design active, press the left button on a part +and drag: the part follows the pointer, the point you grabbed moving in the plane through it that +faces the view, and stays where you let go, with its position captured. The whole drag is one +undo step, and Escape during the drag puts the part back. The drag moves the placement of the +active component that holds the part, so with the design active a part inside a subassembly +moves the whole subassembly; activate the subassembly to move the part inside it. Moving a +definition shared by several placements moves it in all of them, as Move does. A grounded part +does not move, and a part that joints hold to something grounded moves only as far as its joints +allow (below). Dragging any member of a chain joined to nothing grounded moves the whole chain, +preserving its joint values. Shared nested definitions move in every instance; the selected +instance follows the pointer even when its parent is rotated differently from the first one. A drag that starts on empty space, on a part that can't move, or +with Shift, Ctrl (Cmd) or Alt held draws a selection box instead. Other uncaptured moves stay +pending. Scripts and agents do the same with `occurrence.drag {occurrence or path, at, point}`, +which moves the placement by `point - at` in world coordinates and answers the placements it +`moved`. Paste New copies nested component definitions, sketches, feature histories and generated dimension parameters. Repeated child instances remain shared within the new copy. Materials, @@ -185,8 +209,8 @@ picked in the browser, or on the first joint that moves when none is picked. grabbed follows the pointer in the plane through it that faces the view, the joints on the part's chain take the values that bring it nearest, within their limits, through motion links, and with closed loops kept closed, and a four-bar's rocker stops at the end of its swing. The -whole drag is one undo step. A grounded part, or one no joint places, is not moved; a drag on -it draws a selection box as before. Each frame runs `joint.reach`, which scripts and agents use +whole drag is one undo step. A grounded part, or one its joints hold fixed, is not moved; a drag +on it draws a selection box. Each frame runs `joint.reach`, which scripts and agents use directly to put a part's point somewhere: `joint.reach {occurrence, point}` moves the joints on its chain from where they are to the nearest pose (the arm keeps its elbow up or down, and a linkage stays on its branch). `at` picks the part's point (the centre of its bodies by diff --git a/docs/book/src/guide/features.md b/docs/book/src/guide/features.md index 6d91b167bb..1521f9f358 100644 --- a/docs/book/src/guide/features.md +++ b/docs/book/src/guide/features.md @@ -7,7 +7,7 @@ it. | Panel | Features | |---|---| -| Create | extrude (one side, two sides, symmetric, taper, start offset, to a distance or through all; new body, join, cut, intersect; thin walls along curves), revolve, sweep, loft, box, cylinder, sphere, torus, coil, pipe, rib, web, emboss, hole (simple, counterbore, countersink, tapped; on faces or at sketch points), thread, rectangular/circular/path patterns, mirror | +| Create | extrude (one side, two sides, symmetric, taper, start offset, to a distance or through all; new body, join, cut, intersect; thin walls along curves), revolve, sweep, loft, box, cylinder, sphere, torus, coil, pipe, rib, web, emboss, hole (simple, counterbore, countersink, tapped; on faces, at distances from edges, or at sketch points), thread, rectangular/circular/path patterns, mirror | | Modify | press pull, fillet, chamfer, shell, draft, scale, combine, split body, offset face, replace face, move/copy, align, remove | | Construct | offset plane, plane at angle, plane along path | | Inspect | measure, section analysis, interference, compare bodies, curvature comb and map, zebra, draft, minimum radius, isocurves, centre of mass | @@ -16,7 +16,19 @@ Extrude automatically selects **Join** for additions when the active component a solid, including sideways elbow connections and screw bosses that meet only part of a face. Extruding into existing material selects **Cut**. In an empty component it selects **New Body**; choose **New Body** explicitly to keep an addition separate. Changing the default does not alter -operations already saved in a design. +operations already saved in a design. A Join (of an extrude, revolve, sweep, loft, primitive…) +whose material touches none of its target bodies still joins it, to the first one as a separate +piece, and the feature's warning says so: "`Body1` is now in 2 separate pieces". Combine joins +the bodies it is given without that warning. + +**Extrude to a picked target** (Preferences › Design, off by default) lets a pick choose the +extent. With it on and a profile selected in the solid Extrude dialog, picking a face away from +its plane, a curved face, an off-plane vertex or a parallel plane automatically switches +**Distance** to **To Object**. Coplanar faces still join the profile selection; non-parallel +planes are not targets. The extent can still be chosen manually. For a new extrusion without +start or end offsets or a From Object input, the target also sets the stored distance and +one-sided direction, so returning to **Distance** keeps that reach. Thin Extrude keeps its +existing picks. With the setting off, picks only add profiles, as before. A taper slopes the sides of any profile, sketch text included: lines, arcs and spline or conic curves move in or out together, and a flat tip shorter than the taper closes to a point. On @@ -84,6 +96,15 @@ unsupported. A new Emboss without an explicit face infers a unique eligible supp select the face when the target is ambiguous. Older saved features without a face retain their planar placement. +A **Hole** with **Single** placement can also sit at distances from up to two straight edges of +its face. After picking the face, click **Reference 1** and pick an edge, then pick a second one +into **Reference 2**. Each edge gets a **Distance** row, measured from where you picked; the +canvas shows each one as a dimension from the edge to the hole's centre, with a box beside it. +Type a distance in either place and the hole moves so its centre is that far from the edge, +measured in the face's plane. Moving the hole measures the distances again. The hole keeps its +distances when the part changes: it follows its edges, and the distances are parameters like its +diameter. + A **Hole** with **Single** placement drills where you pick a planar face. A pick on or near a point of a shown sketch that lies on that face, such as a circle's centre, places the hole exactly at that point, as **Multiple** placement picks it; **Select Other** (press and hold) also @@ -91,6 +112,23 @@ offers the face where you clicked. The corners of text outlines don't count, nor points while the sketch hides projected geometry. The hole keeps that place on the face; to have holes follow later edits of the sketch, place them with **Multiple**. +Two settings under **Create Hole** in **Preferences › Design**, both off by default, change how +a Single hole is placed: + +- **Snap to face and edge centres**: the face itself offers snap points too: its middle, and the centre + of each round edge on it (a boss standing on it, a rounded corner, a hole already in it). While + the cursor is over the face they show as small marks; a pick near one, or on or beside one of + those round edges, places the hole at that centre, and the mark it takes is named. Hold **Alt** + or **Ctrl** to place the hole exactly where you click. +- **Move holes by clicking or dragging**: each placed hole has a handle at its centre. Drag it + to move the hole (it snaps when snapping is on), or click it to take the hole out. A pick + elsewhere on a face that already has a hole moves the hole there instead of taking it out. + **Edit Feature** shows the handle as well, so a hole can be moved later. + +The Hole dialog draws a section through the hole as its options make it (type, extents, drill +point, tapped) and puts each size in a field beside the dimension it sets; hovering a field, or +typing in it, lights its dimension. + Fillets and chamfers handle straight edges between planar faces, whole smooth loops, runs of neighbouring edges with mitred or spherical corners, and closed chains of curved edges (a rolling ball). See the roadmap's known limitations for the rest. @@ -195,7 +233,7 @@ still be added through `set`. The fields of the common features: | Chamfer | `distance`, `distance2` or `angle`, `flip`, `edges`, `body` | | Box | `length`, `width`, `height`, `corner`, `operation` | | Cylinder | `radius`, `height`, `base`, `axis`, `operation` | -| Hole | `diameter`, `depth`, `position`, `direction`, `hole` (simple, drilled, counterbore, countersink), `thread`, `to` | +| Hole | `diameter`, `depth`, `position`, `direction`, `hole` (simple, drilled, counterbore, countersink), `thread`, `to`, `references` (an `edge` point and a `distance` each) | | Shell | `thickness`, `faces`, `direction`, `body`, `tangent_chain` | | Draft | `angle`, `faces`, `neutral`, `pull`, `body` | | Offset face | `distance`, `faces`, `body` | diff --git a/docs/book/src/guide/files.md b/docs/book/src/guide/files.md index 093f53b59e..ff6e30c3f5 100644 --- a/docs/book/src/guide/files.md +++ b/docs/book/src/guide/files.md @@ -6,7 +6,7 @@ | `.json` (self-contained plain JSON design) | yes | yes | | STEP (AP203, AP214, AP242) | solids, assemblies, units, names, colours | AP242 with names, colours and the component tree | | IGES 5.3 | manifold solids, or trimmed surfaces sewn into solids | manifold solids with names and colours | -| FreeCAD `.FCStd`, Rhino `.3dm` | solids, through cadmpeg (desktop app only) | | +| FreeCAD `.FCStd`, Rhino `.3dm` | solids, through cadmpeg | | | 3MF, STL, OBJ | mesh bodies | yes | | glTF 2.0 (`.glb`, `.gltf` with its `.bin` files) | mesh bodies in the node tree's components, materials as appearances | | | 3MF with face tags | as 3MF | each triangle tagged with its B-rep face, exact coordinates | @@ -36,10 +36,11 @@ message: save the file uncompressed first. A 3MF project saved by a slicer comes in too, also when it keeps its objects in model parts of their own inside the file (the 3MF Production extension): each object on the build plates becomes -one mesh body, with all its volumes merged where the slicer placed them. Slicer-only data -(modifier, negative and support volumes, painting, per-object settings, and names kept in the -slicer's metadata) is not read: modifier and negative volumes come in as ordinary solid geometry, -and objects without a name in the model itself are called Mesh1, Mesh2, … +one mesh body, with all its volumes merged where the slicer placed them. Each body is named after +its object: the name in the model, else the one the slicer keeps in its project settings (Bambu +Studio's `Metadata/model_settings.config`), else Mesh1, Mesh2, … Other slicer-only data +(modifier, negative and support volumes, painting, per-object settings) is not read: modifier and +negative volumes come in as ordinary solid geometry. **File → Export STL…** and **File → Export 3MF…** (also **Save As Mesh** on the Insert tab's Files panel, and in the right-click menu of a body) open the same dialog with that format chosen; it saves diff --git a/docs/book/src/guide/shortcuts.md b/docs/book/src/guide/shortcuts.md index 221eafcf1d..beaaaf5abc 100644 --- a/docs/book/src/guide/shortcuts.md +++ b/docs/book/src/guide/shortcuts.md @@ -2,6 +2,32 @@ Command shortcuts can be changed in File › Keyboard Shortcuts; these are the defaults. +Click a binding to record a new shortcut. A shortcut can contain Shift, Ctrl/Cmd and +Alt/Option modifiers plus exactly one other key. Hold the modifiers, then press the final +key; you can take your time assembling the modifiers before that key. The recorder also +allows modifiers to arrive after the ordinary key: it previews the chord and waits one +second after the last added modifier, and until all keys are released, before confirming. +For example, record Shift+Cmd+M for Sketch Mirror on macOS. Escape cancels recording without +changing the old binding. Pressing two different ordinary keys cancels the attempt. + +The binding button shows modifiers immediately as you press them, even before adding the +ordinary key. Releasing a modifier before that key removes it from the preview. A status +line tells you to add a key, release all keys, or wait for confirmation; the proposed chord +stays visible during confirmation. Nothing is saved while the preview is still pending. + +On macOS, the editor, Help list, toolbar hints, context menus and S search display Cmd and +Option. Existing settings retain SolidCraft's Ctrl/Cmd alias for the primary modifier; +Control and Command pressed together are recorded explicitly as Ctrl+Cmd. Conflicting +bindings still require confirmation, and built-in reserved shortcuts remain reserved. +Recording changes app settings, not the design file. + +This follows the usual [modifier-before-key convention documented by Apple](https://support.apple.com/en-ie/102650) +and the [key-event and modifier-state model documented by W3C](https://www.w3.org/TR/uievents/#keys-modifiers). +The one-second grace period is SolidCraft's recording aid, not a standard-mandated delay; +using an already assigned shortcut has no added delay. Operating systems and browsers can +intercept reserved combinations before SolidCraft receives them. Choose a different chord +if that happens; recording is cancelled if the app loses focus. + | Key | Command | Key | Command | |---|---|---|---| | L | Line | E | Extrude | diff --git a/docs/book/src/guide/sketching.md b/docs/book/src/guide/sketching.md index 2b8b488c1d..014b4d0834 100644 --- a/docs/book/src/guide/sketching.md +++ b/docs/book/src/guide/sketching.md @@ -3,7 +3,9 @@ A sketch lies on a plane: an origin plane, a construction plane or a planar face. Start one with **Create Sketch** (`sketch.create`) and click a plane, or pick a face first. Right-clicking a plane, in the view or on its browser row, offers **Create Sketch** on that plane too. -**Finish Sketch** returns to the model. +**Finish Sketch** returns to the model. **Cancel Sketch** (`sketch.cancel`) returns without +keeping what changed since the sketch was created or opened for editing: a new sketch is removed, +an edited one goes back to how it was, as one step that Undo brings back. In scripts, `XY`, `XZ`, `YZ` and their `Top`, `Front`, `Right` aliases name origin planes. These names are reserved: a construction plane cannot be created with or renamed to one diff --git a/docs/book/src/guide/ui-tour.md b/docs/book/src/guide/ui-tour.md index 68c84f831f..97593d1e08 100644 --- a/docs/book/src/guide/ui-tour.md +++ b/docs/book/src/guide/ui-tour.md @@ -30,6 +30,10 @@ preset in Preferences switches to SolidWorks, Inventor, Alias, Blender, Tinkercad, OpenSCAD or Touchpad style). On a trackpad, pinch zooms and two-finger scroll pans (Navigation › Two-finger scroll pans; off: scroll zooms like the wheel); mouse wheels zoom and the thumb wheel pans sideways. + With the **Fusion** preset and two-finger pan enabled, hold **Shift** while scrolling with + two fingers to orbit freely instead of panning. No click is needed. Releasing Shift returns + to panning, even during the same gesture. Shift does not snap this gesture to standard views; + orbit gestures do nothing on flat 2D pages. Other presets keep their existing scroll behaviour. Shift+middle orbits freely in the default and Inventor presets. In every preset, Ctrl+left drag (Cmd on macOS) that starts on empty space orbits; a Ctrl+click still adds to the selection. Shift+right-drag snaps to the nearest standard or @@ -116,7 +120,7 @@ Gruvbox themes (dark and light, each in hard, normal and soft contrast) and any imported. A choice applies at once. A theme is a small JSON file that starts from the Dark or Light palette and changes any of its -colours by name: +colours and corner radii by name: ```json { @@ -130,6 +134,14 @@ colours by name: "viewport_top": "#32302f", "viewport_bottom": "#32302f", "triad": ["#fb4934", "#b8bb26", "#83a598"] + }, + "radii": { + "radius_sm": 2, + "radius_md": 4, + "radius_lg": 6, + "radius_xl": 8, + "control_radius": 4, + "sketch_mode_radius": 4 } } ``` @@ -137,6 +149,16 @@ colours by name: Colours are `#rrggbb`, or `#rrggbbaa` for translucent ones. A few colours, such as the view triad's X, Y and Z, take a list. Any colour the file leaves out keeps the base palette's value. +`radii` is optional. Its six keys above accept integers from 0 to 32 in logical UI points; +zero makes square corners. Omitted keys keep today's base values: 4, 6, 8, 10, 6 and 7, +respectively. The first four style small accents through large surfaces; `control_radius` +styles form controls, and `sketch_mode_radius` styles sketch creation buttons. Themes own +these settings, with matching shapes across light and dark variants. Dock joins remain +square, circular controls remain circular, and control sizes and spacing stay the same. +Platinum 9 and the phosphor themes use square corners, SolidCraft 95 uses subtle 3-point +corners, Blueprint uses the standard radii, and Cupertino uses softer corners. Other built-in +themes retain the defaults. + Tab accents also have separate fill, small-text ink and tint keys. Sketch uses `sketch_accent`, `sketch_ink`, `sketch_soft`; Surface, Mesh, Sheet Metal and Render use `_fill`, `_ink`, `_soft` (`sheet` for Sheet Metal). Form uses @@ -150,7 +172,7 @@ pills when `on_accent` has less than 4.5:1 contrast with the fill. See the - **Import…** adds a theme file and turns it on. Importing a file with the same name again updates that theme. A file with an unknown colour name or a malformed colour is refused, and the message names every problem. -- **Export…** saves the theme on now as a complete file listing every colour. It is a good starting +- **Export…** saves the theme on now as a complete file listing every colour and radius. It is a good starting point for your own theme. - **Remove** deletes the selected imported theme. @@ -183,3 +205,22 @@ lightly damped spring transition and a small boundary margin to prevent flicker. On desktop, Ctrl/Cmd+Plus (or Equals) enlarges the interface, Ctrl/Cmd+Minus reduces it, and Ctrl/Cmd+0 restores 100%. These shortcuts update the UI scale preference. The UI scale slider in Preferences applies the new layout when released, so it stays still while dragging. + + +Theme files can also define `borders`: `primary` outer outlines, `secondary` major section +rules, `tertiary` small dividers, `container` solid preview/card/keycap outlines, and independent +`control` outlines. Each accepts a `width` +from 0 to 4 UI points and a `color` in hex. Optional `primary_inset` adds a companion outline +inside panel chrome, with a `gap` from 0 to 12 points. Missing settings keep existing borders. +Blueprint uses a single white outer outline, dotted section lines and dark minor dividers. Secondary rules optionally accept `pattern: "dotted"`; the default is `solid`. Focus, selection and +error outlines retain their semantic colours. See the UI-kit border hierarchy for the schema. + +SolidCraft 95 also defines optional `borders.bevel`: a per-band `width` and the +`highlight`, `light`, `shadow` and `dark_shadow` colours. The shared UI uses these +for raised panels/buttons, recessed fields and reversed pressed-button lighting. +Themes that omit this setting keep their existing flat borders. + +Theme files may also set `icon_palette`: opaque colours for SVG display roles, +and three-colour pale/medium/strong ramps for Solid, Surface, Mesh, Form, Sketch, +constraints and construction. These settings colour the existing command artwork +without changing its shapes. Omitted roles keep the default light/dark palette. diff --git a/docs/book/src/reference/glossary.md b/docs/book/src/reference/glossary.md index 0e54856f71..f3c66893f4 100644 --- a/docs/book/src/reference/glossary.md +++ b/docs/book/src/reference/glossary.md @@ -229,7 +229,7 @@ One placed copy of a component inside its parent component. A component can have several occurrences. Moving an occurrence moves only that copy; editing the component changes every copy. The browser names occurrences with a number, such as `Bolt M20:3`. -- **In SolidCraft:** `occurrence.copy`, `occurrence.move`, `occurrence.ground`, +- **In SolidCraft:** `occurrence.copy`, `occurrence.move`, `occurrence.drag`, `occurrence.ground`, `occurrence.properties`; the `occurrences` design-file key; the `path` of occurrence ids. - **Don't use:** instance, placement. - **Elsewhere:** occurrence, instance, component instance. diff --git a/docs/capabilities.md b/docs/capabilities.md index 9a93a46243..c35c052dc4 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -70,7 +70,7 @@ and help is welcome: see [CONTRIBUTING.md](../CONTRIBUTING.md). that later features build on — open a `.step`/`.stp`/`.igs`/`.iges` file, insert one into a design, or drop it on the window ([docs/step-import.md](step-import.md)); FreeCAD `.FCStd` and Rhino `.3dm` solids import the same way through - [cadmpeg](https://github.com/cadmpeg/cadmpeg) in the desktop app and CLI. + [cadmpeg](https://github.com/cadmpeg/cadmpeg) in the desktop app, the browser app and the CLI. - **Render:** opaque surfaces can use metal fraction, roughness and dielectric IOR in the integrated product viewport and CPU preview PNGs. Existing simple colors remain supported. Fixed direct lighting is a preview; HDRI, refraction and texture maps are not implemented. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 272b60f981..59491bd137 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -1,7 +1,31 @@ # Control protocol -`solidcraft --control PORT` (or `SOLIDCRAFT_CONTROL_PORT=PORT`) listens on `127.0.0.1:PORT` for -JSON lines. Each request is one line `{"id": …, "method": "…", "params": {…}}`; each reply is one +The control channel is **on by default**. SolidCraft listens on `127.0.0.1` only, for JSON lines, +so agents can drive the app without a launch flag. Every connection still needs this launch's +token (below); nothing else on the machine, and nothing off it, can send a command. + +| Launch | What listens | +|---|---| +| `solidcraft --control PORT`, or `SOLIDCRAFT_CONTROL_PORT=PORT` | exactly `127.0.0.1:PORT` (the flag wins over the variable), whatever the setting | +| neither, and Preferences > Assistant > "Let AI agents connect (control channel / MCP)" on (the default) | the default port **27878**, or the next free one up to 27893 when it is taken (a second window, another program) | +| neither, and the setting off | nothing | + +The setting is read at start-up. Old preference files, which don't have it, load with it on. A +launch with `--control` or the variable behaves as it always did (the start page stays hidden, +the window doesn't ask for the foreground), because a scripted launch expects that; the default +channel changes neither for a normal launch. + +### Finding the port + +Each app that opens its channel writes the port to a discovery file beside the token files, +`control/current.port` (same folders and permissions as below), and removes it at exit if no later +app has replaced it. It holds only the port, never the token. `solidcraft-cli mcp` with no +arguments reads it, checks that something listens there and bridges to that app; with no app +running, or with `--headless`, it runs a session of its own. `--connect HOST:PORT` and the +other arguments still decide first. With several windows open, the most recently started one is +the one found; use `--connect` for another. + +Requests are one line `{"id": …, "method": "…", "params": {…}}`; replies are described below. Each request is one line `{"id": …, "method": "…", "params": {…}}`; each reply is one line `{"id": …, "ok": true, "result": …}` or `{"id": …, "ok": false, "error": "…"}`. Requests are handled on the UI thread between frames; pointer and keyboard input is injected as real egui events, one pointer event per frame. @@ -51,13 +75,20 @@ answered within 60 seconds (3600 seconds for `engine.batch`) is replied to with `{"ok": false, "running": true, "error": "timeout: … is still running …"}`. The same 16 MiB line limit applies to the MCP server. -A timed-out request has not failed: it is still running in the app and will still apply (a long -fillet, say), so don't simply send it again. The connection keeps its reply (#1618). Ask for it +A timed-out request has not failed: it is still running in the app, and a change still applies +when it finishes (a long fillet, say), so don't simply send it again. The timeout's text says +what it still does: a request that only reads (`ui.inspect`, `document.inspect`, an inspection +command) "only reads: nothing changes"; one that only writes a file (`file.export`, +`file.save`, `file.save_as`, `file.save_mesh`, another export given a `path`, `ui.render`, or +`ui.screenshot` with a `path`) "will still write the file when it finishes" and leaves the design +as it is; any other request "will still apply when it finishes". The connection keeps its reply (#1618). Ask for it with `control.pending`: `{"pending": , "method": …, "done": false}` while it runs, then `{"pending": , "method": …, "done": true, "reply": {…}}` (its own reply, once), and `{"pending": null}` when nothing is outstanding. Until it lands, any other request on that connection is refused at once with `{"ok": false, "busy": true, "error": "busy: …"}` and not -run, so nothing is queued to apply unseen behind it. +run, so nothing is queued to apply unseen behind it. `control.pending` belongs to the +connection: a new connection has nothing pending. The bridged MCP server does this itself +([mcp.md](mcp.md#long-running-requests)). `control.pending` answers only for its own connection. `control.running` answers for the whole app, the same on every connection, at once (it never waits behind a running request, and a @@ -191,10 +222,10 @@ labels; `prefs:measure_axis_distances` identifies the checkbox. | `ui.sketchToScreen` | `points`: `[[x, y]…]` in active-sketch coordinates | their screen points (null when behind the camera) | | `ui.dialogInput` | `index` | what clicking an input's Select chip does: picks go to that input | | `ui.at` | `world: [x,y,z]` \| `sketch: [x,y]` \| `plane: "XY"` \| `axis: "Z"` \| `dimension: "d1"` (its value text) \| `handle: "arrow"` (a manipulator handle, see `scenario::publish_handle`) | the screen point (a scenario step's target; see `crates/ui-egui/src/scenario.rs`) | -| `ui.handles` | `prefix?` | the handles drawn last frame, `[{name, x, y}]`: toolbar buttons and drop-down items `toolbar:` (the button alone: `button:`; a drop-down row: `dropdown:`, highlighted `dropdown_hot:`, its pin `dropdown_pin:`; Customize Toolbar's check boxes `customize:`), ribbon tabs `tab:` (MODEL, SURFACE, ASSEMBLE, INSERT, INSPECT, MANAGE, VIEW, SHEET METAL; contextual SKETCH; workspace RENDER), panel labels `panel::` (also `ribbon:section::
`), document overflow list `tab:overflow`, workspace column `ribbon:workspace`, command-search launcher `ribbon:search`, browser rows `row: