Compare commits

...

49 Commits

Author SHA1 Message Date
bherbruck cd45fc5fe6 Preferences: choose the Extrude dialog's starting distance (fixed or last used)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:43:48 +00:00
JIJINBEI 355154fa9b refactor: geom::Grid for control nets; one surface fit through a grid of samples (#2331)
Thanks @jijinbei.
2026-10-19 07:39:22 -04:00
github-actions[bot] f1a5f4ab30 Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-19 11:38:55 +00:00
takuto-NA 3a9b053f5b Fix constrained point dragging within the shared frame budget (#1976) (#1977) 2026-10-19 07:24:04 -04:00
amolinari-autotex aaa35a94d8 Relink a linked assembly source from the Browser (#2772) (#2773)
Thanks @amolinari-autotex.
2026-10-19 07:14:33 -04:00
Colin Faulkingham 852d364d5f Preserve curved face picks when reopening modeled threads (#2265)
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-19 07:14:07 -04:00
Jacques 34cb3167b6 Sketch: trim keeps the rest of a dimensioned line where it was (#2910)
Thanks @Jacquesvdberg92.
2026-10-19 06:44:50 -04:00
abteenz 1666ca7018 Fix the macOS launch hang with the 3Dconnexion driver installed (#2841)
Thanks @abteenz.
2026-10-19 05:54:43 -04:00
NOWN-DEV 768e69c4c7 Joints: a new joint between faces that already meet leaves the part as it is (#2709) (#2710)
Thanks @NOWN-DEV.
2026-10-19 05:54:21 -04:00
bherbruck 5997d28de7 STEP import: orient each instanced part once (#2814) (#2875)
Thanks @bherbruck.
2026-10-19 04:07:39 -04:00
bherbruck 5122b98af3 Parameters window: no per-frame re-evaluation on big designs (#2723)
The window listed (and so evaluated) every parameter and re-checked the add row's
expression on every frame, and laid out every row. It now lists once per design
revision, caches the add row's check, and lays out only the rows in view.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 02:16:36 -04:00
bherbruck f9342aa8b6 Malay: drop the "Gap" entry that is not in the English catalog
The catalog test rejects ids English doesn't have.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:38:57 -04:00
bherbruck d9c3fdb80f Agent chat: drops on the popped-out window attach (#2812) (#2861)
The chat in a window of its own takes the files dropped on it and shows the drop hint while files are dragged over it; a window floating inside the app counts as the chat's place for the app's drop handling.
2026-10-19 00:03:11 -04:00
bherbruck dc27988bc2 Agent chat: PDFs as documents, any other file by reference (#2812) (#2858)
A PDF goes whole to agents that take embedded documents; to the others, or past 12 MB, the message names it with its type, size and place. Any file that is not a picture, text or CAD file is attached by reference instead of refused. Chips show the type's extension; the file dialog offers All files.
2026-10-19 00:02:29 -04:00
bherbruck e3831b05d4 Agent chat: Ctrl/Cmd+V attaches copied files and pictures (#2812) (#2854)
Files copied in the file manager attach as files, a picture on the clipboard attaches as a picture, text pastes as text. A picture-only or files-only paste is seen through the V key coming up (egui-winit sends nothing when the clipboard has no text); macOS sends no key-up while Cmd is held, so there only pastes that come with text are seen and + > Paste a picture still works.
2026-10-18 23:30:21 -04:00
bherbruck 59ba87103e CI: heavy checks run after review (approved-for-ci label) (#2689)
* CI: heavy checks run after review (approved-for-ci label)

Pull requests without the approved-for-ci label run Select checks, the
format and repository checks and a quick cargo check. The label runs the
full suite, and every push after that does too. Other labels start no work
and never cancel a run. Main, merge groups, nightly and Run workflow are
unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* CI: approved fork PRs run heavy jobs on self-hosted-fast in private directories

An approved-for-ci label on a fork pull request sends Clippy, Build tests,
the test shards and Doc tests to the trusted runners, with private copies
of the build directory, Cargo home and rustup home that are deleted after
the job. Only the labeling event qualifies; later pushes use the old fork
routing. The skipped run of an unrelated label names its ci job differently.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* Revert "CI: approved fork PRs run heavy jobs on self-hosted-fast in private directories"

This reverts commit 439f2f7dab.

* CI: name the ci job differently on an ignored label's skipped run

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 23:28:17 -04:00
bherbruck 19ffb0dd75 Orbit drags turn from where they began, so erratic drags don't drift (#2842)
An orbit drag now applies its whole turn so far to the camera it began
with, instead of adding each pointer step to the camera of the step
before. The view then depends only on where the pointer is: dragging
past straight up or down and back no longer loses the pitch that the
clamp swallowed, and returning to the starting point brings back the
starting view. The pivot is still picked once, when the drag begins,
and kept still on screen by the rigid turn about it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 23:12:55 -04:00
NOWN-DEV c66e33284b Joints: offsets, angles and values typed as expressions follow their parameters (#2385) (#2386)
Thanks @NOWN-DEV.
2026-10-18 22:13:13 -04:00
NOWN-DEV 57d210130c Assembly: place an occurrence or an inserted STEP file by a matrix (#1890) (#1901)
Thanks @NOWN-DEV.
2026-10-18 22:03:20 -04:00
SiroArt 71534117af Mesh a face thinner than the tolerance instead of leaving a hole (#2712)
Thanks @SiroArt.
2026-10-18 21:56:28 -04:00
github-actions[bot] 38f6ed76c9 Regenerate THIRD-PARTY-LICENSES.txt and docs/features.md [skip ci] 2026-10-19 01:41:22 +00:00
A.Hakim Noor 66108a6b99 Add Malay language support and update localization doc (#2188)
Thanks @a-hakim.
2026-10-18 21:34:54 -04:00
CypherNaugh_0x 8e762fee57 Add optional ambient occlusion and graphics quality presets (#1135)
Thanks @CypherNaught-0x.
2026-10-18 21:29:03 -04:00
phanoob 6b1fc16d47 Sketch projection: a solid's edges come in as their exact curves (#1508)
Thanks @phanoob.
2026-10-18 21:23:11 -04:00
bherbruck 1ea85ff231 Merge pull request #2831 from tangency-labs/ci-fast-runners-main-only
CI: repository checks keep off the fast runners for pull requests
2026-10-18 21:18:10 -04:00
bherbruck 2aa808b116 Merge pull request #2303 from tangency-labs/sketch-grid
Sketch: always show a grid on the open sketch's plane
2026-10-18 21:16:52 -04:00
CamWight aa438611a7 S command search and the new design's name: shown and matched in the current language (#2544) (#2555)
* claim: #2544

* S command search and the command line: names, places and matching in the current language (#2544)

The S box showed each command's English label and toolbar place in every language, and found
only what was typed in English; the command line matched English only too. Rows, places and
tooltips now go through the label lookup, and the query matches the translated label, tab and
panel as well as the English ones and the id. The pin tooltips get catalog entries.

A new sweep (i18n_painted_tests) runs every language, reads the text each frame paints, and
fails on English painted where the language has a translation, skipping the design's own names
and key names, so a painter call that skips the lookup can't come back unnoticed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* The painted-text sweep also covers every Preferences page and four right-click menus

Each surface must show (the Preferences title in that language, or new text for a menu), so the
sweep can't pass by painting nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* S box rows: labels stay 'static; the place's separator is drawn, level in every language

A font's "›" (and one centred on the row) sits on the baseline beside Japanese or Chinese words,
where it reads as a comma. The separator is now a drawn chevron (glyphs::breadcrumb) centred on
the ink of the panel's name. search() returns the place as (tab, panel).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* A new design's name, Untitled, shows translated in the browser too, like the other default names

The tab and the save prompt translated "Untitled" through their own special case; the browser's
top row showed it in English. It is now one of the default names the label layer translates
(Locale::default_name, with Sketch1, Body2, Component3), so every place that shows a given name
shows it translated, and the two special cases go. The painted-text sweep no longer skips a design
name that the language translates, and splits a tab's dirty mark off, so it catches a place that
shows the raw name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:16:48 -04:00
leValdemar19 86b2b97d6f Sketch Offset: constrain to the ends of an offset spline (#2424) (#2615)
* claim: #2424

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SBN24y2TcqRcm2zn8Gkj27

* Sketch Offset: constrain to the ends of an offset spline (#2424)

An associated offset spline hid all of its generated fit nodes, its two
end points included, so nothing snapped to them, and a curve drawn from
one shared the node and was refused ("invalid offset output point roles").

- An open offset's end points are shown and snapped to again; its
  interior fit nodes stay private.
- After every sketch edit, a curve that took an offset node gets its own
  point there, coincident with the node: the offset keeps sole ownership
  and the curve follows the offset when its source moves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SBN24y2TcqRcm2zn8Gkj27

* Scenario: lines snap to and close onto an offset spline's ends (#2424)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SBN24y2TcqRcm2zn8Gkj27

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:16:43 -04:00
NOWN-DEV c177adfceb Clearance: check a shared volume against the union, allowing for the bodies' measuring error (#2090)
* Clearance: check a shared volume against the bodies and their union

An intersection can come out well formed and still wrong: one of a
mirrored pair of imported parts shared three times the volume of its
twin. The shared volume is now compared with vol(a) + vol(b) - vol(a ∪ b),
on the same (cut down) bodies; when they differ by more than 1 % (plus the
bodies' own measuring error), the pair is reported unchecked with both
numbers. Where the union gives no answer, the intersection stands.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Clearance: let the union cross-check allow for the bodies' measuring error

vol(a) + vol(b) - vol(a ∪ b) is a difference of measured volumes and
carries the error of all three, so correct small overlaps on large bodies
came back unchecked (0.96 mm³ where the union implied -0.75). measure
keeps a face's fine tessellation when it is within 1 % of one with a
5 times coarser chord, so a measured volume can be off by up to 0.25 %:
the allowance is now 1 % of the larger of the two answers plus 0.25 % of
the three volumes. Past it, a union that implies nothing (within that
noise of zero, or below it) while the shared part is at most a tenth of
the smaller body cannot tell, and the shared part stands. A shared part
of 412 mm³ where bodies of a few thousand imply 141 is still caught.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Clearance: tighten the union check's measuring allowance to 1e-4

The old note cited measure's 1 % gate, which compares face areas, not
volumes, and its 0.25 % was about 100 times the disagreement the bodies'
measures actually show. The allowance is now justified from volumes: a
curved face's chord shortfall cancels between a body and the union except
where the chord changes, which stays near 2e-5 of the three volumes; 1e-4
leaves five times that. A 40 mm roller pressed 0.2 mm into a 2 m plate
exercises it: with no allowance the pair is left unchecked (7.52 mm³
measured against 8.16 implied), with it the overlap is reported.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-18 21:16:37 -04:00
JIJINBEI 7ad5130cd2 refactor: geom::UnitVec3 for unit axes and normals (#2603)
* geom: UnitVec3; a face's analytic axes and normals are unit by type

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* geom: UnitVec3 adds to and subtracts from a Vec3

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* kernel, doc, engine: cylinder axes and measured normals held as UnitVec3

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* kernel, engine: functions that take a unit axis or direction say so by type

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* doc: a face's normal goes to Plane::from_normal as is

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* kernel, doc: main's new cylinder helpers take the face's axis as a UnitVec3

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:16:31 -04:00
ealitt a3080d02d6 claim: #2561 (#2562) 2026-10-18 21:16:25 -04:00
DuBearte 17e173cb7b Construct tools ask for their picks instead of failing with "a is required" (#2474, #2494) (#2563)
* claim: #2474, #2494

* fix(ui): construct tools ask for their picks instead of failing (#2474, #2494)

The 13 construct tools without a dialog of their own ran their command with no
parameters from the toolbar and reported that `a` (or `face`, `edge`...) is
required. One pick dialog now serves them: labelled inputs per tool,
preselection fills them, and each pick becomes the construction reference the
command takes; Point Along Path keeps its position.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 21:16:20 -04:00
bherbruck 46bc44b706 Merge pull request #2821 from tangency-labs/ci-faster-shards
CI: faster test shards
2026-10-18 21:16:14 -04:00
bherbruck 9eb1540a34 Docs: describe SolidCraft as independent CAD (#2768)
Reword comments, docs and package descriptions so they describe SolidCraft's own behaviour instead of comparing it to another product. The trademark notice, the commercial-CAD rule and the navigation preset name stay as they were.

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 21:16:11 -04:00
bherbruck 3a1e1eed39 Merge pull request #2267 from tangency-labs/ci/slow-runner-timeouts
Tests: more time for the web offline launches and the icon review server
2026-10-18 21:16:09 -04:00
fkirill 9ee2de9a0f Fix drilled hole cuts through tapered walls (#2387) (#2401)
Rebase the complete PR change onto current main, preserving changes previously stored in merge commits.
2026-10-18 21:16:06 -04:00
NOWN-DEV ed64988d8f Joints: a round origin keeps its end when the face it opens onto gets other neighbours (#2760)
A round origin's end is kept by the name of the face across the rim. On
a face in pieces (a sheet split by a groove or a bend) that name lists
the faces round the piece, and when those change (a hole nearby moves
or goes) the name matched neither rim, so the end fell back to the
nearer end of the stored pick: a pick near one end of a thin wall that
the design moved past mid-wall turned a part through 180 degrees. The
end whose rim matches the name better now wins: the face itself, a face
merged into it, then a piece of it, then the same face by its stem.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 21:16:01 -04:00
bherbruck 03e6c3d468 Version 0.16.2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:40:00 +00:00
bherbruck cbf589eb8c Revert #2142: orbit pivot choice back to 0.15.0's, as in 0.16.1 (#2832)
This reverts commit cac4bccea8. Orbiting
about the point under the cursor goes back to 0.15.0's pivot choice
(the picked face or vertex point, an edge's midpoint, or the visible
geometry's centre over empty space), matching 0.16.1.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:36:37 -04:00
bherbruck cd85efd73d CI: the repository checks also keep off the fast runners for pull requests
the format and repository checks job still took a self-hosted-fast runner for every same-repo
pull request. It now follows the same rule, and the notes at the top say so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:36:35 +00:00
bherbruck 2452513b65 CI: faster test shards
- Pull requests that can change a robustness golden check every seed but the 17 slowest, in the
  same nextest run as the other tests instead of a second step after it (16-30 minutes on its own).
  The slow seeds (about four fifths of the goldens' time) still run in the merge queue, on main and
  nightly. A failed golden still prints how to re-record it, and junit.xml now holds the whole run.
- The slowest tests start first (nextest priority), so a shard no longer ends with one of them
  running alone.
- tspline::tests::hostile_tsplines_never_panic joins the heavy tests pull requests skip (up to
  12 minutes on a GitHub runner; the same kind of check as hostile_params_never_panic).
- The ci profile builds dependencies without line tables: the test archive pull requests' shards
  download goes from 3.4 GB to 2.2 GB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 00:36:14 +00:00
bherbruck 9c411859bc CI: self-hosted test shards build in their warm directories, no 3 GB download (#2833)
On main and in the merge queue every test shard ran on the self-hosted machine but waited for
the GitHub-hosted Build tests job and then spent about 12.5 minutes downloading its 3.2 GB
archive (four shards at once over one connection). There, each shard now builds the tests in
the runner's warm build directory (the existing build-tests one) and runs its slice directly;
Build tests runs only where the shards run on GitHub's or the untrusted runners.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:35:03 -04:00
bherbruck 22a86a744e CI: keep the fast self-hosted runners for main and the merge queue (#2830)
Same-repo pull requests ran their heavy jobs on the three fast runners too, and the merge
queue's test shards waited behind them for hours. Pull requests now use the GitHub-hosted path.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:31:22 -04:00
bherbruck 143435dc1c Format and clippy
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 23:53:43 +00:00
bherbruck 36aa4a15fe Merge remote-tracking branch 'origin/main' into sketch-grid 2026-10-18 23:52:12 +00:00
bherbruck e887792182 Fix orbit about the picked point (regression in 0.16.0) (#2753)
Orbiting about the point under the cursor (or the origin) turned the camera
about its target and then panned to put the pivot back. The pan works at the
target's depth with the parallel-view scale, so in a perspective view the
pivot slid off its pixel and the model swung about the camera.

The camera now turns rigidly about the pivot: Camera::orbit_about keeps the
pivot's coordinates in the camera's frame and rebuilds the target from the
new orientation, so the pivot stays exactly on its pixel in both projections.
The Shift snap spring and view.navigate's pivot orbit use the same.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:51:37 +00:00
bherbruck a940f77447 Sketch: always show a grid on the open sketch's plane
While a sketch is open its grid is drawn on the sketch's own plane, centred
near the view target and snapped to the major step, whatever the Grid switch,
the sketch's Sketch Grid option or Grid follows the view say. Headless window
pictures follow. Outside a sketch nothing changes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-18 01:44:15 +00:00
bherbruck c59842b8e1 Merge branch 'main' into ci/slow-runner-timeouts 2026-10-17 20:26:18 -04:00
bherbruck a587d3339e Tests: more time for the web offline launches and the icon review server
On a busy CI machine (several fork jobs under gVisor at once) a web
offline launch took over 90 s and the icon review server took over ~2 s
to answer. Launches now get 240 s and one retry; the review server gets
up to 60 s to start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 00:11:14 +00:00
199 changed files with 8482 additions and 1302 deletions
+31 -2
View File
@@ -1,8 +1,9 @@
# cargo-nextest settings for CI (`cargo xtask test` does not use nextest).
# PR runs build the tests once and run them in 4 shards (.github/workflows/ci.yml).
# CI builds the tests once and runs them in shards: 2 for a pull request, 4 for main and the nightly
# (.github/workflows/ci.yml).
[profile.ci]
# Heavy tests (one to four minutes each on a CI runner) that pull requests skip. They run on
# Heavy tests (one to twelve minutes each on a CI runner) that pull requests skip. They run on
# every push to main and nightly (`--ignore-default-filter`), and in `cargo xtask test`. The
# boolean_props property tests run only nightly (ci.yml excludes them from pushes to main).
# Keep this list in sync with docs/book/src/dev/testing.md and CONTRIBUTING.md.
@@ -17,6 +18,7 @@ not (
| test(=cmd::plastic::tests::enclosure_snap_fit_follows_resized_front_wall)
))
| (package(solidcraft-kernel) & test(=tests::boolean_identity_on_tangent_primitives))
| (package(solidcraft-kernel) & test(=tspline::tests::hostile_tsplines_never_panic))
| (package(solidcraft-kernel) & binary(boolean_props))
| (package(solidcraft-engine) & binary(atomic_commands))
| (package(solidcraft-ui-egui) & binary(scenarios) & test(=acceptance_sensor_enclosure))
@@ -39,3 +41,30 @@ path = "junit.xml"
filter = "binary(action_matrix) & test(=action_matrix_sample)"
threads-required = "num-cpus"
# The slowest tests start first, so a shard does not end with one of them running alone on a
# core while the others sit idle (seed_81 alone takes about 20 minutes under CI load). Medians
# from the CI test timings (the junit artifacts), slowest first; a test over ~100 s belongs here.
[[profile.ci.overrides]]
filter = """
(package(solidcraft-engine) & (
test(/^tests::robustness_goldens::seed_(81|45|20|24|95|02|57|40|18|48|32|46|85|61)$/)
| test(/^tests::robustness::/)
| test(=tests::hostile_params_never_panic)
| test(=cmd::clearance::tests::distances_are_exact_on_edges_and_curved_faces)
| test(=cmd::clipboard::tests::a_pasted_coil_joins_the_pasted_body)
| test(=tests::pleat_join::the_pleats_join_at_every_wall_from_2_4_to_2_6_mm)
| test(=tests::flush_join_loft::a_flange_flush_with_the_end_of_a_bent_tapered_tube_joins)
| test(=cmd::sheet::tests::perforated_sheet_takes_a_thousand_round_holes)
| test(=boolean_regression_tests::joined_loft_frames_plan_their_display_at_every_quality)
))
| (package(solidcraft-engine) & (binary(atomic_commands) | binary(step_export_regressions)))
| (package(solidcraft-kernel) & (
test(=curveblend::tests::rolling_ball_turns_about_a_rounded_corner_of_its_radius)
| test(=tests::fillet_tabbed_plate_outer_loop_and_partial_selection_after_hole)
| test(=tspline::tests::hostile_tsplines_never_panic)
| test(=fill::tests::regular_rims_of_three_to_eight_sides_fill_with_one_face_and_close_the_cup)
| test(/^issue_788_tests::equal_bores_crossing_/)
))
"""
priority = 100
+133 -48
View File
@@ -5,7 +5,7 @@
# those. Changes to the lockfile, root manifest, toolchain/cargo/nextest config, .github/,
# vendor/, tests/, scripts/ or any file no package owns select the whole workspace, as do
# pushes to main, the nightly run and Run workflow.
# Tests are built once and run in 4 shards. A few heavy tests (see .config/nextest.toml) are
# Tests are built once and run in shards. A few heavy tests (see .config/nextest.toml) are
# skipped on pull requests and run on pushes to main and nightly. The Windows jobs (D3D12 shaders
# and app startup) run on pull requests only when what they test changes (the GPU crate, the app,
# the viewport's GPU glue, the graphics dependencies, their scripts or this file); main and
@@ -14,26 +14,37 @@
# main that a passing merge-queue run already tested runs only the repository checks; a push
# that skipped the queue still runs everything. Pull requests run the reference tests when the
# command line (or anything it depends on) changes, and doc tests for the affected packages.
# Heavy checks wait for review: a pull request without the `approved-for-ci` label runs only
# Select checks, the format and repository checks and a quick compile. Adding the label (after
# review) runs everything above, and every later push to the PR does too. Pushes to main, merge
# groups, the nightly run and Run workflow are not pull requests and always run the full suite.
# GitHub cannot filter `labeled` events by label name, so any other label starts a run whose jobs
# are all skipped; it gets its own concurrency group (below) so it never cancels the real run.
# Cross-compilation is opt-in through Run workflow; release builds remain in release.yml.
# Trusted runs use both pools: pushes to main, merge-queue groups and pull requests from a branch
# of this repository (people with write access; fork pull requests never run on this machine).
# There, Select checks and ci run on the self-hosted light runner (`self-hosted-light`, quick
# jobs, so they never wait behind a build), and the repository checks, Clippy, the doc tests,
# the reference tests and all four test shards on the self-hosted runners (`self-hosted-fast`, warm
# build directories via scripts/ci/warm-target.sh). Fork PRs' Linux jobs run on the isolated, ephemeral
# `self-hosted-untrusted` runners (no warm caches) when the PR number ends in 7, 8 or 9 (the rest of
# the fork PRs, and everything else, run on GitHub's runners, which have far more free slots).
# The test archive is always built on GitHub's runners: the self-hosted machine has a newer
# glibc, so its binaries wouldn't start on ubuntu-24.04, while the archive runs there unchanged
# (same /home/runner/work checkout path). Fork pull requests, the nightly run and Run workflow
# use GitHub's runners only; the self-hosted runners refuse any other job.
# jobs, so they never wait behind a build). For pushes to main and the merge queue only, the
# repository checks, Clippy, the doc tests, the reference tests and all four test shards run on the
# self-hosted runners (`self-hosted-fast`, warm build directories via scripts/ci/warm-target.sh);
# a pull request runs them on GitHub's runners, so it never holds up the queue. Fork PRs' Linux
# jobs run on the isolated, ephemeral `self-hosted-untrusted` runners (no warm caches) when the
# PR number ends in 7, 8 or 9 (the rest of the fork PRs, and everything else, run on GitHub's
# runners, which have far more free slots).
# Where the test shards run on GitHub's runners, the test archive is built there too: the
# self-hosted machine has a newer glibc, so its binaries wouldn't start on ubuntu-24.04, while the
# archive runs there unchanged (same /home/runner/work checkout path). The self-hosted shards
# (main and the merge queue) skip the archive and build the tests in their warm build directories.
# Fork pull requests, the nightly run and Run workflow use GitHub's runners only; the self-hosted
# runners refuse any other job.
name: CI
on:
# Draft pull requests run no CI (see `changes`, `checks` and `ci`): it starts when the PR is
# marked ready for review, and on every push after that.
# marked ready for review, and on every push after that. `labeled` is there for approved-for-ci
# only (other labels are skipped, see the top of this file).
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
types: [opened, synchronize, reopened, ready_for_review, labeled]
# The merge queue: each queued group runs the full suite before it lands on main.
merge_group:
push:
@@ -53,7 +64,10 @@ permissions:
concurrency:
# A new PR push replaces the old run. Only one run per group may wait, so a busy main would
# cancel the waiting nightly (or a Run workflow) before it starts: they get groups of their own.
group: ci-${{ github.event_name }}-${{ github.ref }}
# A `labeled` event for any label but approved-for-ci does no work and must not cancel (or queue
# behind) the PR's real run, so it gets a group of its own; approved-for-ci replaces the
# unlabelled run with the full one.
group: ci-${{ github.event_name }}-${{ github.ref }}${{ github.event.action == 'labeled' && github.event.label.name != 'approved-for-ci' && format('-ignored-{0}', github.run_id) || '' }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
@@ -75,8 +89,9 @@ jobs:
changes:
name: Select checks
# Not on a draft: every other check needs this one, so they skip too.
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
# Not on a draft: every other check needs this one, so they skip too. Not on a label other than
# approved-for-ci either (the same skip, for the same reason).
if: (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event.action != 'labeled' || github.event.label.name == 'approved-for-ci')
# Trusted runs (see the top of this file) use the self-hosted runners.
runs-on: ${{ (github.event_name == 'merge_group' || (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)) && 'self-hosted-light' || 'ubuntu-24.04' }}
permissions:
@@ -89,12 +104,13 @@ jobs:
goldens: ${{ steps.paths.outputs.goldens }}
windows: ${{ steps.paths.outputs.windows }}
full: ${{ steps.paths.outputs.full }}
# Code changed on a pull request that has no approved-for-ci label yet: only Quick compile.
quick: ${{ steps.paths.outputs.quick }}
packages: ${{ steps.paths.outputs.packages }}
oracle: ${{ steps.paths.outputs.oracle }}
# Whether the other Linux jobs use the self-hosted runners too.
# Trusted self-hosted runs only: a fork PR's checks run on the untrusted pool and must never
# route the jobs after them to the trusted runners.
local: ${{ runner.environment == 'self-hosted' && !(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) }}
# The fast self-hosted runners are kept for main and the merge queue (the pre-merge gate);
# pull requests, same-repo ones included, take the GitHub-hosted or untrusted paths.
local: ${{ runner.environment == 'self-hosted' && github.event_name != 'pull_request' }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
@@ -107,6 +123,7 @@ jobs:
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PUSH_SHA: ${{ github.sha }}
REPO: ${{ github.repository }}
APPROVED: ${{ contains(github.event.pull_request.labels.*.name, 'approved-for-ci') }}
GH_TOKEN: ${{ github.token }}
run: |
native=false
@@ -183,6 +200,15 @@ jobs:
# The pull request fast lane: Windows and the reference tests run on main (and the nightly),
# not on every pull request push; a break there is fixed forward on main.
if [ "$EVENT_NAME" = pull_request ]; then windows=false; oracle=false; fi
# Until a maintainer adds approved-for-ci, a pull request gets a quick compile instead of
# Clippy, the tests, the doc tests and the goldens.
quick=false
if [ "$EVENT_NAME" = pull_request ] && [ "$APPROVED" != true ]; then
quick=$native
native=false
oracle=false
goldens=false
fi
{
echo "native=$native"
echo "kernel=$kernel"
@@ -190,13 +216,14 @@ jobs:
echo "windows=$windows"
echo "oracle=$oracle"
echo "full=$full"
echo "quick=$quick"
echo "packages=$packages"
} >> "$GITHUB_OUTPUT"
checks:
name: Format and repository checks
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: ${{ (github.event_name == 'merge_group' || (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository)) && 'self-hosted-fast' || 'ubuntu-24.04' }}
if: (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event.action != 'labeled' || github.event.label.name == 'approved-for-ci')
runs-on: ${{ (github.event_name == 'merge_group' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) && 'self-hosted-fast' || 'ubuntu-24.04' }}
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -234,6 +261,26 @@ jobs:
- name: Claim and triage bot tests
run: node --test scripts/claim/*.test.js scripts/triage/*.test.js
# Fast feedback for a pull request that has not been approved for the full CI yet.
quick-compile:
name: Quick compile
needs: changes
if: needs.changes.outputs.quick == 'true'
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-light' || 'ubuntu-24.04' }}
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Size parallel compiles to the job's memory
run: scripts/ci/build-jobs.sh
- uses: dtolnay/rust-toolchain@stable
- if: runner.environment == 'github-hosted'
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
with:
key: quick-compile
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Cargo check
run: cargo check --workspace --all-targets --locked
clippy:
name: Native Linux Clippy
needs: changes
@@ -268,7 +315,9 @@ jobs:
build-tests:
name: Build tests
needs: changes
if: needs.changes.outputs.native == 'true'
# The self-hosted shards (main and the merge queue) build from their warm build directories
# instead: downloading the 3 GB archive took them longer than building.
if: needs.changes.outputs.native == 'true' && needs.changes.outputs.local != 'true'
runs-on: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && (endsWith(format('{0}', github.event.pull_request.number), '7') || endsWith(format('{0}', github.event.pull_request.number), '8') || endsWith(format('{0}', github.event.pull_request.number), '9'))) && 'self-hosted-untrusted' || 'ubuntu-24.04' }}
# Fork builds compile inside gVisor on shared boxes; give them room to finish and upload.
timeout-minutes: ${{ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && (endsWith(format('{0}', github.event.pull_request.number), '7') || endsWith(format('{0}', github.event.pull_request.number), '8') || endsWith(format('{0}', github.event.pull_request.number), '9'))) && 90 || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) && 60 || 45 }}
@@ -315,6 +364,7 @@ jobs:
tests:
name: Native Linux tests (${{ matrix.shard }}/4)
needs: [changes, build-tests]
if: ${{ !cancelled() && needs.changes.outputs.native == 'true' && (needs.changes.outputs.local == 'true' || needs.build-tests.result == 'success') }}
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-fast' || ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && (endsWith(format('{0}', github.event.pull_request.number), '7') || endsWith(format('{0}', github.event.pull_request.number), '8') || endsWith(format('{0}', github.event.pull_request.number), '9'))) && 'self-hosted-untrusted' || 'ubuntu-24.04') }}
timeout-minutes: 60
strategy:
@@ -339,39 +389,63 @@ jobs:
- uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28
with:
tool: cargo-nextest
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
# On the self-hosted runners each shard builds the tests in its warm build directory (a few
# minutes for what changed); elsewhere it runs the archive the Build tests job made.
- name: Size parallel compiles to the job's memory
if: needs.changes.outputs.local == 'true'
run: scripts/ci/build-jobs.sh
- name: Warm build directory (self-hosted runner)
if: needs.changes.outputs.local == 'true'
run: scripts/ci/warm-target.sh build-tests
- if: needs.changes.outputs.local == 'true'
uses: dtolnay/rust-toolchain@stable
- if: needs.changes.outputs.local != 'true'
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: nextest-archive
- name: Run tests
env:
EVENT_NAME: ${{ github.event_name }}
GOLDENS: ${{ needs.changes.outputs.goldens }}
LOCAL: ${{ needs.changes.outputs.local }}
FULL: ${{ needs.changes.outputs.full }}
PACKAGES: ${{ needs.changes.outputs.packages }}
SHARD: ${{ matrix.shard }}
SHARDS: ${{ github.event_name == 'pull_request' && 2 || 4 }}
# The robustness golden seeds a pull request skips: each takes one to twenty minutes under
# CI load, together about four fifths of the goldens' time. Pushes to main, the merge
# queue and the nightly run every seed.
SLOW_SEEDS: 81|45|20|24|95|02|57|40|18|48|32|46|85|61|77|34|14
run: |
# Pull requests skip the heavy tests (default-filter in .config/nextest.toml).
all=()
# The boolean property tests draw new random inputs every run; they run only in the
# nightly property-tests job, so a known kernel bug cannot turn main red at random.
filter=()
if [ "$EVENT_NAME" != pull_request ]; then
all=(--ignore-default-filter -E 'not (package(solidcraft-kernel) & binary(boolean_props))')
# The boolean property tests draw new random inputs every run; they run only in the
# nightly property-tests job, so a known kernel bug cannot turn main red at random.
filter=(--ignore-default-filter -E 'not (package(solidcraft-kernel) & binary(boolean_props))')
elif [ "$GOLDENS" = true ]; then
# A pull request that can change a robustness golden (tests/robustness/goldens.json)
# also checks every seed but the slow ones, in the same run so they share its cores.
filter=(--ignore-default-filter -E "default() | (package(solidcraft-engine) & test(/^tests::robustness_goldens::/) & not test(/^tests::robustness_goldens::seed_($SLOW_SEEDS)\$/))")
fi
# Extract into ./target: tests that run a built binary (env!("CARGO_BIN_EXE_*")) have
# the build job's path, which is the same checkout path here.
cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \
--profile ci --partition "slice:$SHARD/$SHARDS" --no-tests=pass "${all[@]}"
- name: Robustness goldens (pull requests that change geometry)
# Every seed of tests/robustness/goldens.json (each seed is a test), split over the shards;
# pushes to main and the nightly run them in the step above.
if: github.event_name == 'pull_request' && needs.changes.outputs.goldens == 'true'
env:
SHARD: ${{ matrix.shard }}
SHARDS: ${{ github.event_name == 'pull_request' && 2 || 4 }}
run: |
if ! cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite \
--profile ci --ignore-default-filter --partition "slice:$SHARD/$SHARDS" --no-tests=pass \
-E 'package(solidcraft-engine) & test(/^tests::robustness_goldens::/)'; then
echo "::error::A robustness golden changed. If the new result is intended, re-record it in this pull request and say why:"
echo "::error::ROBUSTNESS_GOLDENS_BLESS=\"why it changed\" cargo test --profile ci -p solidcraft-engine --lib robustness_goldens:: (see docs/robustness.md)"
junit=${CARGO_TARGET_DIR:-target}/nextest/ci/junit.xml
# The warm build directory keeps the last run's junit.xml: never upload that one.
rm -f "$junit"
if [ "$LOCAL" = true ]; then
scope=(--workspace)
if [ "$FULL" != true ]; then scope=(); for p in $PACKAGES; do scope+=(-p "$p"); done; fi
run=(cargo nextest run "${scope[@]}" --cargo-profile ci --locked)
else
# Extract into ./target: tests that run a built binary (env!("CARGO_BIN_EXE_*")) have
# the build job's path, which is the same checkout path here.
run=(cargo nextest run --archive-file tests.tar.zst --workspace-remap . --extract-to . --extract-overwrite)
fi
if ! "${run[@]}" --profile ci --partition "slice:$SHARD/$SHARDS" --no-tests=pass "${filter[@]}"; then
# A failed golden gets the hint to re-record it (junit.xml lists the failures).
if [ -f "$junit" ] && tr '\n' ' ' < "$junit" | grep -q '<testcase name="tests::robustness_goldens::[^"]*"[^>]*>[[:space:]]*<failure'; then
echo "::error::A robustness golden changed. If the new result is intended, re-record it in this pull request and say why:"
echo "::error::ROBUSTNESS_GOLDENS_BLESS=\"why it changed\" cargo test --profile ci -p solidcraft-engine --lib robustness_goldens:: (see docs/robustness.md)"
fi
exit 1
fi
- name: Upload test timings
@@ -379,7 +453,7 @@ jobs:
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: junit-${{ matrix.shard }}
path: target/nextest/ci/junit.xml
path: ${{ env.CARGO_TARGET_DIR || 'target' }}/nextest/ci/junit.xml
retention-days: 7
action-matrix:
@@ -627,8 +701,11 @@ jobs:
# Preserve the existing check name for branch protection, including docs-only PRs.
ci:
needs: [changes, checks, clippy, build-tests, tests, doc-tests, oracle, windows-fxc, windows-startup]
if: always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft)
# On the skipped run another label starts, this job is named differently, so that skipped job
# can never stand in for the required `ci` check of the pull request's real run.
name: ${{ github.event.action == 'labeled' && github.event.label.name != 'approved-for-ci' && 'ci (ignored label)' || 'ci' }}
needs: [changes, checks, quick-compile, clippy, build-tests, tests, doc-tests, oracle, windows-fxc, windows-startup]
if: always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft) && (github.event.action != 'labeled' || github.event.label.name == 'approved-for-ci')
runs-on: ${{ needs.changes.outputs.local == 'true' && 'self-hosted-light' || 'ubuntu-24.04' }}
steps:
- name: Verify required checks
@@ -636,6 +713,9 @@ jobs:
CHANGES: ${{ needs.changes.result }}
CHECKS: ${{ needs.checks.result }}
NATIVE: ${{ needs.changes.outputs.native }}
QUICK: ${{ needs.changes.outputs.quick }}
QUICK_COMPILE: ${{ needs.quick-compile.result }}
LOCAL: ${{ needs.changes.outputs.local }}
WINDOWS: ${{ needs.changes.outputs.windows }}
ORACLE_RUN: ${{ needs.changes.outputs.oracle }}
CLIPPY: ${{ needs.clippy.result }}
@@ -648,9 +728,14 @@ jobs:
run: |
test "$CHANGES" = success
test "$CHECKS" = success
# An unapproved pull request: the heavy jobs are skipped on purpose.
if [ "$QUICK" = true ]; then
test "$QUICK_COMPILE" = success
fi
if [ "$NATIVE" = true ]; then
test "$CLIPPY" = success
test "$BUILD" = success
# The self-hosted test shards build their own tests (no Build tests job there).
[ "$BUILD" = success ] || { [ "$LOCAL" = true ] && [ "$BUILD" = skipped ]; }
test "$TESTS" = success
test "$DOCS" = success
fi
+1
View File
@@ -96,6 +96,7 @@ Editable drawing code, native projects and shipped exports are collected under `
| assets/locales/it.json | SolidCraft contributors | Original Italian translation of SolidCraft's English catalog; no commercial CAD text used | MIT OR Apache-2.0 |
| assets/locales/ja.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/locales/ko.json | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
| assets/locales/ms.json | A.Hakim (a-hakim) | Malay translation of the English message catalog | MIT OR Apache-2.0 |
| assets/locales/pl.json | cyryllo | Polish translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/ru.json | Alex Pro (del000) | Russian translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
| assets/locales/pt-BR.json | DuBearte | Brazilian Portuguese translation of the English catalog, contributed upstream | MIT OR Apache-2.0 |
+21 -3
View File
@@ -191,17 +191,17 @@ the same commit; otherwise it runs the full suite.
**CI shards the tests.** One job builds them into a cargo-nextest archive; two shards run on
pull requests and four during full validation. Every test runs in its own process: don't rely
on state left by another test.
Pull requests skip a few heavy tests (one to four minutes each), which run in full validation
Pull requests skip a few heavy tests (one to twelve minutes each), which run in full validation
(the merge queue, nightly, manual runs and pushes to `main` not already tested by the queue).
Check that validation after merging. They are listed in
`.config/nextest.toml` and the [testing page](docs/book/src/dev/testing.md):
- `solidcraft-engine`: `tests::hostile_params_never_panic`, everything in `tests::robustness` and
the seeds of `tests::robustness_goldens` (`seed_00` … `seed_95`; pull requests matching the workflow's geometry paths run all seeds),
the seeds of `tests::robustness_goldens` (`seed_00` … `seed_95`; pull requests matching the workflow's geometry paths run all but the slowest, `SLOW_SEEDS` in `ci.yml`),
`pattern_suppress_tests::enclosure_arc_extinction_keeps_a_real_sharp_inner_lip_after_resize_and_reopen`,
`pattern_suppress_tests::enclosure_datums_follow_wall_height_and_board_depth_with_material_proofs`,
`cmd::plastic::tests::enclosure_snap_fit_follows_resized_front_wall`, the `atomic_commands` contract tests
- `solidcraft-kernel`: `tests::boolean_identity_on_tangent_primitives`, and the `boolean_props` property tests
- `solidcraft-kernel`: `tests::boolean_identity_on_tangent_primitives`, `tspline::tests::hostile_tsplines_never_panic`, and the `boolean_props` property tests
(these run only in the nightly "Property tests" job: their inputs are random, so main skips them too)
- `solidcraft-ui-egui` scenarios: `acceptance_sensor_enclosure`
@@ -285,6 +285,10 @@ and a fresh CI run:
2. Branch from `main` and keep the PR to one topic.
3. Run `cargo xtask ci` before pushing. It runs fmt, the asset, licence and layer checks,
clippy (`-D warnings`), then the tests with the wasm build check alongside. A PR has to pass it.
Heavy CI runs after review: a pull request gets the formatting and repository checks and a
quick compile (`cargo check`) when you push. Once a maintainer has reviewed it they add the
`approved-for-ci` label, which runs the full CI (build, tests, goldens, Clippy); every push
after that runs the full set again.
4. Reference the issue (`Fixes #12`). In the PR, say what you tested and how. For geometry work, include the measured numbers (and
the expected ones).
@@ -359,6 +363,20 @@ In order of how much they'd help users:
3. **Drawings, CAM and mesh tools.** These are out of scope for now (see
[ROADMAP.md](ROADMAP.md)), but anyone who wants to own one is welcome.
## Updating the generated feature list
After adding or renaming commands, run `cargo xtask features`. The registry is the
source of truth for `docs/features.md`; do not hand-edit the generated rows.
The generator sorts commands by ID and omits global and per-section counters,
so additions to unrelated areas no longer rewrite shared summary lines.
If two branches still conflict in this file (for example, adjacent additions in
the same area), first resolve the command registry and source conflicts, then run
`cargo xtask features` again and stage the regenerated file. Do not use Git's
`union` merge driver: it can silently retain stale or duplicate command rows.
The generator tests cover stable ordering and additions that leave existing lines
unchanged. The initial switch to sorted rows is a one-time larger diff.
## Licence
By contributing you agree that your contribution is licensed as MIT OR Apache-2.0, like the rest
Generated
+17 -17
View File
@@ -4078,7 +4078,7 @@ dependencies = [
[[package]]
name = "solidcraft"
version = "0.16.1"
version = "0.16.2"
dependencies = [
"block2 0.6.2",
"eframe",
@@ -4109,11 +4109,11 @@ dependencies = [
[[package]]
name = "solidcraft-3dx-client"
version = "0.16.1"
version = "0.16.2"
[[package]]
name = "solidcraft-cli"
version = "0.16.1"
version = "0.16.2"
dependencies = [
"image",
"serde_json",
@@ -4124,7 +4124,7 @@ dependencies = [
[[package]]
name = "solidcraft-derive"
version = "0.16.1"
version = "0.16.2"
dependencies = [
"proc-macro2",
"quote",
@@ -4133,7 +4133,7 @@ dependencies = [
[[package]]
name = "solidcraft-doc"
version = "0.16.1"
version = "0.16.2"
dependencies = [
"log",
"serde",
@@ -4150,7 +4150,7 @@ dependencies = [
[[package]]
name = "solidcraft-engine"
version = "0.16.1"
version = "0.16.2"
dependencies = [
"log",
"serde",
@@ -4167,7 +4167,7 @@ dependencies = [
[[package]]
name = "solidcraft-geom"
version = "0.16.1"
version = "0.16.2"
dependencies = [
"serde",
"serde_json",
@@ -4176,7 +4176,7 @@ dependencies = [
[[package]]
name = "solidcraft-gpu"
version = "0.16.1"
version = "0.16.2"
dependencies = [
"log",
"naga",
@@ -4193,7 +4193,7 @@ dependencies = [
[[package]]
name = "solidcraft-io"
version = "0.16.1"
version = "0.16.2"
dependencies = [
"cadmpeg-core",
"cadmpeg-ir",
@@ -4213,7 +4213,7 @@ dependencies = [
[[package]]
name = "solidcraft-kernel"
version = "0.16.1"
version = "0.16.2"
dependencies = [
"log",
"proptest",
@@ -4233,7 +4233,7 @@ dependencies = [
[[package]]
name = "solidcraft-mcp"
version = "0.16.1"
version = "0.16.2"
dependencies = [
"serde_json",
"solidcraft-engine",
@@ -4242,7 +4242,7 @@ dependencies = [
[[package]]
name = "solidcraft-render"
version = "0.16.1"
version = "0.16.2"
dependencies = [
"image",
"moxcms",
@@ -4256,7 +4256,7 @@ dependencies = [
[[package]]
name = "solidcraft-sketch"
version = "0.16.1"
version = "0.16.2"
dependencies = [
"serde",
"serde_json",
@@ -4267,11 +4267,11 @@ dependencies = [
[[package]]
name = "solidcraft-space-mouse"
version = "0.16.1"
version = "0.16.2"
[[package]]
name = "solidcraft-ui-egui"
version = "0.16.1"
version = "0.16.2"
dependencies = [
"arboard",
"egui",
@@ -4296,7 +4296,7 @@ dependencies = [
[[package]]
name = "solidcraft-web"
version = "0.16.1"
version = "0.16.2"
dependencies = [
"eframe",
"egui",
@@ -5826,7 +5826,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.16.1"
version = "0.16.2"
dependencies = [
"serde_json",
"sha2 0.10.9",
+4 -1
View File
@@ -5,7 +5,7 @@ members = ["crates/*", "apps/*", "xtask"]
exclude = ["vendor"]
[workspace.package]
version = "0.16.1"
version = "0.16.2"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -123,6 +123,9 @@ incremental = false
[profile.ci.package."*"]
opt-level = 2
# No line tables for dependencies: they were over a third of the test archive every CI shard
# downloads. Our own crates keep theirs, so a backtrace still names file:line in them.
debug = false
[profile.release]
opt-level = 3
+1 -1
View File
@@ -311,7 +311,7 @@ configuration and an example session: [docs/mcp.md](docs/mcp.md).
## Contributing
Coming from Autodesk Fusion? SolidCraft uses familiar parametric CAD workflows: constrained
SolidCraft uses a familiar parametric CAD workflow: constrained
sketches, solid features, an editable timeline, parameters and assemblies. See the
[roadmap](ROADMAP.md) for supported operations and current limitations.
+5 -1
View File
@@ -14,6 +14,10 @@ features → parametric timeline) as an open, pure-Rust, agent-drivable applicat
| Gates | `cargo xtask ci`: fmt, clippy -D warnings, tests, asset attribution, layering, wasm32 build — green |
| Time to a useful alpha (M0–M6) | ≈ **82 agent hours** remain (sum of the M1–M6 estimates below) |
Optional GPU viewport ambient occlusion is available in View settings and Graphics preferences,
with persistent settings, Performance/Quality graphics presets (Quality combines AO, Fine tessellation and adaptive 2× supersampling; manual combinations show Custom), and the
`view.ambient_occlusion` and `view.graphics_preset` commands. See [display settings](docs/ambient-occlusion.md).
## Scope (owner decision, 2026-10-07)
The issue batch adds preservation of blind pockets in through cuts, associative projected
@@ -55,7 +59,7 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings.
| 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 |
| M8 | Surface workspace (#772). Done: Patch (sketch regions; edge loops filled G0, plus `crown`), Stitch, Thicken (planes and cylinders), Extend (straight edges of planar surfaces), Trim (by a plane or a face), Delete Face (healing), Boundary Fill, zebra/draft/curvature-map analysis, Surface Extrude/Revolve/Sweep/Loft from open and closed curves, Offset, Unstitch and Reverse Normal; the continuity-driven core (`kernel/src/bridge.rs`, G0–G3 Hermite rows) with **Blend** (`surface.blend`, #764), **Match** (`surface.match`), **Boundary** (`surface.boundary`, #763 — two sketch curves exact with G0–G3, three or four as the flat patch of their loop, and curve networks: Coons and Gordon surfaces through sketch, 3D-sketch or edge curves, a point for a three-sided side, G1/G2 against the faces along one direction's edges, joined into their body on request; G1/G2 on two sides that meet at a corner waits for the Gregory-type corner), **Fair** (`surface.fair`, CV fairing), **continuity analysis** (`inspect.continuity`, #770; a toolbar tool with edge markers and a legend, and curvature combs along face isocurves) and a multi-section **loft** with per-end continuity and crown. Also in the Class-A pass: G1…G4 blend curves in the sketch, chord and variable-radius fillets on curved edges (with honest refusals where the fit cannot hold), and a G2 corner patch where filleted edges meet (kernel; the command refuses until the patch's sections can be sewn to the blends' contacts). Left: bias/form-factor fillet sections (design recorded), a Gregory-type corner, surface Fillet between separate surface bodies (#769), Ruled (#762), Untrim (#761), curved-face Extend (#760), loft guide rails (#201; sweep guide rails and two-rail sweeps are done, #771), and every remaining #757–#772 item | deferred | — |
| M9 | Components and assemblies: New Component / Create Components from Bodies, active component, per-component origin/bodies/sketches/timeline entries, occurrences with transforms, Copy/Paste vs Paste New, nested Browser tree with visibility and isolate; Ground, Move/Copy of occurrences, joints (rigid, revolute, slider, cylindrical, pin-slot, planar, ball) by joint origins with limits and Drive Joints, closed kinematic loops (four-bar, slider-crank: the loop's other joints follow the driven one on its branch, and driving stops where a loop can no longer close), cams (`joint.cam`: a revolute cam drives a slider or revolute follower along a closed sketch profile or the cam's edges, knife, roller or flat followers, worked out again when the profile changes; drives, studies, closed loops, contact sets), As-built Joint, Rigid Group, Contact Sets (driving and motion studies stop where bodies in a set would pass through each other), Motion Study (joint values keyed along a timeline, playback, export of occurrence positions as JSON/CSV), motion analysis (joint values and a point's path charted over a study as value, velocity and acceleration; the parts that collide along a study, with the steps where they do), Exploded views (automatic or by hand, shown without changing placements); interference, per-component physical properties; STEP with assembly structure; URDF export for robot simulators (links with meshes, mass and inertia; joint chains whose values match Drive Joints; docs/urdf.md). Physical properties now measure material-aware component/occurrence placements, descendants and repeated instances, including pending moves and body reassignment offsets; contact geometry and automatic exploded centres respect those offsets. Groundwork done: component tree, occurrences with transforms (instances, Paste New), world placement, active component as authoring frame, ground, Capture/Revert Position, moving bodies and sketches. UI: ASSEMBLE panel dialogs for New Component, Joint (snaps on faces, edges, circles and vertices; animated motion preview; offsets, flip, limits), As-built Joint, Joint Origin, Rigid Group, Drive Joints (live slider), Motion Link, Cam and Interference (overlaps shown red); joints in the timeline and the Browser | in progress | 32 |
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs in Fusion's layout with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature; corner reliefs (notch, round, rectangular, tear) chosen by the rule; Convert to Sheet Metal from a solid or shelled body by its bend edges (flat faces, sharp bends) | in progress | 36 |
| M10 | Sheet metal: flange (base, edge, contour), hem, unfold/refold, convert, flat pattern + DXF, rules: done. PLASTIC enclosure subset: Boss, Rib, Web, Lip/Groove, Snap Fit, Rest, Plastic Rules (library, edit, assign; rule draft and clearance as feature defaults): done, tested against hand-computed geometry. UI: SHEET METAL and PLASTIC tabs with dialogs (live preview, Edit Feature, pre-selection) for Flange, Hem, Unfold/Refold, Convert, Sheet Metal Rules, Create Flat Pattern (flat view with bend lines, bend table CSV/JSON and DXF export with exact circle/arc/line cutouts), Boss, Lip/Groove, Snap Fit, Rest, Manage and Assign Plastic Rules; Fold (bend along a sketch line); flanges meeting at a corner closed at any angle with the rule's gap; edge flanges with their own angle and height per edge in one feature; corner reliefs (notch, round, rectangular, tear) chosen by the rule; Convert to Sheet Metal from a solid or shelled body by its bend edges (flat faces, sharp bends) | in progress | 36 |
| M11 | 2D drawings | deferred | — |
| 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 |
+1 -1
View File
@@ -350,7 +350,7 @@ fn actions(s: &Session) -> Vec<Action> {
// The surface's normal at `p` (either way round): exact on analytic faces.
let normal_at = |f: u32, p: Vec3| -> Option<Vec3> {
match face_surface(&b.body, f as usize) {
Some(FaceSurface::Plane { normal, .. }) => Some(normal),
Some(FaceSurface::Plane { normal, .. }) => Some(*normal),
Some(FaceSurface::Cylinder { origin, axis, .. }) => {
let v = p - origin;
(v - axis * v.dot(axis)).normalized()
+1 -1
View File
@@ -27,7 +27,7 @@ const KNOWN: [(&str, &str, &str); 2] = [
(
"m0-revolve-primitives",
"Body1 bbox",
"partial revolves turn toward the sketch normal since ac43365 (as Fusion's do); M0 turned them the other way",
"partial revolves turn toward the sketch normal since ac43365 (as most parametric CAD tools do); M0 turned them the other way",
),
("components-arm", "body_count", "Measure counted only the root component's bodies then; it measures every placed body now"),
];
+2 -1
View File
@@ -179,7 +179,8 @@ fn services(cc: &eframe::CreationContext<'_>) -> Services {
}
}
let all: Vec<&str> = all.iter().map(String::as_str).collect();
let future = file_parent.clone().add_filter(label, &all).pick_file();
// Any other file attaches by reference, so the list is not the only choice.
let future = file_parent.clone().add_filter(label, &all).add_filter("All files", &["*"]).pick_file();
Box::pin(async move { future.await.map(|p| p.path().to_string_lossy().into_owned()) })
})),
pick_program: Some(Box::new(move || {
+24 -18
View File
@@ -362,25 +362,31 @@ fn reader(ctx: Context, shared: Arc<Mutex<Reading>>) {
fn start_driver_client(ctx: &Context, shared: &Arc<Mutex<Reading>>) {
use solidcraft_3dx_client::Event;
let (ctx, shared) = (ctx.clone(), Arc::clone(shared));
let started = solidcraft_3dx_client::start("SolidCraft", move |event| match event {
Event::DeviceAdded => DRIVER_CONNECTED.store(true, Ordering::Relaxed),
Event::DeviceRemoved => {
DRIVER_CONNECTED.store(false, Ordering::Relaxed);
reset(&ctx, &shared);
}
Event::Axes { translation, rotation } => {
if let Ok(mut reading) = shared.lock() {
let now = Instant::now();
reading.axes = Axes { translation, rotation };
reading.at = [now; 6];
ctx.request_repaint();
let started = solidcraft_3dx_client::start(
"SolidCraft",
move |event| match event {
Event::DeviceAdded => DRIVER_CONNECTED.store(true, Ordering::Relaxed),
Event::DeviceRemoved => {
DRIVER_CONNECTED.store(false, Ordering::Relaxed);
reset(&ctx, &shared);
}
}
Event::Buttons(_) => {}
});
match started {
Ok(()) => DRIVER_REGISTERED.store(true, Ordering::Relaxed),
Err(error) => log::info!("3D mouse: no driver client: {error}"),
Event::Axes { translation, rotation } => {
if let Ok(mut reading) = shared.lock() {
let now = Instant::now();
reading.axes = Axes { translation, rotation };
reading.at = [now; 6];
ctx.request_repaint();
}
}
Event::Buttons(_) => {}
},
|connected| match connected {
Ok(()) => DRIVER_REGISTERED.store(true, Ordering::Relaxed),
Err(error) => log::info!("3D mouse: no driver client: {error}"),
},
);
if let Err(error) = started {
log::info!("3D mouse: no driver client: {error}");
}
}
+9
View File
@@ -89,6 +89,15 @@ pub(crate) fn pin_icon(p: &egui::Painter, r: Rect, on: bool, col: Color32) {
p.line_segment([at(0.0, -2.0), at(0.0, 7.0)], stroke);
}
/// A breadcrumb separator ("Sketch › Create"): a thin chevron about 3 by 6 px, centred on `c`.
/// Drawn rather than typed, because a font's "›" sits on the baseline in some languages
/// (Japanese), where it reads as a comma.
pub(crate) fn breadcrumb(p: &egui::Painter, c: egui::Pos2, col: Color32) {
let s = Stroke::new(1.0, col);
p.line_segment([pos2(c.x - 1.5, c.y - 3.0), pos2(c.x + 1.5, c.y)], s);
p.line_segment([pos2(c.x + 1.5, c.y), pos2(c.x - 1.5, c.y + 3.0)], s);
}
pub(crate) fn caret(p: &egui::Painter, c: egui::Pos2, col: Color32) {
p.add(egui::Shape::convex_polygon(vec![pos2(c.x - 3.5, c.y - 2.0), pos2(c.x + 3.5, c.y - 2.0), pos2(c.x, c.y + 2.5)], col, Stroke::NONE));
}
+2
View File
@@ -1337,6 +1337,8 @@
"ui.save_mesh_pick_bodies": "Vyberte tělesa k uložení",
"ui.save_mesh_temp_failed": "Nelze vytvořit {folder}: {error}",
"ui.saved_to": "Uloženo {path}",
"ui.sbox_pin": "Připnout nahoru ve vyhledávání S",
"ui.sbox_unpin": "Odepnout",
"ui.scale": "Měřítko",
"ui.section_analysis": "Analýza řezu",
"ui.select": "Vybrat",
+11
View File
@@ -425,8 +425,10 @@
"cmd.timeline_roll_to": "Verlaufsmarkierung verschieben",
"cmd.timeline_rollback": "Zeitleistenmarkierung verschieben",
"cmd.timeline_suppress": "Merkmal unterdrücken",
"cmd.view_ambient_occlusion": "Umgebungsverdeckung",
"cmd.view_apply": "Benannte Ansicht wiederherstellen",
"cmd.view_delete": "Benannte Ansicht löschen",
"cmd.view_graphics_preset": "Grafikprofil",
"cmd.view_list": "Benannte Ansichten auflisten",
"cmd.view_navigate": "Ansicht navigieren",
"cmd.view_rename": "Benannte Ansicht umbenennen",
@@ -756,6 +758,8 @@
"ui.ai_toolbar_button_note": "Die KI-Schaltfläche am rechten Ende der Werkzeugleiste. Strg+Umschalt+A und das Ansicht-Menü öffnen das Panel weiterhin.",
"ui.ai_undo_tip": "Alles, was der Agent für diese Nachricht geändert hat, in einem Schritt rückgängig machen",
"ui.all": "Alle",
"ui.ambient_occlusion": "Umgebungsverdeckung",
"ui.ambient_occlusion_help": "Sanfte Schattierung an Kontaktstellen und in Vertiefungen undurchsichtiger Flächen. Erfordert die GPU-Ansicht.",
"ui.analysis": "Analyse",
"ui.angle": "Winkel",
"ui.angle_decimals": "Winkel-Dezimalstellen",
@@ -910,6 +914,11 @@
"ui.fully_constrained": "Vollständig bestimmt",
"ui.general": "Allgemein",
"ui.graphics": "Grafik",
"ui.graphics_custom": "Benutzerdefiniert",
"ui.graphics_performance": "Leistung",
"ui.graphics_preset": "Grafikprofil",
"ui.graphics_preset_help": "Qualität: Umgebungsverdeckung, feine Anzeigequalität und adaptives 2× Supersampling. Leistung: keine Umgebungsverdeckung, normale Anzeigequalität und kein Supersampling. Manuelle Änderungen: Benutzerdefiniert. MSAA bleibt unverändert.",
"ui.graphics_quality": "Qualität",
"ui.groupSelected": "Auswahl gruppieren",
"ui.heading_assemble": "ZUSAMMENBAU",
"ui.heading_box": "BLOCK",
@@ -1118,6 +1127,8 @@
"ui.save_mesh_pick_bodies": "Wähle die Körper zum Speichern aus",
"ui.save_mesh_temp_failed": "{folder} konnte nicht angelegt werden: {error}",
"ui.saved_to": "Gespeichert: {path}",
"ui.sbox_pin": "Oben im S-Feld anheften",
"ui.sbox_unpin": "Lösen",
"ui.scale": "Skalieren",
"ui.second_rail": "Zweite Leitkurve",
"ui.section_analysis": "Querschnittsanalyse",
+22 -2
View File
@@ -350,6 +350,7 @@
"cmd.component_move_sketches": "Move Sketches to Component",
"cmd.component_paste_new": "Paste New",
"cmd.component_properties": "Component Physical Properties",
"cmd.component_relink": "Relink Source",
"cmd.component_rename": "Rename Component",
"cmd.component_revert_position": "Revert Position",
"cmd.component_unlock": "Unlock Contents",
@@ -632,8 +633,10 @@
"cmd.timeline_roll_to": "Roll History Marker",
"cmd.timeline_rollback": "Move Timeline Marker",
"cmd.timeline_suppress": "Suppress Feature",
"cmd.view_ambient_occlusion": "Ambient Occlusion",
"cmd.view_apply": "Restore Named View",
"cmd.view_delete": "Delete Named View",
"cmd.view_graphics_preset": "Graphics Preset",
"cmd.view_list": "List Named Views",
"cmd.view_navigate": "Navigate View",
"cmd.view_rename": "Rename Named View",
@@ -857,7 +860,7 @@
"ui.agent_chat_approval": "Allow the agent to do this?",
"ui.agent_chat_attach_view": "Attach view",
"ui.agent_chat_attach_view_tip": "Send a picture of the current view with the message",
"ui.agent_chat_attachable": "Pictures, text and CAD files",
"ui.agent_chat_attachable": "Pictures, PDFs, text and CAD files",
"ui.agent_chat_auth_agent": "Sign in with {name}",
"ui.agent_chat_auth_env": "Set {vars} in your environment and restart SolidCraft.",
"ui.agent_chat_auth_needed": "The agent asks you to sign in. You sign in with the agent itself; SolidCraft never sees your password or keys.",
@@ -889,7 +892,6 @@
"ui.agent_chat_file_error": "{name}: {error}",
"ui.agent_chat_file_heic": "{name}: HEIC pictures can't be read here; export it as JPEG or PNG first",
"ui.agent_chat_file_importing": "Importing into the design…",
"ui.agent_chat_file_kinds": "{name}: pictures (PNG, JPEG, GIF, WebP, BMP, TIFF), text files (SVG too) and CAD files (STEP, IGES, FreeCAD, Rhino, STL, 3MF, OBJ, glTF) can be attached",
"ui.agent_chat_file_loading": "Reading the file…",
"ui.agent_chat_file_not_a_file": "{name}: not a file",
"ui.agent_chat_file_not_a_picture": "{name}: not a picture",
@@ -1036,6 +1038,8 @@
"ui.ai_toolbar_button_note": "The AI button at the right end of the toolbar. Ctrl+Shift+A and the View menu still open the panel.",
"ui.ai_undo_tip": "Undo everything the agent changed for this message, as one step",
"ui.all": "All",
"ui.ambient_occlusion": "Ambient occlusion",
"ui.ambient_occlusion_help": "Soft shading where opaque surfaces meet. Requires the GPU viewport.",
"ui.analysis": "Analysis",
"ui.angle": "Angle",
"ui.angle_decimals": "Angle decimals",
@@ -1196,6 +1200,11 @@
"ui.fully_constrained": "Fully constrained",
"ui.general": "General",
"ui.graphics": "Graphics",
"ui.graphics_custom": "Custom",
"ui.graphics_performance": "Performance",
"ui.graphics_preset": "Graphics Preset",
"ui.graphics_preset_help": "Quality: ambient occlusion, Fine display quality and adaptive 2× supersampling. Performance: no ambient occlusion, Normal display quality and supersampling off. Manual changes show Custom. MSAA stays unchanged.",
"ui.graphics_quality": "Quality",
"ui.grid_follows_view": "Grid follows the view",
"ui.grid_follows_view_hint": "Side views show the grid on the upright plane facing them instead of the ground",
"ui.ground_shadow": "Ground shadow",
@@ -1337,6 +1346,10 @@
"ui.newSketchGroup": "New Sketch Group",
"ui.new_body": "New Body",
"ui.new_design": "New Design",
"ui.new_extrude_distance": "New extrude distance",
"ui.new_extrude_distance_fixed": "Fixed (10 mm)",
"ui.new_extrude_distance_last_used": "Last used",
"ui.new_extrude_distance_tip": "The distance a new Extrude starts with. Editing an existing extrude keeps its own.",
"ui.no_slicer_found": "No slicer found",
"ui.normal": "Normal",
"ui.normalConstruction": "Normal / Construction",
@@ -1400,6 +1413,11 @@
"ui.rectangle": "Rectangle",
"ui.rectangular_pattern": "Rectangular Pattern",
"ui.redo": "Redo",
"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.",
"ui.relink_select_one": "Select one linked component or one of its instances first.",
"ui.relink_source_all": "Relink Source (All Instances)…",
"ui.removeFromToolbar": "Remove from Toolbar",
"ui.removeSection": "Remove Section",
"ui.render": "Render",
@@ -1423,6 +1441,8 @@
"ui.save_mesh_pick_bodies": "Select the bodies to save",
"ui.save_mesh_temp_failed": "Couldn't make {folder}: {error}",
"ui.saved_to": "Saved {path}",
"ui.sbox_pin": "Pin to the top of the S box",
"ui.sbox_unpin": "Unpin",
"ui.scale": "Scale",
"ui.second_rail": "Second Rail",
"ui.section_analysis": "Section Analysis",
+2
View File
@@ -1372,6 +1372,8 @@
"ui.save_mesh_pick_bodies": "Selecciona los cuerpos que se van a guardar",
"ui.save_mesh_temp_failed": "No se ha podido crear {folder}: {error}",
"ui.saved_to": "Guardado en {path}",
"ui.sbox_pin": "Fijar arriba en la búsqueda S",
"ui.sbox_unpin": "Desfijar",
"ui.scale": "Escala",
"ui.second_rail": "Segunda línea guía",
"ui.section_analysis": "Análisis de sección",
+11
View File
@@ -628,8 +628,10 @@
"cmd.timeline_roll_to": "Déplacer le marqueur d'historique",
"cmd.timeline_rollback": "Déplacer le marqueur de chronologie",
"cmd.timeline_suppress": "Désactiver la fonction",
"cmd.view_ambient_occlusion": "Occlusion ambiante",
"cmd.view_apply": "Restaurer la vue nommée",
"cmd.view_delete": "Supprimer la vue nommée",
"cmd.view_graphics_preset": "Préréglage graphique",
"cmd.view_list": "Lister les vues nommées",
"cmd.view_navigate": "Naviguer dans la vue",
"cmd.view_rename": "Renommer la vue nommée",
@@ -995,6 +997,8 @@
"ui.ai_toolbar_button_note": "Le bouton IA à l'extrémité droite de la barre d'outils. Ctrl+Shift+A et le menu Vue ouvrent toujours le panneau.",
"ui.ai_undo_tip": "Annuler tout ce que l'agent a modifié pour ce message, en une seule étape",
"ui.all": "Tout",
"ui.ambient_occlusion": "Occlusion ambiante",
"ui.ambient_occlusion_help": "Ombrage doux aux jonctions des surfaces opaques. Nécessite la vue GPU.",
"ui.analysis": "Analyse",
"ui.angle": "Angle",
"ui.angle_decimals": "Décimales des angles",
@@ -1155,6 +1159,11 @@
"ui.fully_constrained": "Entièrement contrainte",
"ui.general": "Général",
"ui.graphics": "Graphisme",
"ui.graphics_custom": "Personnalisé",
"ui.graphics_performance": "Performances",
"ui.graphics_preset": "Préréglage graphique",
"ui.graphics_preset_help": "Qualité : occlusion ambiante, tessellation fine et suréchantillonnage adaptatif 2×. Performances : sans occlusion ambiante, tessellation normale et sans suréchantillonnage. Les réglages manuels affichent Personnalisé. MSAA reste inchangé.",
"ui.graphics_quality": "Qualité",
"ui.grid_follows_view": "La grille suit la vue",
"ui.grid_follows_view_hint": "Dans les vues de côté, la grille s'affiche sur le plan vertical qui leur fait face plutôt que sur le sol",
"ui.ground_shadow": "Ombre au sol",
@@ -1379,6 +1388,8 @@
"ui.save_mesh_pick_bodies": "Sélectionner les corps à enregistrer",
"ui.save_mesh_temp_failed": "Impossible de créer {folder} : {error}",
"ui.saved_to": "Enregistré : {path}",
"ui.sbox_pin": "Épingler en haut de la boîte S",
"ui.sbox_unpin": "Désépingler",
"ui.scale": "Échelle",
"ui.second_rail": "Second rail",
"ui.section_analysis": "Analyse de coupe",
+2
View File
@@ -1337,6 +1337,8 @@
"ui.save_mesh_pick_bodies": "Selecciona os corpos que queres gardar",
"ui.save_mesh_temp_failed": "Non se puido crear {folder}: {error}",
"ui.saved_to": "Gardado {path}",
"ui.sbox_pin": "Fixar arriba na caixa S",
"ui.sbox_unpin": "Desfixar",
"ui.scale": "Escala",
"ui.section_analysis": "Análise de sección",
"ui.select": "Seleccionar",
+8
View File
@@ -345,6 +345,7 @@
"cmd.component_move_sketches": "Sposta schizzi nel componente",
"cmd.component_paste_new": "Incolla come nuovo",
"cmd.component_properties": "Proprietà fisiche del componente",
"cmd.component_relink": "Ricollega sorgente",
"cmd.component_rename": "Rinomina componente",
"cmd.component_revert_position": "Ripristina posizione",
"cmd.component_unlock": "Sblocca contenuto",
@@ -1342,6 +1343,11 @@
"ui.rectangle": "Rettangolo",
"ui.rectangular_pattern": "Ripetizione rettangolare",
"ui.redo": "Ripeti",
"ui.relink_design_changed": "L'assieme è cambiato durante la scelta del sorgente. Avvia di nuovo Ricollega sorgente.",
"ui.relink_done": "Sorgente ricollegato per tutte le istanze.",
"ui.relink_not_linked": "Questo componente non è collegato a un progetto sorgente.",
"ui.relink_select_one": "Seleziona un componente collegato o una delle sue istanze.",
"ui.relink_source_all": "Ricollega sorgente (tutte le istanze)…",
"ui.removeFromToolbar": "Rimuovi dalla barra degli strumenti",
"ui.removeSection": "Rimuovi sezione",
"ui.render": "Rendering",
@@ -1363,6 +1369,8 @@
"ui.save_mesh_pick_bodies": "Seleziona i corpi da salvare",
"ui.save_mesh_temp_failed": "Impossibile creare {folder}: {error}",
"ui.saved_to": "Salvato {path}",
"ui.sbox_pin": "Fissa in cima al riquadro S",
"ui.sbox_unpin": "Sblocca",
"ui.scale": "Scala",
"ui.second_rail": "Seconda guida",
"ui.section_analysis": "Analisi in sezione",
+22 -2
View File
@@ -350,6 +350,7 @@
"cmd.component_move_sketches": "スケッチをコンポーネントに移動",
"cmd.component_paste_new": "新規として貼り付け",
"cmd.component_properties": "コンポーネントの物理的プロパティ",
"cmd.component_relink": "参照元を再リンク",
"cmd.component_rename": "コンポーネントの名前を変更",
"cmd.component_revert_position": "位置を元に戻す",
"cmd.component_unlock": "内容のロックを解除",
@@ -632,8 +633,10 @@
"cmd.timeline_roll_to": "履歴マーカーを移動",
"cmd.timeline_rollback": "タイムライン マーカーを移動",
"cmd.timeline_suppress": "フィーチャーを抑制",
"cmd.view_ambient_occlusion": "アンビエントオクルージョン",
"cmd.view_apply": "名前付きビューを復元",
"cmd.view_delete": "名前付きビューを削除",
"cmd.view_graphics_preset": "グラフィックスプリセット",
"cmd.view_list": "名前付きビューを一覧表示",
"cmd.view_navigate": "ビューナビゲーション",
"cmd.view_rename": "名前付きビューの名前を変更",
@@ -857,7 +860,7 @@
"ui.agent_chat_approval": "エージェントにこの操作を許可しますか?",
"ui.agent_chat_attach_view": "ビューを添付",
"ui.agent_chat_attach_view_tip": "現在のビューの画像をメッセージと一緒に送ります",
"ui.agent_chat_attachable": "画像・テキスト・CAD ファイル",
"ui.agent_chat_attachable": "画像・PDF・テキスト・CAD ファイル",
"ui.agent_chat_auth_agent": "{name} でサインイン",
"ui.agent_chat_auth_env": "環境変数 {vars} を設定して SolidCraft を再起動してください。",
"ui.agent_chat_auth_needed": "エージェントがサインインを求めています。サインインはエージェント自身で行います。SolidCraft がパスワードやキーを見ることはありません。",
@@ -889,7 +892,6 @@
"ui.agent_chat_file_error": "{name}: {error}",
"ui.agent_chat_file_heic": "{name}: HEIC 画像はここでは読めません。先に JPEG か PNG に書き出してください",
"ui.agent_chat_file_importing": "デザインにインポート中…",
"ui.agent_chat_file_kinds": "{name}: 添付できるのは画像 (PNG、JPEG、GIF、WebP、BMP、TIFF)、テキストファイル (SVG も)、CAD ファイル (STEP、IGES、FreeCAD、Rhino、STL、3MF、OBJ、glTF) です",
"ui.agent_chat_file_loading": "ファイルを読み込み中…",
"ui.agent_chat_file_not_a_file": "{name}: ファイルではありません",
"ui.agent_chat_file_not_a_picture": "{name}: 画像ではありません",
@@ -1036,6 +1038,8 @@
"ui.ai_toolbar_button_note": "ツールバー右端の AI ボタン。Ctrl+Shift+A と表示メニューからも引き続きパネルを開けます。",
"ui.ai_undo_tip": "このメッセージでエージェントが行った変更を、1回でまとめて元に戻します",
"ui.all": "すべて",
"ui.ambient_occlusion": "アンビエントオクルージョン",
"ui.ambient_occlusion_help": "不透明な面が接する部分に柔らかい陰影を加えます。GPUビューポートが必要です。",
"ui.analysis": "解析",
"ui.angle": "角度",
"ui.angle_decimals": "角度の小数点以下の桁数",
@@ -1196,6 +1200,11 @@
"ui.fully_constrained": "完全拘束",
"ui.general": "一般",
"ui.graphics": "グラフィックス",
"ui.graphics_custom": "カスタム",
"ui.graphics_performance": "パフォーマンス",
"ui.graphics_preset": "グラフィックスプリセット",
"ui.graphics_preset_help": "品質:アンビエントオクルージョン、細かい表示品質、適応型2×スーパーサンプリング。パフォーマンス:AOなし、標準の表示品質、スーパーサンプリングなし。手動変更はカスタムと表示されます。MSAAは変更されません。",
"ui.graphics_quality": "品質",
"ui.grid_follows_view": "グリッドをビューに追従させる",
"ui.grid_follows_view_hint": "側面ビューでは、グリッドを地面ではなく、そのビューに向いた垂直平面に表示します",
"ui.ground_shadow": "地面の影",
@@ -1337,6 +1346,10 @@
"ui.newSketchGroup": "新しいスケッチグループ",
"ui.new_body": "新規ボディ",
"ui.new_design": "新規デザイン",
"ui.new_extrude_distance": "新規押し出しの距離",
"ui.new_extrude_distance_fixed": "固定 (10 mm)",
"ui.new_extrude_distance_last_used": "前回の値",
"ui.new_extrude_distance_tip": "新しい押し出しの開始距離です。既存の押し出しを編集するときは、その押し出しの値が使われます。",
"ui.no_slicer_found": "スライサーが見つかりません",
"ui.normal": "標準",
"ui.normalConstruction": "通常 / コンストラクション",
@@ -1400,6 +1413,11 @@
"ui.rectangle": "長方形",
"ui.rectangular_pattern": "矩形パターン",
"ui.redo": "やり直す",
"ui.relink_design_changed": "参照元の選択中にアセンブリが変更されました。再リンクをやり直してください。",
"ui.relink_done": "すべてのインスタンスの参照元を再リンクしました。",
"ui.relink_not_linked": "このコンポーネントは参照元の設計にリンクされていません。",
"ui.relink_select_one": "リンクされたコンポーネントまたはそのインスタンスを1つ選択してください。",
"ui.relink_source_all": "参照元を再リンク(すべてのインスタンス)…",
"ui.removeFromToolbar": "ツールバーから削除",
"ui.removeSection": "セクションを解除",
"ui.render": "レンダリング",
@@ -1423,6 +1441,8 @@
"ui.save_mesh_pick_bodies": "保存するボディを選択してください",
"ui.save_mesh_temp_failed": "{folder} を作成できません: {error}",
"ui.saved_to": "保存しました: {path}",
"ui.sbox_pin": "Sボックスの上部に固定",
"ui.sbox_unpin": "固定を解除",
"ui.scale": "尺度",
"ui.second_rail": "第2レール",
"ui.section_analysis": "断面解析",
+11
View File
@@ -425,8 +425,10 @@
"cmd.timeline_roll_to": "이력 마커 이동",
"cmd.timeline_rollback": "타임라인 마커 이동",
"cmd.timeline_suppress": "특성 비활성화",
"cmd.view_ambient_occlusion": "앰비언트 오클루전",
"cmd.view_apply": "이름 있는 보기 복원",
"cmd.view_delete": "이름 있는 보기 삭제",
"cmd.view_graphics_preset": "그래픽 프리셋",
"cmd.view_list": "이름 있는 보기 목록",
"cmd.view_navigate": "보기 탐색",
"cmd.view_rename": "이름 있는 보기 이름 변경",
@@ -756,6 +758,8 @@
"ui.ai_toolbar_button_note": "도구 모음 오른쪽 끝의 AI 버튼. Ctrl+Shift+A와 보기 메뉴로도 패널을 열 수 있습니다.",
"ui.ai_undo_tip": "이 메시지에서 에이전트가 바꾼 모든 것을 한 번에 실행 취소",
"ui.all": "모두",
"ui.ambient_occlusion": "앰비언트 오클루전",
"ui.ambient_occlusion_help": "불투명한 표면이 만나는 곳에 부드러운 음영을 추가합니다. GPU 뷰포트가 필요합니다.",
"ui.analysis": "분석",
"ui.angle": "각도",
"ui.angle_decimals": "각도 소수점 자릿수",
@@ -910,6 +914,11 @@
"ui.fully_constrained": "완전 구속",
"ui.general": "일반",
"ui.graphics": "그래픽",
"ui.graphics_custom": "사용자 지정",
"ui.graphics_performance": "성능",
"ui.graphics_preset": "그래픽 프리셋",
"ui.graphics_preset_help": "품질: 앰비언트 오클루전, 세밀한 표시 품질, 적응형 2× 슈퍼샘플링. 성능: 앰비언트 오클루전 끄기, 보통 표시 품질, 슈퍼샘플링 끄기. 수동 변경 시 사용자 지정으로 표시됩니다. MSAA는 유지됩니다.",
"ui.graphics_quality": "품질",
"ui.groupSelected": "선택 항목 그룹화",
"ui.heading_assemble": "조립",
"ui.heading_box": "박스",
@@ -1116,6 +1125,8 @@
"ui.save_mesh_pick_bodies": "저장할 바디를 선택하세요",
"ui.save_mesh_temp_failed": "{folder}을(를) 만들 수 없습니다: {error}",
"ui.saved_to": "저장됨: {path}",
"ui.sbox_pin": "S 상자 맨 위에 고정",
"ui.sbox_unpin": "고정 해제",
"ui.scale": "축척",
"ui.section_analysis": "단면 분석",
"ui.select": "선택",
File diff suppressed because it is too large Load Diff
+2
View File
@@ -1384,6 +1384,8 @@
"ui.save_mesh_pick_bodies": "Zaznacz bryły do zapisania",
"ui.save_mesh_temp_failed": "Nie udało się utworzyć {folder}: {error}",
"ui.saved_to": "Zapisano {path}",
"ui.sbox_pin": "Przypnij na górze okna S",
"ui.sbox_unpin": "Odepnij",
"ui.scale": "Skala",
"ui.second_rail": "Druga prowadnica",
"ui.section_analysis": "Analiza przekroju",
+2
View File
@@ -1317,6 +1317,8 @@
"ui.save_mesh_pick_bodies": "Selecione os corpos a salvar",
"ui.save_mesh_temp_failed": "Não foi possível criar {folder}: {error}",
"ui.saved_to": "Salvo {path}",
"ui.sbox_pin": "Fixar no topo da caixa S",
"ui.sbox_unpin": "Desafixar",
"ui.scale": "Escala",
"ui.section_analysis": "Análise de seção",
"ui.select": "Selecionar",
+11
View File
@@ -568,8 +568,10 @@
"cmd.timeline_roll_to": "Переместить маркер истории",
"cmd.timeline_rollback": "Сдвинуть маркер шкалы",
"cmd.timeline_suppress": "Подавить операцию",
"cmd.view_ambient_occlusion": "Окружающее затенение",
"cmd.view_apply": "Вернуть именованный вид",
"cmd.view_delete": "Удалить именованный вид",
"cmd.view_graphics_preset": "Графический профиль",
"cmd.view_list": "Список именованных видов",
"cmd.view_navigate": "Навигация вида",
"cmd.view_rename": "Переименовать именованный вид",
@@ -750,6 +752,8 @@
"ui.agent_cursor": "Курсор агента",
"ui.agent_cursor_speed": "Скорость курсора агента",
"ui.all": "Все",
"ui.ambient_occlusion": "Окружающее затенение",
"ui.ambient_occlusion_help": "Мягкое затенение в местах соприкосновения непрозрачных поверхностей. Требуется видовой экран GPU.",
"ui.analysis": "Анализ",
"ui.angle": "Угол",
"ui.angle_decimals": "Знаков после запятой (углы)",
@@ -899,6 +903,11 @@
"ui.fully_constrained": "Полностью определён",
"ui.general": "Общие",
"ui.graphics": "Графика",
"ui.graphics_custom": "Настраиваемый",
"ui.graphics_performance": "Производительность",
"ui.graphics_preset": "Графический профиль",
"ui.graphics_preset_help": "Качество: окружающее затенение, детальная тесселяция и адаптивный суперсэмплинг 2×. Производительность: без затенения, обычная тесселяция и без суперсэмплинга. Ручные изменения отображаются как настраиваемые. MSAA не меняется.",
"ui.graphics_quality": "Качество",
"ui.ground_shadow": "Тень на земле",
"ui.groupSelected": "Группировать выбранное",
"ui.heading_add_ins": "НАДСТРОЙКИ",
@@ -1116,6 +1125,8 @@
"ui.save_mesh_pick_bodies": "Выберите тела для сохранения",
"ui.save_mesh_temp_failed": "Не удалось создать {folder}: {error}",
"ui.saved_to": "Сохранено: {path}",
"ui.sbox_pin": "Закрепить вверху панели S",
"ui.sbox_unpin": "Открепить",
"ui.scale": "Масштаб",
"ui.section_analysis": "Анализ сечения",
"ui.select": "Выбрать",
+2
View File
@@ -1264,6 +1264,8 @@
"ui.save_mesh_pick_bodies": "Kaydedilecek gövdeleri seçin",
"ui.save_mesh_temp_failed": "{folder} oluşturulamadı: {error}",
"ui.saved_to": "{path} konumuna kaydedildi",
"ui.sbox_pin": "S kutusunun en üstüne sabitle",
"ui.sbox_unpin": "Sabitlemeyi kaldır",
"ui.scale": "Ölçekle",
"ui.section_analysis": "Kesit analizi",
"ui.select": "Seç",
+2
View File
@@ -1340,6 +1340,8 @@
"ui.save_mesh_pick_bodies": "Виберіть тіла для збереження",
"ui.save_mesh_temp_failed": "Не вдалося створити {folder}: {error}",
"ui.saved_to": "Збережено {path}",
"ui.sbox_pin": "Закріпити вгорі поля S",
"ui.sbox_unpin": "Відкріпити",
"ui.scale": "Масштаб",
"ui.section_analysis": "Аналіз перерізу",
"ui.select": "Вибрати",
+22 -2
View File
@@ -350,6 +350,7 @@
"cmd.component_move_sketches": "将草图移至组件",
"cmd.component_paste_new": "粘贴为新对象",
"cmd.component_properties": "组件物理属性",
"cmd.component_relink": "重新链接源文件",
"cmd.component_rename": "重命名组件",
"cmd.component_revert_position": "恢复位置",
"cmd.component_unlock": "解锁内容",
@@ -632,8 +633,10 @@
"cmd.timeline_roll_to": "滚动历史标记",
"cmd.timeline_rollback": "移动时间线标记",
"cmd.timeline_suppress": "抑制特征",
"cmd.view_ambient_occlusion": "环境光遮蔽",
"cmd.view_apply": "还原命名视图",
"cmd.view_delete": "删除命名视图",
"cmd.view_graphics_preset": "图形预设",
"cmd.view_list": "列出命名视图",
"cmd.view_navigate": "导航视图",
"cmd.view_rename": "重命名命名视图",
@@ -857,7 +860,7 @@
"ui.agent_chat_approval": "允许智能体执行此操作吗?",
"ui.agent_chat_attach_view": "附加视图",
"ui.agent_chat_attach_view_tip": "随消息发送当前视图的图片",
"ui.agent_chat_attachable": "图片、文本和 CAD 文件",
"ui.agent_chat_attachable": "图片、PDF、文本和 CAD 文件",
"ui.agent_chat_auth_agent": "使用 {name} 登录",
"ui.agent_chat_auth_env": "请在环境中设置 {vars},然后重启 SolidCraft。",
"ui.agent_chat_auth_needed": "智能体要求你登录。登录在智能体本身完成;SolidCraft 从不接触你的密码或密钥。",
@@ -889,7 +892,6 @@
"ui.agent_chat_file_error": "{name}:{error}",
"ui.agent_chat_file_heic": "{name}:这里无法读取 HEIC 图片;请先导出为 JPEG 或 PNG",
"ui.agent_chat_file_importing": "正在导入设计…",
"ui.agent_chat_file_kinds": "{name}:可以附加图片(PNG、JPEG、GIF、WebP、BMP、TIFF)、文本文件(包括 SVG)和 CAD 文件(STEP、IGES、FreeCAD、Rhino、STL、3MF、OBJ、glTF)",
"ui.agent_chat_file_loading": "正在读取文件…",
"ui.agent_chat_file_not_a_file": "{name}:不是文件",
"ui.agent_chat_file_not_a_picture": "{name}:不是图片",
@@ -1036,6 +1038,8 @@
"ui.ai_toolbar_button_note": "工具栏右端的 AI 按钮。Ctrl+Shift+A 和视图菜单仍可打开面板。",
"ui.ai_undo_tip": "将智能体为此消息所做的全部更改一步撤销",
"ui.all": "全部",
"ui.ambient_occlusion": "环境光遮蔽",
"ui.ambient_occlusion_help": "在不透明表面相接处添加柔和阴影。需要 GPU 视口。",
"ui.analysis": "分析",
"ui.angle": "角度",
"ui.angle_decimals": "角度小数位",
@@ -1196,6 +1200,11 @@
"ui.fully_constrained": "完全约束",
"ui.general": "常规",
"ui.graphics": "图形",
"ui.graphics_custom": "自定义",
"ui.graphics_performance": "性能",
"ui.graphics_preset": "图形预设",
"ui.graphics_preset_help": "质量:环境光遮蔽、精细显示质量和自适应2×超采样。性能:关闭环境光遮蔽、普通显示质量和关闭超采样。手动修改显示为自定义。MSAA保持不变。",
"ui.graphics_quality": "质量",
"ui.grid_follows_view": "网格跟随视图",
"ui.grid_follows_view_hint": "在侧视图中,将网格显示在朝向该视图的竖直平面上,而不是地面上",
"ui.ground_shadow": "地面阴影",
@@ -1337,6 +1346,10 @@
"ui.newSketchGroup": "新建草图组",
"ui.new_body": "新建实体",
"ui.new_design": "新建设计",
"ui.new_extrude_distance": "新建拉伸距离",
"ui.new_extrude_distance_fixed": "固定 (10 mm)",
"ui.new_extrude_distance_last_used": "上次使用",
"ui.new_extrude_distance_tip": "新建拉伸的起始距离。编辑已有拉伸时保留其自身的值。",
"ui.no_slicer_found": "未找到切片软件",
"ui.normal": "法向",
"ui.normalConstruction": "普通 / 构造",
@@ -1400,6 +1413,11 @@
"ui.rectangle": "矩形",
"ui.rectangular_pattern": "矩形阵列",
"ui.redo": "重做",
"ui.relink_design_changed": "选择源文件时装配已更改。请重新开始链接。",
"ui.relink_done": "已为所有实例重新链接源文件。",
"ui.relink_not_linked": "此组件未链接到源设计。",
"ui.relink_select_one": "请先选择一个链接组件或其实例。",
"ui.relink_source_all": "重新链接源文件(所有实例)…",
"ui.removeFromToolbar": "从工具栏移除",
"ui.removeSection": "移除剖面",
"ui.render": "渲染",
@@ -1423,6 +1441,8 @@
"ui.save_mesh_pick_bodies": "请选择要保存的实体",
"ui.save_mesh_temp_failed": "无法创建 {folder}:{error}",
"ui.saved_to": "已保存:{path}",
"ui.sbox_pin": "固定到 S 框顶部",
"ui.sbox_unpin": "取消固定",
"ui.scale": "缩放",
"ui.second_rail": "第二导轨",
"ui.section_analysis": "截面分析",
@@ -6,7 +6,7 @@
<metadata_license>CC0-1.0</metadata_license>
<project_license>MIT OR Apache-2.0</project_license>
<name>SolidCraft</name>
<summary>Parametric 3D CAD in the style of Fusion</summary>
<summary>Independent parametric 3D CAD</summary>
<developer id="io.github.bherbruck">
<name>SolidCraft contributors</name>
</developer>
+62 -3
View File
@@ -10,6 +10,7 @@ struct U {
acc_u: vec4<f32>, // accessibility map axes (xyz, offset)
acc_v: vec4<f32>,
eye: vec4<f32>, // eye position, w = 1 for perspective (0: orthographic, looking along -back)
ao: vec4<f32>, // radius in world units (zero: disabled)
// analysis colours (`theme::AnalysisColors`): draft positive, between, negative; reachable,
// blocked; curvature flat, middle, tight
ana_c: array<vec4<f32>, 8>,
@@ -28,6 +29,7 @@ fn near_z(c: vec4<f32>) -> f32 {
}
@group(1) @binding(2) var acc_t: texture_2d<f32>;
@group(2) @binding(0) var ao_depth: texture_depth_2d;
fn clipped(p: vec3<f32>) -> bool {
return length(u.clip.xyz) > 0.5 && dot(u.clip.xyz, p) > u.clip.w;
@@ -119,7 +121,7 @@ fn solid(i: TOut, front: bool) -> vec4<f32> {
let fw = fwidth(ndv);
if (clipped(i.wp)) { discard; }
if (!front && length(u.clip.xyz) > 0.5) {
// The cut face in its body's colour (as Fusion's "from component"), tinted toward the
// The cut face in its body's colour ("from component" colouring), tinted toward the
// cap colour so it reads as a cut, hatched at 45 degrees on screen.
let base = mix(i.c.rgb, u.cap.rgb, 0.35);
let h = fract((i.pos.x + i.pos.y) / 9.0);
@@ -128,7 +130,7 @@ fn solid(i: TOut, front: bool) -> vec4<f32> {
if (u.ana.x > 0.5 && length(i.n) > 0.5) {
return analysis(i, k);
}
let c = shade(i);
let c = lit(i, occlusion(i));
// Silhouettes (styles with edges): where a curved surface turns away from the eye, a line
// about 1.5 px wide, on the visible surface only (depth-correct).
if (u.screen.w > 0.5 && length(i.n) > 0.5 && fw > 1e-6) {
@@ -138,6 +140,59 @@ fn solid(i: TOut, front: bool) -> vec4<f32> {
return c;
}
// A single-sample opaque depth map, independent of the window's MSAA and its UI offsets.
@fragment
fn fs_ao_depth(i: TOut, @builtin(front_facing) front: bool) -> @builtin(frag_depth) f32 {
if (clipped(i.wp) || length(i.n) < 0.5) { discard; }
if (!front && length(u.clip.xyz) > 0.5) { return cap_depth(i.wp, i.pos.z); }
return i.pos.z;
}
// Deterministic cosine-weighted hemisphere samples. Projecting the sample and a point one
// radius toward the eye supplies a depth range in both orthographic and perspective views;
// distant foreground geometry cannot cast a dark halo onto a background surface.
fn occlusion(i: TOut) -> f32 {
let radius = u.ao.x;
if (radius <= 0.0 || length(i.n) < 0.5) { return 1.0; }
var n = normalize(i.n);
if (dot(n, u.back.xyz) < 0.0) { n = -n; }
let axis = select(vec3<f32>(0.0, 0.0, 1.0), vec3<f32>(0.0, 1.0, 0.0), abs(n.z) > 0.9);
let tangent = normalize(cross(axis, n));
let bitangent = cross(n, tangent);
let size = vec2<i32>(textureDimensions(ao_depth));
// The map is a 1 × 1 stand-in until a frame draws into it (the product preview draws no
// prepass): sampling it tells nothing, so leave the surface unoccluded.
if (size.x < 2 || size.y < 2) { return 1.0; }
// One comparison for both depth directions: depths read as 0 near / 1 far (mirrored when
// the buffer runs reversed). The map is cleared to its far plane either way, so a texel
// with nothing in it reads as 1 and the `d < 1.0` test below skips it.
let flip = reversed();
var blocked = 0.0;
for (var j = 0u; j < 32u; j = j + 1u) {
let f = (f32(j) + 0.5) / 32.0;
let angle = f32(j) * 2.39996323;
let direction = tangent * (cos(angle) * sqrt(f)) + bitangent * (sin(angle) * sqrt(f)) + n * sqrt(1.0 - f);
let reach = radius * (0.2 + 0.8 * fract(f32(j) * 0.61803399 + 0.5));
let sample = i.wp + n * (radius * 0.025) + direction * reach;
let q = u.vp * vec4<f32>(sample, 1.0);
let near = u.vp * vec4<f32>(sample + u.back.xyz * radius, 1.0);
if (q.w <= 0.0 || near.w <= 0.0) { continue; }
let ndc = q.xyz / q.w;
let uv = vec2<f32>(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
if (any(uv < vec2<f32>(0.0)) || any(uv >= vec2<f32>(1.0)) || ndc.z < 0.0 || ndc.z > 1.0) { continue; }
let d0 = textureLoad(ao_depth, vec2<i32>(uv * vec2<f32>(size)), 0);
let zs = select(ndc.z, 1.0 - ndc.z, flip);
let zn = select(near.z / near.w, 1.0 - near.z / near.w, flip);
let d = select(d0, 1.0 - d0, flip);
let span = max(zs - zn, 1e-7);
let delta = (zs - d) / span;
if (d < 1.0 && delta > 0.005 && delta < 1.0) {
blocked = blocked + smoothstep(0.005, 0.15, delta) * (1.0 - smoothstep(0.5, 1.0, delta));
}
}
return 1.0 - 0.85 * blocked / 32.0;
}
/// How fast the normal turns across the pixel (per mm).
fn curvature(i: TOut) -> f32 {
let nn = normalize(i.n);
@@ -230,6 +285,10 @@ fn fs_tri_all(i: TOut) -> @location(0) vec4<f32> {
}
fn shade(i: TOut) -> vec4<f32> {
return lit(i, 1.0);
}
fn lit(i: TOut, visibility: f32) -> vec4<f32> {
// A zero normal marks flat colour (selection fills, translucent planes).
if (length(i.n) < 0.5) {
return out_color(i.c);
@@ -242,7 +301,7 @@ fn shade(i: TOut) -> vec4<f32> {
let fill = max(dot(n, v), 0.0);
let h = normalize(key + v);
let spec = pow(max(dot(n, h), 0.0), 40.0) * 0.25;
let k = 0.42 + 0.38 * diff + 0.25 * fill;
let k = 0.42 + 0.38 * diff + 0.25 * fill - (0.42 + 0.25 * fill) * (1.0 - visibility);
return out_color(vec4<f32>(min(i.c.rgb * k + vec3<f32>(spec), vec3<f32>(1.0)), i.c.a));
}
+1 -1
View File
@@ -32,7 +32,7 @@ pub(crate) fn remap_sheet_metadata(st: &mut ModelState, names: &[String], transf
.filter(|(_, t)| names.contains(t.body.as_str()))
.map(|(i, t)| {
let point = apply_point(transform, t.axis_point);
let axis = apply_vector(transform, t.axis).normalized().ok_or_else(|| DocError::Invalid("invalid transferred thread axis".into()))?;
let axis = apply_vector(transform, *t.axis).unit().ok_or_else(|| DocError::Invalid("invalid transferred thread axis".into()))?;
if !point.is_finite() {
return Err(DocError::Invalid("invalid transferred thread placement".into()));
}
+1 -1
View File
@@ -1,4 +1,4 @@
//! Canvases: reference images placed on a plane (Fusion's Insert Canvas), kept in the design.
//! Canvases: reference images placed on a plane (Insert Canvas), kept in the design.
//! The image bytes travel inside the design file (base64), so it reopens without the original.
use serde::{Deserialize, Serialize};
+6 -6
View File
@@ -1,6 +1,6 @@
//! Construction geometry from the model: references to vertices, edges, faces, planes, axes,
//! points and sketch entities, resolved on each evaluation, and the axes, points and planes
//! built from them (Fusion's CONSTRUCT panel).
//! built from them (the CONSTRUCT panel).
//!
//! Face and edge references carry the persistent name of what was picked (see `naming`), so
//! they follow the model when earlier features change; the picked point is the fallback.
@@ -285,9 +285,9 @@ pub(crate) fn line(ctx: &Ctx, g: &GeoRef) -> Result<(Vec3, Vec3)> {
GeoRef::Face { at, name } => {
let (b, fi, _) = face_of(ctx.st, *at, name)?;
match kernel::face_surface(&b.body, fi) {
Some(FaceSurface::Cylinder { origin, axis, .. }) => Ok((origin, axis)),
Some(FaceSurface::Cone { apex, axis, .. }) => Ok((apex, axis)),
Some(FaceSurface::Torus { center, axis, .. }) => Ok((center, axis)),
Some(FaceSurface::Cylinder { origin, axis, .. }) => Ok((origin, *axis)),
Some(FaceSurface::Cone { apex, axis, .. }) => Ok((apex, *axis)),
Some(FaceSurface::Torus { center, axis, .. }) => Ok((center, *axis)),
_ => Err(bad("the face is not a cylinder, cone or torus")),
}
}
@@ -367,7 +367,7 @@ fn path_points(ctx: &Ctx, g: &GeoRef) -> Result<Vec<Vec3>> {
/// The surface's unit normal at (the point of the surface nearest) `p`, and that point.
fn surface_normal(s: FaceSurface, p: Vec3) -> Option<(Vec3, Vec3)> {
match s {
FaceSurface::Plane { normal, point } => Some((p - normal * (p - point).dot(normal), normal)),
FaceSurface::Plane { normal, point } => Some((p - normal * (p - point).dot(normal), *normal)),
FaceSurface::Cylinder { origin, axis, radius } => {
let w = p - origin;
let r = (w - axis * w.dot(axis)).normalized()?;
@@ -403,7 +403,7 @@ fn face_surface_at(ctx: &Ctx, g: &GeoRef) -> Result<(FaceSurface, Vec3)> {
}
GeoRef::Plane { .. } => {
let pl = plane(ctx, g)?;
Ok((FaceSurface::Plane { normal: pl.normal(), point: pl.origin }, pl.origin))
Ok((FaceSurface::Plane { normal: pl.normal().unit().ok_or_else(|| bad("degenerate plane"))?, point: pl.origin }, pl.origin))
}
_ => Err(bad("pick a face")),
}
+4 -4
View File
@@ -812,11 +812,11 @@ pub enum FeatureKind {
#[serde(default, skip_serializing_if = "Option::is_none")]
line: Option<String>,
},
/// A construction axis (Fusion's CONSTRUCT axes).
/// A construction axis (CONSTRUCT axes).
ConstructionAxis {
def: AxisDef,
},
/// A construction point (Fusion's CONSTRUCT points).
/// A construction point (CONSTRUCT points).
ConstructionPoint {
def: PointDef,
},
@@ -2065,7 +2065,7 @@ impl FeatureKind {
)
}
/// Fusion-like type name (timeline tooltip, `inspect`).
/// Short type name (timeline tooltip, `inspect`).
pub fn type_name(&self) -> &'static str {
match self {
FeatureKind::BodyTransfer { .. } => "BodyTransferFeature",
@@ -3273,7 +3273,7 @@ impl Document {
}
}
/// Density of the default material (g/cm³), as Fusion's default.
/// Density of the default material (g/cm³), as the default.
#[path = "../../../assets/materials/library.rs"]
mod materials;
pub use materials::{DEFAULT_DENSITY, MATERIALS, material_color};
+6 -6
View File
@@ -6,7 +6,7 @@ use std::sync::{Arc, OnceLock};
use serde::Serialize;
use solidcraft_geom::tess::TessPlan;
use solidcraft_geom::{Mesh, Plane, Region2, Vec2, Vec3, regions_apart};
use solidcraft_geom::{Mesh, Plane, Region2, UnitVec3, Vec2, Vec3, regions_apart};
use solidcraft_kernel::{self as kernel, Body, BoolOp};
use solidcraft_sketch::{CurveKind, Profile, SolveReport, find_profiles};
@@ -179,7 +179,7 @@ pub struct ThreadInfo {
pub pitch: f64,
pub internal: bool,
pub axis_point: Vec3,
pub axis: Vec3,
pub axis: UnitVec3,
/// Threaded part along the axis from `axis_point`.
pub start: f64,
pub end: f64,
@@ -365,10 +365,10 @@ fn thread_groove(t: &ThreadInfo, radius: f64, open: (bool, bool)) -> Result<soli
// at the threaded length's end.
let (s0, s1) = (if open.0 { t.start - p } else { t.start }, if open.1 { t.end + p } else { t.end });
let base = t.axis_point + k * s0;
let plane = Plane { origin: base, x: u, y: k };
let plane = Plane { origin: base, x: u, y: *k };
let section = [Vec2::new(r0, -w0), Vec2::new(r1, -w1), Vec2::new(r1, w1), Vec2::new(r0, w0)];
// Sections 7.5° apart: the chords sag 0.2% of the radius.
Ok(kernel::sweep_helix_mesh(&plane, &section, base, k, p, (s1 - s0) / p, 48)?)
Ok(kernel::sweep_helix_mesh(&plane, &section, base, *k, p, (s1 - s0) / p, 48)?)
}
/// A constant fillet of `edges`; with [`FilletFit::Reduce`](crate::FilletFit::Reduce), a radius too
@@ -2390,7 +2390,7 @@ fn faces_through(st: &ModelState, p: Vec3, reach: f64) -> Vec<(f64, Vec3, Option
}
for (f, (d, facet)) in near {
let (exact, off) = match kernel::face_surface(&b.body, f as usize) {
Some(kernel::FaceSurface::Plane { normal, point }) => (Some(normal), Some((p - point).dot(normal).abs())),
Some(kernel::FaceSurface::Plane { normal, point }) => (Some(*normal), Some((p - point).dot(normal).abs())),
Some(kernel::FaceSurface::Cylinder { origin, axis, .. }) => {
let v = p - origin;
((v - axis * v.dot(axis)).normalized(), None)
@@ -5805,7 +5805,7 @@ pub fn world_state_reusing(doc: &Document, st: &ModelState, placed_before: &mut
let mut t2 = t.clone();
let frame = st.body(&t.body).and_then(|body| doc.model_body_offset(body)).map(|offset| crate::mat_mul(&m, offset)).unwrap_or(m);
t2.axis_point = crate::apply_point(&frame, t.axis_point);
t2.axis = crate::apply_vector(&frame, t.axis);
t2.axis = crate::apply_vector(&frame, *t.axis).unit().unwrap_or(t.axis);
out.threads.push(t2);
}
}
+1 -1
View File
@@ -395,7 +395,7 @@ fn lex_spans(s: &str) -> Result<Vec<(Tok, usize, usize)>, DocError> {
out.push((Tok::Op('-'), at(i), at(i + 1)));
i += 1;
} else if c == ';' {
// Fusion separates function arguments with `;` in some locales.
// Some CAD tools separate function arguments with `;` in some locales.
out.push((Tok::Op(','), at(i), at(i + 1)));
i += 1;
} else if "+-*/^(),%".contains(c) {
+1 -1
View File
@@ -16,7 +16,7 @@
//! - 5: joins, cuts and intersects change only their own component's bodies unless bodies are
//! named as `participants`; older features in designs with components get `participants:
//! ["*"]` (every body), which is what they did, so those designs keep their shape.
//! - 4: joint origins mate by default (B's z against A's, as Fusion does); `flip` now means
//! - 4: joint origins mate by default (B's z against A's); `flip` now means
//! "don't mate". Older joints keep their placement: their `flip` is inverted.
//! - 6: XZ origin planes use sketch y = -Z and normal +Y. Older XZ references become
//! explicit planes retaining their previous axes, including offset-plane expressions.
@@ -0,0 +1,4 @@
//! A joint keeps the expressions its offset, angle and values were typed as (`exprs`) and works
//! them out again whenever the design changes, so it follows the parameters they name. Older
//! designs need no transformation: a joint without `exprs` keeps its numbers as before. The last
//! release refuses a design saved with this (the newer format number).
+160 -2
View File
@@ -260,6 +260,92 @@ pub struct Joint {
pub angle: f64,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub suppressed: bool,
/// The expressions the offset, the angle and the values were typed as: they are worked out
/// again whenever the design changes, so a joint follows the parameters it is defined by.
#[serde(default, skip_serializing_if = "JointExprs::is_empty")]
pub exprs: JointExprs,
}
/// Expressions a joint's numbers follow (over the design's parameters): the offset (a length),
/// the angle and each value (angles or lengths as the joint's values are). A number typed in
/// place of one drops it.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct JointExprs {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub offset: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub angle: Option<String>,
/// By value index, as in `Joint::values`.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub values: Vec<Option<String>>,
}
impl JointExprs {
pub fn is_empty(&self) -> bool {
self.offset.is_none() && self.angle.is_none() && self.values.iter().all(Option::is_none)
}
/// The expression value `i` follows.
pub fn value(&self, i: usize) -> Option<&str> {
self.values.get(i).and_then(Option::as_deref)
}
/// Value `i` follows `e` (or nothing).
pub fn set_value(&mut self, i: usize, e: Option<String>) {
if e.is_some() && self.values.len() <= i {
self.values.resize(i + 1, None);
}
if let Some(slot) = self.values.get_mut(i) {
*slot = e;
}
while self.values.last().is_some_and(Option::is_none) {
self.values.pop();
}
}
}
/// Largest offset or value an expression may give a joint (mm, or radians for an angle).
const MAX_EXPR_VALUE: f64 = 1e9;
/// A joint's offset, angle and values worked out from its expressions (those without one keep
/// their numbers), or why one can't be: an unknown name, a wrong unit, or a number too large.
pub fn expressed(doc: &Document, j: &Joint) -> Result<(f64, f64, Vec<f64>)> {
let eval = |e: &str, kind: crate::expr::Kind, what: &str| -> Result<f64> {
let v = doc.eval(e, kind).map_err(|err| DocError::Invalid(format!("joint {}: {what} `{e}`: {err}", j.name)))?;
if v.is_finite() && v.abs() < MAX_EXPR_VALUE { Ok(v) } else { Err(DocError::Invalid(format!("joint {}: {what} `{e}` gives {v}", j.name))) }
};
let x = &j.exprs;
let offset = match &x.offset {
Some(e) => eval(e, crate::expr::Kind::Length, "offset")?,
None => j.offset,
};
let angle = match &x.angle {
Some(e) => eval(e, crate::expr::Kind::Angle, "angle")?,
None => j.angle,
};
let mut values = j.values.clone();
for (i, e) in x.values.iter().enumerate().take(j.kind.dofs().len()) {
let Some(e) = e else { continue };
let kind = if j.kind.is_angle(i) { crate::expr::Kind::Angle } else { crate::expr::Kind::Length };
let name = j.kind.dofs().get(i).copied().unwrap_or("value");
let v = eval(e, kind, name)?;
if values.len() <= i {
values.resize(i + 1, 0.0);
}
if let Some(slot) = values.get_mut(i) {
*slot = v;
}
}
Ok((offset, angle, values))
}
/// Values the loop solver leaves alone: those a motion link sets and those an expression does.
fn set_values(asm: &Assembly) -> BTreeSet<(u64, usize)> {
let mut set: BTreeSet<(u64, usize)> = asm.links.iter().map(|l| (l.b, l.ib)).collect();
for j in &asm.joints {
set.extend(j.exprs.values.iter().enumerate().filter(|(_, e)| e.is_some()).map(|(i, _)| (j.id, i)));
}
set
}
/// Value of joint `b` (index `ib`) = ratio · value of joint `a` (index `ia`) + offset, or for a
@@ -446,6 +532,49 @@ pub struct Assembly {
}
impl Assembly {
/// Every expression the joints and motion links keep, with the joint (or the driven joint of
/// a link) and what it sets: `offset`, `angle`, a value's name, or a coupling's length.
pub fn exprs(&self) -> Vec<(u64, String, &str)> {
let mut out = Vec::new();
for j in &self.joints {
let x = &j.exprs;
out.extend(x.offset.as_deref().map(|e| (j.id, "offset".to_string(), e)));
out.extend(x.angle.as_deref().map(|e| (j.id, "angle".to_string(), e)));
for (i, e) in x.values.iter().enumerate() {
if let Some(e) = e {
out.push((j.id, j.kind.dofs().get(i).copied().unwrap_or("value").to_string(), e.as_str()));
}
}
}
for l in &self.links {
for s in l.coupling.as_ref().map(Coupling::sizes).unwrap_or_default() {
if let Size::Expr(e) = s {
out.push((l.b, "coupling".to_string(), e.as_str()));
}
}
}
out
}
/// The expressions of `exprs`, to rewrite (a renamed parameter).
pub fn exprs_mut(&mut self) -> Vec<&mut String> {
let mut out = Vec::new();
for j in &mut self.joints {
let x = &mut j.exprs;
out.extend(x.offset.as_mut());
out.extend(x.angle.as_mut());
out.extend(x.values.iter_mut().flatten());
}
for l in &mut self.links {
for s in l.coupling.as_mut().map(Coupling::sizes_mut).unwrap_or_default() {
if let Size::Expr(e) = s {
out.push(e);
}
}
}
out
}
pub fn is_empty(&self) -> bool {
self.joints.is_empty()
&& self.origins.is_empty()
@@ -858,7 +987,7 @@ fn close_loops(tree: &Tree, closing: &[u64], asm: &Assembly, held: &Held, vals:
if closing.len() > MAX_LOOPS {
return (Values::new(), stuck);
}
let linked: BTreeSet<(u64, usize)> = asm.links.iter().map(|l| (l.b, l.ib)).collect();
let linked = set_values(asm);
let groups = loop_groups(tree, closing, asm, &linked);
let spent = std::cell::Cell::new(0usize);
for (group, around, touches) in &groups {
@@ -1053,7 +1182,7 @@ fn loop_dof(tree: &Tree, closing: &[u64], asm: &Assembly, vals: &Values, dof: &m
if closing.len() > MAX_LOOPS {
return;
}
let linked: BTreeSet<(u64, usize)> = asm.links.iter().map(|l| (l.b, l.ib)).collect();
let linked = set_values(asm);
let groups = loop_groups(tree, closing, asm, &linked);
// The joints loops depend on: those around them, those whose values they read, and those a
// motion link carries any of these values into.
@@ -1466,6 +1595,14 @@ fn build<'a>(doc: &'a Document, st: &ModelState, vals: &Values, sol: &mut Soluti
// Joint frames in component coordinates (errors leave the joint out).
let mut frames: BTreeMap<u64, (Mat, Mat)> = BTreeMap::new();
for j in &joints {
// A joint whose expressions can't be worked out (a parameter gone) is left out rather
// than placed by numbers that no longer say what it was defined by.
if !j.exprs.is_empty()
&& let Err(e) = expressed(doc, j)
{
sol.errors.push((j.id, j.name.clone(), e.to_string()));
continue;
}
match (resolve_origin(doc, st, &j.a), resolve_origin(doc, st, &j.b)) {
(Ok(fa), Ok(fb)) => {
frames.insert(j.id, (fa, fb));
@@ -1600,6 +1737,26 @@ mod face;
pub use face::{end_faces, face_name_at, named_face_spot, pin_end, round_end_at};
pub use reach::{REACH_TOL, Reach, Reached, reach};
/// The rest angle that turns B least as joint `j` is made, as B sits now: none or a half turn
/// about the joint's z. Origins whose x is chosen for them (a face, a circle, a point without
/// x) get opposite x when their z are opposite, so two faces that already meet would, at angle
/// 0, spin B a half turn about their shared normal.
pub fn least_turn_angle(doc: &Document, st: &ModelState, j: &Joint) -> f64 {
let (Ok(fa), Ok(fb)) = (resolve_origin(doc, st, &j.a), resolve_origin(doc, st, &j.b)) else { return 0.0 };
let (ta, tb) = (occurrence_world(doc, j.a.occurrence), occurrence_world(doc, j.b.occurrence));
let mut q = j.values.clone();
q.resize(j.kind.dofs().len(), 0.0);
clamp(j, &mut q);
let turn = |angle: f64| {
let k = Joint { angle, ..j.clone() };
through(&k, &(fa, fb), &q, true, &ta).map(|m| mismatch(&m, &tb).1)
};
match (turn(0.0), turn(std::f64::consts::PI)) {
(Some(none), Some(half)) if half + 1e-9 < none => std::f64::consts::PI,
_ => 0.0,
}
}
/// The frame matrix for an explicit origin / z / x (public for commands).
pub fn frame_of(o: Vec3, z: Vec3, x: Option<Vec3>) -> Option<Mat> {
frame(o, z, x)
@@ -1631,6 +1788,7 @@ mod tests {
offset: 0.0,
angle: 0.0,
suppressed: false,
exprs: Default::default(),
})
.collect();
let links =
+1
View File
@@ -967,6 +967,7 @@ mod tests {
offset: 0.0,
angle: 0.0,
suppressed: false,
exprs: Default::default(),
};
let occ = |id: u64, grounded: bool| crate::Occurrence { id, component: id, parent: 0, name: format!("O{id}"), transform: IDENTITY, grounded };
// 1 frame (grounded), 2 cam, 3-6 a rigid chain to the guide 7, 8 the piston, 9 crank, 10 rod.
+25 -13
View File
@@ -10,6 +10,7 @@ use solidcraft_geom::Vec3;
use solidcraft_kernel::CylinderFace;
use super::{Snap, XRef, component_bodies};
use crate::history_names::Match;
use crate::{DocError, Document, IDENTITY, Mat, ModelBody, ModelState, Result, apply_point, apply_vector, mat_inverse};
/// Rim points sampled to find the face across a cylinder's rim.
@@ -71,7 +72,7 @@ impl NamedFace<'_> {
/// A cylinder of a body's own coordinates placed by the body's (rigid) offset.
pub(super) fn placed(m: &Mat, c: CylinderFace) -> CylinderFace {
CylinderFace { axis_point: apply_point(m, c.axis_point), axis: apply_vector(m, c.axis), ..c }
CylinderFace { axis_point: apply_point(m, c.axis_point), axis: apply_vector(m, *c.axis).unit().unwrap_or(c.axis), ..c }
}
/// The face across the rim at the `at` end (along the axis) of cylinder `c`, face `face` of `b`
@@ -170,21 +171,32 @@ impl Round {
pub(super) fn named_round(doc: &Document, st: &ModelState, pick: Vec3, face: &str, end: Option<&str>) -> Result<Option<Round>> {
let nf = named_face(doc, st, pick, face)?;
let Some(own) = nf.own_cylinder() else { return Ok(None) };
let chosen = end
.and_then(|name| {
let hits: Vec<(f64, f64)> = ends(&own)
.into_iter()
.filter(|(at, _)| rim_neighbour(nf.body, nf.face, &own, *at).is_some_and(|f| crate::name_lookup::face_has_name(nf.body, f, name)))
.collect();
match hits[..] {
[one] => Some(one),
_ => None,
}
})
.unwrap_or_else(|| end_by_pick(&nf, &own));
let chosen = end.and_then(|name| end_named(nf.body, nf.face, &own, name)).unwrap_or_else(|| end_by_pick(&nf, &own));
Ok(Some(Round::at(placed(&nf.offset, own), chosen)))
}
/// The end of cylinder `own` (face `face` of `b`) whose rim meets the face called `name` better
/// than the other end's rim does: the very face, or one merged into it, else a piece of it whose
/// neighbours have changed (the face round a hole in a sheet is told apart from its other pieces
/// by the faces around it, and those change when the hole moves). `None` when neither rim, or
/// both, meet it as well.
fn end_named(b: &ModelBody, face: usize, own: &CylinderFace, name: &str) -> Option<(f64, f64)> {
let names = crate::naming::names(b);
let how = |at: f64| {
let f = rim_neighbour(b, face, own, at)?;
if crate::name_lookup::legacy_name(name).is_some() {
return crate::name_lookup::face_has_name(b, f, name).then_some(Match::Exact);
}
crate::history_names::matches(&names, f, name)
};
let [start, end] = ends(own);
match (how(start.0), how(end.0)) {
(s, e) if s > e => Some(start),
(s, e) if e > s => Some(end),
_ => None,
}
}
/// The x axis an x reference gives a frame at `o` with axis `z` (`None`: no reference).
pub(super) fn x_axis(doc: &Document, st: &ModelState, x: Option<&XRef>, o: Vec3) -> Result<Option<Vec3>> {
Ok(match x {
+17
View File
@@ -486,6 +486,9 @@ pub enum ParamUser {
Dimension { feature: u64, name: String, constraint: String },
/// A configured column or an expression saved in a configuration row.
Configuration { row: Option<String>, column: String },
/// A joint's offset, angle or value, or a motion link's gear, rack or screw length (`input`
/// says which; a link is named by the joint it drives).
Joint { joint: u64, name: String, input: String },
}
impl ParamUser {
@@ -498,6 +501,7 @@ impl ParamUser {
Some(row) => format!("configuration `{row}` ({column})"),
None => format!("configuration column {column}"),
},
ParamUser::Joint { name, input, .. } => format!("joint {name} ({input})"),
}
}
}
@@ -615,6 +619,12 @@ impl Document {
}
}
}
for (joint, input, e) in self.assembly.exprs() {
if expr::references(e).iter().any(|r| r == name) {
let jname = self.assembly.joints.iter().find(|j| j.id == joint).map(|j| j.name.clone()).unwrap_or_else(|| joint.to_string());
out.push(ParamUser::Joint { joint, name: jname, input });
}
}
out
}
@@ -719,6 +729,13 @@ impl Document {
}
}
}
for e in self.assembly.exprs_mut() {
let r = expr::rename_reference(e, old, new);
if r != *e {
*e = r;
n += 1;
}
}
if let Some(c) = self.param_comments.remove(old) {
self.param_comments.insert(new.to_string(), c);
}
+61 -27
View File
@@ -3,13 +3,16 @@
//!
//! Model geometry is found again on every recompute: a face or an edge by the persistent name
//! its link keeps, anything else (and a link saved without a name) from the reference point
//! stored in the link. Edge polylines from the tessellation lie exactly on their curves,
//! so lines and circular arcs are recovered exactly by fitting; anything else (an ellipse from
//! a tilted circle, a spline edge) becomes a chain of lines.
//! stored in the link. An edge of a solid lying in a plane parallel to the sketch's comes in as
//! its exact curve (a line, an arc, the Bézier pieces of a free-form curve such as a letter
//! cut from a font), so a profile drawn round it meets the solid's faces exactly. Anything
//! else is fitted to its polyline from the tessellation, which lies exactly on the curve:
//! lines and circular arcs are recovered exactly, the rest (an ellipse from a tilted circle,
//! a spline edge running out of the plane) becomes a chain of lines.
use std::collections::BTreeMap;
use solidcraft_geom::{Mesh, Plane, Vec2, Vec3};
use solidcraft_geom::{Mesh, Plane, Seg2, Vec2, Vec3};
use solidcraft_sketch::{CurveKind, LinkGeom, LinkKind, LinkSource, Sketch};
use crate::eval::ModelState;
@@ -117,7 +120,8 @@ fn resolve_uncached(
let name = st.bodies.get(bi).map(|b| b.name.clone()).unwrap_or_else(|| body.clone());
source = LinkSource::Edge { body: name, at: q };
edge_index = Some(e);
edge_geom(plane, kind, mesh.edges.get(e).cloned().unwrap_or_default(), &mut wires)
let exact = st.bodies.get(bi).and_then(|b| exact_edges(b, plane, &[e]).pop().flatten());
edge_geom(plane, kind, exact, mesh.edges.get(e).cloned().unwrap_or_default(), &mut wires)
}
LinkSource::Face { body, at } => {
let (bi, mesh) = body_mesh(st, body, *at)?;
@@ -133,13 +137,8 @@ fn resolve_uncached(
match kind {
LinkKind::Intersect => section(plane, &mesh, Some(f)),
_ => {
let mut g = Vec::new();
let target = st.bodies.get(bi).ok_or_else(|| lost("the projection body"))?;
for e in projection_face_edges(target, &mesh, f, plane)? {
if let Some(pts) = mesh.edges.get(e) {
g.extend(fit_world(plane, pts));
}
}
let g = projected_edges(target, &mesh, plane, &projection_face_edges(target, &mesh, f, plane)?);
if certified_planar_construction(target, f as usize) { merge_family_lines(g)? } else { g }
}
}
@@ -282,19 +281,62 @@ fn off_plane(plane: &Plane, pts: &[Vec3]) -> bool {
pts.iter().any(|p| plane.height(*p).abs() > size * 1e-7)
}
/// What a body edge (its points) gives a sketch: its section with the sketch plane, itself as a
/// 3D curve (into `wires`) when it is included and leaves the plane, else its projection.
fn edge_geom(plane: &Plane, kind: LinkKind, pts: Vec<Vec3>, wires: &mut Vec<Vec<Vec3>>) -> Vec<LinkGeom> {
/// What a body edge (its `exact` curve when read, and its polyline `pts`) gives a sketch: its
/// section with the sketch plane, itself as a 3D curve (into `wires`) when it is included and
/// leaves the plane, else its projection (the exact curve, or the polyline fitted).
fn edge_geom(plane: &Plane, kind: LinkKind, exact: Option<Vec<LinkGeom>>, pts: Vec<Vec3>, wires: &mut Vec<Vec<Vec3>>) -> Vec<LinkGeom> {
match kind {
LinkKind::Intersect => section_of_polyline(plane, &pts),
LinkKind::Include if off_plane(plane, &pts) => {
wires.push(pts);
Vec::new()
}
_ => fit_world(plane, &pts),
_ => exact.unwrap_or_else(|| fit_world(plane, &pts)),
}
}
/// Edges `edges` of `body` (indices into its mesh's `edges`) on `plane` as their exact curves:
/// lines, circles and arcs, and the Bézier pieces of a free-form curve. `None` for an edge that
/// doesn't lie in a plane parallel to `plane` or isn't read exactly (a mesh body's): it is
/// fitted to its polyline instead.
fn exact_edges(body: &crate::ModelBody, plane: &Plane, edges: &[usize]) -> Vec<Option<Vec<LinkGeom>>> {
body.body.planar_edges(plane, edges).into_iter().map(|segs| segs?.iter().map(link_geom).collect()).collect()
}
/// `edges` of `body` projected on `plane`: each exactly ([`exact_edges`]) or fitted to its
/// polyline in `mesh`.
fn projected_edges(body: &crate::ModelBody, mesh: &Mesh, plane: &Plane, edges: &[usize]) -> Vec<LinkGeom> {
let mut out = Vec::new();
for (e, exact) in edges.iter().zip(exact_edges(body, plane, edges)) {
match exact {
Some(g) => out.extend(g),
None => {
if let Some(pts) = mesh.edges.get(*e) {
out.extend(fit_world(plane, pts));
}
}
}
}
out
}
/// A boundary segment as the link geometry that is the same curve.
fn link_geom(s: &Seg2) -> Option<LinkGeom> {
Some(match *s {
Seg2::Line { a, b } => LinkGeom::Line(a, b),
Seg2::Arc { center, radius, sweep, .. } if sweep.abs() >= std::f64::consts::TAU - 1e-9 => LinkGeom::Circle(center, radius),
Seg2::Arc { center, radius, start, sweep } => {
let at = |t: f64| center + Vec2::new(t.cos(), t.sin()) * radius;
// Counter-clockwise from `a` to `b`.
let (a, b) = if sweep > 0. { (at(start), at(start + sweep)) } else { (at(start + sweep), at(start)) };
LinkGeom::Arc { c: center, a, b }
}
Seg2::Conic { a, apex, b, w } => LinkGeom::Conic { a, apex, b, rho: w / (1. + w) },
Seg2::Cubic { p0, p1, p2, p3 } => LinkGeom::Spline { pts: vec![p0, p1, p2, p3], control: true, degree: 3, periodic: false },
Seg2::Bezier { n, p } => LinkGeom::Spline { pts: p.get(..=usize::from(n))?.to_vec(), control: true, degree: n, periodic: false },
})
}
/// The persistent name of the edge an `Edge` reference resolved to, for its link to keep and
/// be found by from then on. None on a mesh body, whose edges have no names.
pub fn resolved_edge_name(st: &ModelState, r: &Resolved) -> Option<String> {
@@ -331,7 +373,8 @@ fn resolve_named_edge(st: &ModelState, plane: &Plane, kind: LinkKind, body: &str
.min_by(|a, b| a.dist(near).total_cmp(&b.dist(near)))
.ok_or_else(|| lost("named projection edge"))?;
let mut wires = Vec::new();
let geom = edge_geom(plane, kind, pts, &mut wires);
let exact = exact_edges(target, plane, &[index]).pop().flatten();
let geom = edge_geom(plane, kind, exact, pts, &mut wires);
if geom.is_empty() && wires.is_empty() {
return Err(DocError::Invalid("named edge projects to nothing on this sketch plane".into()));
}
@@ -350,12 +393,7 @@ fn resolve_named_face(st: &ModelState, plane: &Plane, kind: LinkKind, body: &str
let geom = if kind == LinkKind::Intersect {
section(plane, &mesh, Some(index))
} else {
let mut geom = Vec::new();
for edge in projection_face_edges(target, &mesh, index, plane)? {
if let Some(points) = mesh.edges.get(edge) {
geom.extend(fit_world(plane, points));
}
}
let geom = projected_edges(target, &mesh, plane, &projection_face_edges(target, &mesh, index, plane)?);
let geom = merge_split_circles(geom);
if certified_planar_construction(target, index as usize) { merge_family_lines(geom)? } else { geom }
};
@@ -425,11 +463,7 @@ pub fn resolve_face_indices(st: &ModelState, plane: &Plane, kind: LinkKind, body
} else {
order::family_edges(&mesh, &selected, plane)?
};
for edge in edges {
if let Some(points) = mesh.edges.get(edge) {
geometry.extend(fit_world(plane, points));
}
}
geometry.extend(projected_edges(body, &mesh, plane, &edges));
}
if kind != LinkKind::Intersect && (indices.len() > 1 || indices.first().is_some_and(|index| certified_planar_construction(body, *index))) {
geometry = merge_family_lines(geometry)?;
+2 -2
View File
@@ -290,7 +290,7 @@ fn rule_hem_gap() -> String {
}
impl SheetRule {
/// Fusion's default: Steel (mm), 2.5 mm, K 0.44, bend radius = Thickness.
/// Default: Steel (mm), 2.5 mm, K 0.44, bend radius = Thickness.
pub fn steel() -> SheetRule {
SheetRule {
name: "Steel (mm)".into(),
@@ -1989,7 +1989,7 @@ impl SheetBody {
.sum()
}
/// Topology counted as Fusion does: a round hole is one face bounded by two closed circular
/// Topology counted by faces and loops: a round hole is one face bounded by two closed circular
/// edges (one vertex each), where the sewn solid splits it into arcs. Returns the change to
/// the sewn (faces, edges, vertices).
pub fn round_hole_correction(&self) -> (i64, i64, i64) {
+5 -5
View File
@@ -50,7 +50,7 @@
use std::collections::BTreeMap;
use std::f64::consts::{FRAC_PI_2, PI, TAU};
use solidcraft_geom::{Loop2, Mesh, Plane, Region2, Seg2, Vec2, Vec3};
use solidcraft_geom::{Loop2, Mesh, Plane, Region2, Seg2, UnitVec3, Vec2, Vec3};
use solidcraft_kernel::{Body, FaceSurface, face_surface};
use super::{FlatWork, ReliefOverride, RuleValues, SheetBody, SheetFlange, base_flange, point_in, polys_cross, signed_area, snap};
@@ -71,7 +71,7 @@ fn at(p: Vec3) -> String {
/// arc where that face is a round wall (`round[i]`: the circle's centre and the arc's middle).
/// `holes`: its inner loops, in `plane`, each with the faces of its walls.
struct Face {
n: Vec3,
n: UnitVec3,
pts: Vec<Vec3>,
across: Vec<Option<usize>>,
round: Vec<Option<(Vec3, Vec3)>>,
@@ -89,7 +89,7 @@ struct Panel {
}
/// Area of a loop in space, positive when it runs counter-clockwise about `n`.
fn area_about(pts: &[Vec3], n: Vec3) -> f64 {
fn area_about(pts: &[Vec3], n: UnitVec3) -> f64 {
let Some(&o) = pts.first() else { return 0.0 };
pts.windows(2).map(|w| (w[0] - o).cross(w[1] - o).dot(n)).sum::<f64>() / 2.0
}
@@ -227,7 +227,7 @@ fn outline(b: &Body, mesh: &Mesh, f: usize, tol: f64) -> Result<Face> {
loops.push(lp);
}
let mut walls = std::collections::HashMap::new();
let mut wall = |g: u32| *walls.entry(g).or_insert_with(|| round_wall(b, &plane, n, g));
let mut wall = |g: u32| *walls.entry(g).or_insert_with(|| round_wall(b, &plane, *n, g));
// Each loop with the faces of its walls.
let mut shaped: Vec<(Vec<(Seg2, Option<u32>)>, Vec<usize>)> = loops
.iter()
@@ -388,7 +388,7 @@ fn bend_cylinder(b: &Body, mesh: &Mesh, f: usize, tol: f64) -> Option<Chamfer> {
let Some(FaceSurface::Plane { normal: n, .. }) = face_surface(b, g) else { continue };
let radial = (p - origin) - axis * (p - origin).dot(axis);
if radial.normalized().is_some_and(|out| n.dot(out).abs() >= 1.0 - 1e-6) {
sides.push((g, n));
sides.push((g, *n));
}
}
let &[(a, na), (bb, nb)] = sides.as_slice() else { return None };
+44 -1
View File
@@ -298,7 +298,14 @@ fn measure_pair(
));
return out;
}
Ok(m) => out.overlap = Some((m.volume, m.area, m.bbox, m.centroid)),
Ok(m) => {
let (wa, wb) = (ca.as_ref().unwrap_or(&ba.body), cb.as_ref().unwrap_or(&bb.body));
if let Some(reason) = union_disagrees(wa, wb, m.volume, region.center()) {
out.unchecked = Some(reason);
return out;
}
out.overlap = Some((m.volume, m.area, m.bbox, m.centroid));
}
Err(e) => {
out.unchecked = Some(e.to_string());
return out;
@@ -375,6 +382,42 @@ fn measure_near_origin(body: &solidcraft_kernel::Body, at: solidcraft_geom::Vec3
Ok(m)
}
/// Does the shared volume of `a` and `b` disagree with the one their union implies,
/// vol(a) + vol(b) − vol(a ∪ b)? The intersection can come out well formed and still wrong (a
/// mirrored pair read three times its volume). `None` when they agree, or when the union gives no
/// answer to compare with.
fn union_disagrees(a: &solidcraft_kernel::Body, b: &solidcraft_kernel::Body, shared: f64, at: solidcraft_geom::Vec3) -> Option<String> {
let (union, report) = solidcraft_kernel::capture_boolean_diagnostics(|| solidcraft_kernel::boolean(a, b, solidcraft_kernel::BoolOp::Union));
let union = union.ok().flatten().filter(|_| report.unverified().is_none())?;
let volume = |x: &solidcraft_kernel::Body| measure_near_origin(x, at).ok().map(|m| m.volume).filter(|v| v.is_finite());
contradicted(shared, volume(a)?, volume(b)?, volume(&union)?)
}
/// How far vol(a) + vol(b) − vol(a ∪ b) can stray from the shared volume by measuring alone,
/// as a share of vol(a) + vol(b) + vol(a ∪ b). [`solidcraft_kernel::measure`] facets a curved
/// face with chords of up to 3e-4 of its radius where the radius sets them, which leaves the
/// volume it encloses short by up to (4/3) × 3e-4 = 4e-4. That shortfall is the same for a face
/// measured on its own body and in the union, and cancels in the difference, except where the
/// chord changes with the body measured (it also follows the body's size). Measured, that
/// change stays near 2e-5 of the three volumes: 2.1e-5 for a 40 mm roller pressed 0.2 mm into a
/// 2 m plate (`a_roller_pressed_into_a_large_plate_is_reported`), 2e-5 and 1e-6 in the examples
/// of `the_union_check_allows_for_the_bodies_measuring_error`. This allows five times that.
const VOLUME_NOISE: f64 = 1e-4;
/// [`union_disagrees`] on measured volumes: `shared` against vol(a) + vol(b) − vol(a ∪ b).
/// That difference of large numbers carries the measuring error of all three, so a small overlap
/// on large bodies can come out near zero or below it: then the union cannot tell, and a shared
/// part small next to both bodies stands. A shared part the union clearly contradicts (412 mm³
/// where the bodies of a few thousand and their union imply 141) does not.
fn contradicted(shared: f64, va: f64, vb: f64, vu: f64) -> Option<String> {
let implied = va + vb - vu;
let noise = VOLUME_NOISE * (va + vb + vu) + 1e-6;
let allowed = 0.01 * shared.max(implied.abs()) + noise;
let unresolved = implied <= noise && shared <= 0.1 * va.min(vb);
((implied - shared).abs() > allowed && !unresolved)
.then(|| format!("the shared part measures {shared:.4} mm³ but the bodies and their union imply {implied:.4} mm³, so neither is reported"))
}
/// Is a measured shared part well formed? A part whose shell came out inside out or open can
/// carry a volume with its centre outside its own box, or more volume than its box holds.
fn plausible(volume: f64, centre: solidcraft_geom::Vec3, bbox: Aabb3) -> bool {
+71
View File
@@ -251,3 +251,74 @@ fn a_small_gap_past_a_corner_is_not_contact() {
assert!((f(&r["nearest"][a][0]["distance_mm"]) - want).abs() < 1e-9, "{a} {b}: {r}");
}
}
/// The shared volume is checked against the bodies and their union: two 10 mm cubes sharing a
/// 3 mm corner cube agree at 27 mm³, and a shared part read as three times that (as a mirrored
/// pair once was) does not.
#[test]
fn a_shared_volume_the_union_contradicts_is_caught() {
use solidcraft_geom::Vec3;
let a = solidcraft_kernel::box_solid(Vec3::new(0.0, 0.0, 0.0), Vec3::new(10.0, 10.0, 10.0)).unwrap_or_else(|e| panic!("{e}"));
let b = solidcraft_kernel::box_solid(Vec3::new(7.0, 7.0, 7.0), Vec3::new(17.0, 17.0, 17.0)).unwrap_or_else(|e| panic!("{e}"));
let at = Vec3::new(8.5, 8.5, 8.5);
assert_eq!(super::union_disagrees(&a, &b, 27.0, at), None);
assert!(super::union_disagrees(&a, &b, 81.0, at).is_some());
}
/// The check works on measured volumes, and vol(a) + vol(b) − vol(a ∪ b) carries the error of
/// all three: a small overlap on large bodies can come out near zero, or below it. A union that
/// cannot resolve the shared part leaves it standing; one that clearly contradicts it does not.
#[test]
fn the_union_check_allows_for_the_bodies_measuring_error() {
use super::contradicted;
// 0.96 mm³ shared where the union of 40 000 and 300 mm³ implies -0.75: noise, not a contradiction.
assert_eq!(contradicted(0.96, 40_000.0, 300.0, 40_300.75), None);
// 5.92 mm³ of a small body in a large sheet, with the union 0.4 mm³ off the bodies' sum.
assert_eq!(contradicted(5.92, 250_000.0, 80.0, 250_074.48), None);
// Past the noise, but the union implies nothing at all while the shared part is small next
// to both bodies: the union cannot tell, and the shared part stands.
assert_eq!(contradicted(6.0, 400.0, 200.0, 600.5), None);
// 412 mm³ shared where bodies of a few thousand and their union imply 141: caught.
assert!(contradicted(411.98, 3_200.0, 3_150.0, 6_350.0 - 141.18).is_some());
// A large shared part the union says is not there at all: caught too.
assert!(contradicted(900.0, 3_200.0, 3_150.0, 6_350.0).is_some());
}
/// A small box pressed 0.6 mm into a 2000 × 1000 × 1 mm plate shares 3 × 3 × 0.6 = 5.4 mm³: an
/// overlap, not an unchecked pair.
#[test]
fn a_small_overlap_on_a_large_plate_is_reported() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 2000, "width": 1000, "height": 1, "corner": [0, 0, 0], "operation": "new", "body_name": "Plate"}));
run(&mut s, "solid.box", json!({"length": 3, "width": 3, "height": 3, "corner": [700, 400, 0.4], "operation": "new", "body_name": "Box"}));
let r = run(&mut s, "inspect.clearance", json!({}));
assert_eq!(r["unchecked"].as_array().map(Vec::len), Some(0), "{r}");
let overlaps = r["overlaps"].as_array().cloned().unwrap_or_default();
assert_eq!(overlaps.len(), 1, "{r}");
assert_eq!(pair(&overlaps[0]), ["Box", "Plate"]);
assert!((f(&overlaps[0]["volume_mm3"]) - 5.4).abs() < 1e-6, "{r}");
}
/// A 40 mm roller lying on a 2000 × 1000 × 3 mm plate, pressed 0.2 mm into it, shares the
/// circular segment along its 10 mm (7.53 mm³). Its curved wall is faceted differently on its
/// own and in the union, so vol(a) + vol(b) − vol(a ∪ b) comes out 0.64 mm³ over the shared
/// volume (2e-5 of the three volumes, past the 1 % of the shared part): the measuring allowance
/// keeps it an overlap, not an unchecked pair.
#[test]
fn a_roller_pressed_into_a_large_plate_is_reported() {
let (r, depth, length) = (20.0_f64, 0.2_f64, 10.0);
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 2000, "width": 1000, "height": 3, "corner": [0, 0, 0], "operation": "new", "body_name": "Plate"}));
run(
&mut s,
"solid.cylinder",
json!({"radius": r, "height": length, "base": [100, 50, 3.0 + r - depth], "axis": [1, 0, 0], "operation": "new", "body_name": "Roller"}),
);
let res = run(&mut s, "inspect.clearance", json!({}));
assert_eq!(res["unchecked"].as_array().map(Vec::len), Some(0), "{res}");
let overlaps = res["overlaps"].as_array().cloned().unwrap_or_default();
assert_eq!(overlaps.len(), 1, "{res}");
assert_eq!(pair(&overlaps[0]), ["Plate", "Roller"]);
let want = (r * r * ((r - depth) / r).acos() - (r - depth) * (r * r - (r - depth) * (r - depth)).sqrt()) * length;
assert!((f(&overlaps[0]["volume_mm3"]) - want).abs() < 0.01 * want, "{want} {res}");
}
+24 -15
View File
@@ -11,6 +11,8 @@ use super::CommandSpec;
use crate::params::{bad, bool_, str_, string_list, vec3};
use crate::{Result, Session};
#[path = "component_matrix.rs"]
pub(super) mod matrix;
#[path = "component_properties.rs"]
pub(super) mod properties;
#[path = "component_transfer.rs"]
@@ -52,7 +54,7 @@ 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]; 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). Nested occurrence definitions remain shared"),
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"),
@@ -416,21 +418,28 @@ fn move_occurrence(s: &mut Session, p: &Value) -> Result<Value> {
let name = s.doc.components.iter().find(|c| c.id == group).map(|c| c.name.clone()).unwrap_or_default();
return Err(bad(cmd, format!("{} is held in {name} by a rigid group: move {name}, or Unlock Contents (component.unlock) first", o.name)));
}
let t = move_vector(p, "translate", Vec3::ZERO, true, cmd)?;
let axis = move_vector(p, "axis", Vec3::Z, false, cmd)?;
let angle = match p.get("angle") {
Some(Value::String(e)) => s.doc.eval(e, solidcraft_doc::expr::Kind::Angle).map_err(|e| bad(cmd, e.to_string()))?,
None => 0.0,
Some(value) => value.as_f64().ok_or_else(|| bad(cmd, "angle must be a number or angle expression"))?,
let delta = if let Some(m) = p.get("matrix") {
if let Some(k) = ["translate", "axis", "angle", "origin"].into_iter().find(|k| p.get(*k).is_some()) {
return Err(bad(cmd, format!("give either matrix or {k}, not both")));
}
matrix::rigid(m, matrix::tolerance(p, cmd)?, cmd)?
} else {
let t = move_vector(p, "translate", Vec3::ZERO, true, cmd)?;
let axis = move_vector(p, "axis", Vec3::Z, false, cmd)?;
let angle = match p.get("angle") {
Some(Value::String(e)) => s.doc.eval(e, solidcraft_doc::expr::Kind::Angle).map_err(|e| bad(cmd, e.to_string()))?,
None => 0.0,
Some(value) => value.as_f64().ok_or_else(|| bad(cmd, "angle must be a number or angle expression"))?,
};
if !(t.is_finite() && axis.is_finite() && angle.is_finite()) {
return Err(bad(cmd, "non-finite move"));
}
if angle != 0.0 && axis.normalized().is_none() {
return Err(bad(cmd, "rotation axis must have a finite nonzero length"));
}
let origin = move_vector(p, "origin", Vec3::ZERO, true, cmd)?;
solidcraft_doc::rigid(t, origin, axis, angle)
};
if !(t.is_finite() && axis.is_finite() && angle.is_finite()) {
return Err(bad(cmd, "non-finite move"));
}
if angle != 0.0 && axis.normalized().is_none() {
return Err(bad(cmd, "rotation axis must have a finite nonzero length"));
}
let origin = move_vector(p, "origin", Vec3::ZERO, true, cmd)?;
let delta = solidcraft_doc::rigid(t, origin, axis, angle);
let space = match p.get("space") {
None => "local",
Some(Value::String(space)) => space.as_str(),
@@ -22,6 +22,7 @@ fn joint(id: u64, a: u64, b: u64) -> Joint {
offset: 2.0,
angle: 0.1,
suppressed: false,
exprs: Default::default(),
}
}
+68
View File
@@ -15,6 +15,11 @@ use super::CommandSpec;
use crate::params::{bad, str_, vec3};
use crate::{EngineError, Result, Session};
#[cfg(test)]
#[path = "component_relink_tests.rs"]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod relink_tests;
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; \
@@ -23,6 +28,9 @@ pub static COMMANDS: &[CommandSpec] = &[
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",
),
CommandSpec::new("component.relink", "Relink Source", relink).icon("update").params(
"component: id or name of a linked component; path: a saved .solidcraft design — replaces the source and linked bodies for all its occurrences, preserving identities and placements; returns path, previous_path, warnings, joint_errors and joint_conflicts",
),
CommandSpec::new("component.break_link", "Break Link", break_link)
.icon("break")
.params("component: id or name of a linked component — keeps its bodies as they are and forgets the design file"),
@@ -216,6 +224,66 @@ fn update_links(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"updated": updated, "unchanged": unchanged, "warnings": warnings}))
}
fn relink(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "component.relink";
let comp = component_key(s, p, "component", cmd)?;
let link = s.doc.links.get(&comp).cloned().ok_or_else(|| bad(cmd, "that component is not linked to a design"))?;
if !matches!(s.doc.feature(link.feature), Some(f) if f.component == comp && matches!(f.kind, FeatureKind::Import { .. })) {
return Err(bad(cmd, "the linked bodies are gone; break the link or insert the design again"));
}
let given = str_(p, "path").filter(|x| !x.trim().is_empty() && x.len() < 4096).ok_or_else(|| bad(cmd, "`path` is required"))?;
let file = absolute(Path::new(given));
if !file.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("solidcraft")) {
return Err(bad(cmd, "choose a saved .solidcraft design"));
}
// The ordinary lexical check in linked_bodies also works in the browser VFS. On native
// hosts, reject an alias of this assembly too (a symlink or a path with different case).
if let (Ok(candidate), Some(Ok(own))) = (file.canonicalize(), s.path.as_deref().map(std::fs::canonicalize))
&& candidate == own
{
return Err(bad(cmd, "a design can't be linked into itself"));
}
// Read/evaluate the candidate before changing anything. Session::execute owns the single
// undo step and restores both the document and evaluated state on any later failure.
let (_, sf) = linked_bodies(&file, s.path.as_deref()).map_err(|e| bad(cmd, e.to_string()))?;
let geometry_changed = match (s.doc.feature(link.feature).map(|f| &f.kind), &sf.kind) {
(Some(FeatureKind::Import { step: old, .. }), FeatureKind::Import { step, .. }) => old != step,
_ => true,
};
let path = stored(s.path.as_deref(), &file);
let mut warnings = sf.warnings;
if geometry_changed
&& s.doc
.assembly
.joints
.iter()
.any(|j| s.doc.occurrences.iter().any(|o| o.component == comp && (j.a.occurrence == o.id || j.b.occurrence == o.id)))
{
warnings.push("the linked geometry changed; check this component's joints, even when the solver reports no error".to_string());
}
if let Some(f) = s.doc_mut().feature_mut(link.feature) {
if f.body_names.len() != sf.body_names.len() {
f.body_names = sf.body_names;
}
f.kind = sf.kind;
}
s.doc_mut().links.insert(comp, DesignLink { path: path.clone(), feature: link.feature });
s.refresh();
if let Some(error) = s.model.result(link.feature).and_then(|r| r.error.clone()) {
return Err(bad(cmd, error));
}
// Report the solver's diagnostics. A resolved joint can still describe the wrong
// connection after a source edit; the reminder above does not depend on solve errors.
let sol = solidcraft_doc::joints::solve(&s.doc, &s.model.state());
let diagnostics =
|rows: &[(u64, String, String)]| rows.iter().map(|(id, name, why)| json!({"joint": id, "name": name, "why": why})).collect::<Vec<_>>();
for (_, name, why) in sol.errors.iter().chain(&sol.conflicts) {
warnings.push(format!("joint {name}: {why}"));
}
Ok(json!({"component": comp, "feature": link.feature, "path": path, "previous_path": link.path, "geometry_changed": geometry_changed,
"warnings": warnings, "joint_errors": diagnostics(&sol.errors), "joint_conflicts": diagnostics(&sol.conflicts)}))
}
fn break_link(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "component.break_link";
let comp = component_key(s, p, "component", cmd)?;
+121
View File
@@ -0,0 +1,121 @@
//! Placement given as a matrix: a script's 3×4 (rotation rows and a translation column) or
//! 4×4 transform, read as a rigid move. A rotation that is one only up to rounding is
//! orthonormalised; a scale, shear or mirror is refused with the reason (a mirror is
//! `solid.mirror`'s, not a placement's).
use serde_json::Value;
use solidcraft_doc::{IDENTITY, Mat};
use crate::Result;
use crate::params::bad;
/// The default largest departure from a rotation a matrix may show: |RᵀR − I| per entry.
pub const TOLERANCE: f64 = 1e-9;
/// The loosest tolerance a caller may ask for.
const MAX_TOLERANCE: f64 = 1e-3;
/// The `tolerance` parameter: the default, or a finite positive number no looser than 1e-3.
pub fn tolerance(p: &Value, cmd: &str) -> Result<f64> {
match p.get("tolerance") {
None | Some(Value::Null) => Ok(TOLERANCE),
Some(v) => v
.as_f64()
.filter(|t| t.is_finite() && *t > 0.0 && *t <= MAX_TOLERANCE)
.ok_or_else(|| bad(cmd, format!("tolerance must be a number above 0 and at most {MAX_TOLERANCE}"))),
}
}
/// Read `matrix` as a rigid transform. It is row-major: 12 numbers (three rows of rotation
/// and translation, `[r00, r01, r02, tx, r10, …, tz]`) or 16 (a fourth row `0, 0, 0, 1`), flat
/// or as rows.
pub fn rigid(v: &Value, tolerance: f64, cmd: &str) -> Result<Mat> {
let numbers = numbers(v).ok_or_else(|| bad(cmd, "matrix must be 12 numbers (3x4, row by row) or 16 (4x4), flat or as rows"))?;
if numbers.iter().any(|x| !x.is_finite()) {
return Err(bad(cmd, "matrix must contain finite numbers"));
}
let at = |row: usize, col: usize| numbers.get(row * 4 + col).copied().unwrap_or(0.0);
if numbers.len() == 16 {
let last = [at(3, 0), at(3, 1), at(3, 2), at(3, 3) - 1.0];
if last.iter().any(|x| x.abs() > tolerance) {
return Err(bad(cmd, "matrix is not affine: its fourth row must be 0, 0, 0, 1 (a projective transform is not a placement)"));
}
}
if (0..3).any(|r| at(r, 3).abs() > 1e9) {
return Err(bad(cmd, "matrix translation exceeds supported numeric range"));
}
// r[row][col], then |RᵀR − I| measures how far it is from a rotation.
let r: [[f64; 3]; 3] = std::array::from_fn(|row| std::array::from_fn(|col| at(row, col)));
let deviation = (0..3)
.flat_map(|i| (0..3).map(move |j| (i, j)))
.map(|(i, j)| {
let dot: f64 = (0..3).map(|k| r[k][i] * r[k][j]).sum();
(dot - if i == j { 1.0 } else { 0.0 }).abs()
})
.fold(0.0, f64::max);
let det = r[0][0] * (r[1][1] * r[2][2] - r[1][2] * r[2][1]) - r[0][1] * (r[1][0] * r[2][2] - r[1][2] * r[2][0])
+ r[0][2] * (r[1][0] * r[2][1] - r[1][1] * r[2][0]);
if det < 0.0 {
return Err(bad(cmd, "matrix mirrors (its determinant is negative): a placement only turns and moves; mirror bodies with solid.mirror"));
}
if !deviation.is_finite() || deviation > tolerance {
return Err(bad(
cmd,
format!("matrix is not rigid: it scales or shears by {deviation:.3e} (|RᵀR − I|), above the tolerance {tolerance:.0e}"),
));
}
let rot = orthonormal(r);
let mut m = IDENTITY;
for (col, column) in m.iter_mut().enumerate().take(3) {
for (row, cell) in column.iter_mut().enumerate().take(3) {
*cell = rot[row][col];
}
}
m[3] = [at(0, 3), at(1, 3), at(2, 3), 1.0];
Ok(m)
}
/// The matrix's numbers, row by row: 12 or 16 of them, flat or nested as 3 or 4 rows of 4.
fn numbers(v: &Value) -> Option<Vec<f64>> {
let a = v.as_array()?;
let flat: Vec<f64> = if a.iter().all(Value::is_array) {
if !matches!(a.len(), 3 | 4) {
return None;
}
let mut out = Vec::with_capacity(16);
for row in a {
let row = row.as_array().filter(|r| r.len() == 4)?;
for x in row {
out.push(x.as_f64()?);
}
}
out
} else {
a.iter().map(Value::as_f64).collect::<Option<_>>()?
};
matches!(flat.len(), 12 | 16).then_some(flat)
}
/// The rotation nearest to `r` (its polar factor), by Newton's iteration R ← (R + R⁻ᵀ) / 2,
/// which converges quadratically from a near-rotation; a few rounds reach machine precision.
fn orthonormal(mut r: [[f64; 3]; 3]) -> [[f64; 3]; 3] {
for _ in 0..4 {
let Some(inv_t) = inverse_transpose(&r) else { break };
for (row, inv_row) in r.iter_mut().zip(inv_t) {
for (x, y) in row.iter_mut().zip(inv_row) {
*x = 0.5 * (*x + y);
}
}
}
r
}
/// (R⁻¹)ᵀ: the cofactor matrix over the determinant.
fn inverse_transpose(r: &[[f64; 3]; 3]) -> Option<[[f64; 3]; 3]> {
let a = |i: usize, j: usize| r[i % 3][j % 3];
let cof: [[f64; 3]; 3] = std::array::from_fn(|i| std::array::from_fn(|j| a(i + 1, j + 1) * a(i + 2, j + 2) - a(i + 1, j + 2) * a(i + 2, j + 1)));
let det: f64 = (0..3).map(|j| r[0][j] * cof[0][j]).sum();
if !det.is_finite() || det.abs() < 1e-12 {
return None;
}
Some(cof.map(|row| row.map(|x| x / det)))
}
@@ -0,0 +1,147 @@
use std::path::{Path, PathBuf};
use serde_json::{Value, json};
use crate::Session;
fn run(s: &mut Session, command: &str, params: Value) -> Value {
s.execute(command, &params).unwrap_or_else(|e| panic!("{command}: {e}"))
}
fn folder(case: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("solidcraft-relink-{case}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn part(path: &Path, height: u32) {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length": 10, "width": 20, "height": height, "corner": [0,0,0]}));
run(&mut s, "file.save", json!({"path": path}));
}
fn volume(s: &mut Session) -> f64 {
run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap()
}
#[test]
fn relink_repairs_a_moved_source_for_every_instance_in_one_undo_step() {
let dir = folder("moved");
let old = dir.join("old.solidcraft");
let moved = dir.join("moved.solidcraft");
let assembly = dir.join("assembly.solidcraft");
part(&old, 30);
let mut s = Session::default();
run(&mut s, "file.save", json!({"path": assembly}));
let inserted = run(&mut s, "component.insert_design", json!({"path": old}));
let component = inserted["component"].as_u64().unwrap();
let first = inserted["occurrence"].as_u64().unwrap();
let second = run(&mut s, "occurrence.copy", json!({"occurrence": first, "translate": [70,30,10]}))["occurrence"].as_u64().unwrap();
run(&mut s, "occurrence.move", json!({"occurrence": second, "translate": [0,12,0], "capture": false}));
let occurrences = s.doc.occurrences.clone();
let pending = s.pending_moves.clone();
let before = serde_json::to_value(&*s.doc).unwrap();
let undo = s.undo.len();
std::fs::rename(&old, &moved).unwrap();
part(&moved, 45);
let result = run(&mut s, "component.relink", json!({"component": component, "path": moved}));
assert_eq!(result["previous_path"], "old.solidcraft");
assert_eq!(result["path"], "moved.solidcraft");
assert_eq!(result["geometry_changed"], true);
assert_eq!(result["warnings"], json!([]));
assert_eq!(s.doc.occurrences, occurrences);
assert_eq!(s.pending_moves, pending);
assert_eq!(s.undo.len(), undo + 1);
assert!((volume(&mut s) - 18000.0).abs() < 1e-5);
let after = serde_json::to_value(&*s.doc).unwrap();
run(&mut s, "edit.undo", json!({}));
assert_eq!(serde_json::to_value(&*s.doc).unwrap(), before);
assert_eq!(s.pending_moves, pending);
assert!((volume(&mut s) - 12000.0).abs() < 1e-5);
run(&mut s, "edit.redo", json!({}));
assert_eq!(serde_json::to_value(&*s.doc).unwrap(), after);
run(&mut s, "file.save", json!({}));
let mut reopened = Session::default();
run(&mut reopened, "doc.open", json!({"path": assembly}));
assert_eq!(run(&mut reopened, "component.links", json!({}))["links"][0]["path"], "moved.solidcraft");
assert_eq!(run(&mut reopened, "component.links", json!({}))["links"][0]["found"], true);
assert!((volume(&mut reopened) - 18000.0).abs() < 1e-5);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn relink_rejects_bad_sources_and_targets_without_touching_the_design_or_undo() {
let dir = folder("failures");
let source = dir.join("source.solidcraft");
let assembly = dir.join("assembly.solidcraft");
let empty = dir.join("empty.solidcraft");
let corrupt = dir.join("corrupt.solidcraft");
part(&source, 30);
let mut s = Session::default();
let component = run(&mut s, "component.insert_design", json!({"path": source}))["component"].as_u64().unwrap();
run(&mut s, "file.save", json!({"path": assembly}));
run(&mut Session::default(), "file.save", json!({"path": empty}));
std::fs::write(&corrupt, b"not a design").unwrap();
let before = serde_json::to_value(&*s.doc).unwrap();
let undo = s.undo.len();
for params in [
json!({}),
json!({"component": component}),
json!({"component": false, "path": source}),
json!({"component": 0, "path": source}),
json!({"component": "unknown", "path": source}),
json!({"component": component, "path": " "}),
json!({"component": component, "path": false}),
json!({"component": component, "path": dir.join("missing.solidcraft")}),
json!({"component": component, "path": dir.join("source.step")}),
json!({"component": component, "path": empty}),
json!({"component": component, "path": corrupt}),
json!({"component": component, "path": assembly}),
] {
assert!(s.execute("component.relink", &params).is_err(), "{params}");
assert_eq!(serde_json::to_value(&*s.doc).unwrap(), before, "{params}");
assert_eq!(s.undo.len(), undo);
assert!((volume(&mut s) - 6000.0).abs() < 1e-5);
}
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn relink_keeps_joint_definitions_and_reports_solver_diagnostics() {
let dir = folder("joints");
let original = dir.join("original.solidcraft");
let replacement = dir.join("replacement.solidcraft");
part(&original, 30);
part(&replacement, 45);
let mut s = Session::default();
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();
// 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(
&mut s,
"joint.create",
json!({"name": "Seat", "type": "rigid", "a": {"occurrence": 0, "point": [0,0,0]}, "b": {"occurrence": occurrence, "point": [0,0,0]}}),
);
let joints = s.doc.assembly.joints.clone();
let identical = dir.join("identical.solidcraft");
std::fs::copy(&original, &identical).unwrap();
let result = run(&mut s, "component.relink", json!({"component": component, "path": identical}));
assert_eq!(result["geometry_changed"], false, "renaming a source is not a geometry change");
assert_eq!(result["warnings"], json!([]));
assert_eq!(s.doc.assembly.joints, joints);
let result = run(&mut s, "component.relink", json!({"component": component, "path": replacement}));
assert_eq!(s.doc.assembly.joints, joints);
assert!(result["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|w| w.contains("check this component's joints"))));
// A deliberately lost named face exercises the ordinary solver diagnostics, without
// requiring relink to guess which face of a different design was intended.
let joint = s.doc_mut().assembly.joints.first_mut().unwrap();
joint.b.snap =
solidcraft_doc::joints::Snap::FaceCenter { pick: solidcraft_geom::Vec3::ZERO, face: Some("no-such-linked-face".to_string()), x: None };
s.refresh();
let result = run(&mut s, "component.relink", json!({"component": component, "path": replacement}));
assert!(result["joint_errors"].as_array().unwrap().iter().any(|e| e["name"] == "Seat"));
assert!(result["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|w| w.contains("joint Seat:"))));
let _ = std::fs::remove_dir_all(dir);
}
@@ -148,10 +148,10 @@ fn transferred_thread_follows_body_frame_without_changing_world_axis() {
run(&mut s, "component.move_bodies", json!({"component":to,"bodies":["Shaft"]}));
let after = s.world_state().threads[0].clone();
assert!(after.axis_point.dist(before.axis_point) < 1e-6);
assert!(after.axis.dist(before.axis) < 1e-6);
assert!(after.axis.dist(*before.axis) < 1e-6);
let mut reopened = Session { doc: solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap().into(), ..Session::default() };
reopened.refresh();
assert!(reopened.world_state().threads[0].axis.dist(before.axis) < 1e-6);
assert!(reopened.world_state().threads[0].axis.dist(*before.axis) < 1e-6);
}
#[test]
+4
View File
@@ -2188,6 +2188,10 @@ mod offset_identity_tests;
#[path = "offset_edit_guard_tests.rs"]
mod offset_edit_guard_tests;
#[cfg(test)]
#[path = "offset_ends_tests.rs"]
mod offset_ends_tests;
#[cfg(test)]
#[path = "offset_plane_face_tests.rs"]
mod offset_plane_face_tests;
+15 -2
View File
@@ -24,7 +24,7 @@ pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("file.insert_step", "Insert STEP", insert_step)
.at("SOLID", "INSERT")
.icon("import")
.params("path: STEP, IGES, FreeCAD .FCStd or Rhino .3dm file (its bodies join the design as an Import base feature in a new component named after the file, placed in the active component; an assembly's products become nested components, a repeated part one component with an occurrence per placement, all held as one rigid group until component.unlock → component, component_name, occurrence, subcomponents?, rigid_joints?); name? (the feature's and the component's); up?: z|y (default z: the file's axes are kept; y: the file has Y up, so the import's component is turned a quarter turn about X, Y becoming Z → stand_up: its occurrence); names?: solid|product|instance (what the bodies are named after: the solid's own name, else the product's (default); the name of the product (part) that holds the body; the name of the assembly occurrence (instance), else the product's)"),
.params("path: STEP, IGES, FreeCAD .FCStd or Rhino .3dm file (its bodies join the design as an Import base feature in a new component named after the file, placed in the active component; an assembly's products become nested components, a repeated part one component with an occurrence per placement, all held as one rigid group until component.unlock → component, component_name, occurrence, subcomponents?, rigid_joints?); name? (the feature's and the component's); up?: z|y (default z: the file's axes are kept; y: the file has Y up, so the import's component is turned a quarter turn about X, Y becoming Z → stand_up: its occurrence); names?: solid|product|instance (what the bodies are named after: the solid's own name, else the product's (default); the name of the product (part) that holds the body; the name of the assembly occurrence (instance), else the product's); matrix?: [12 or 16 numbers] (where the new component's occurrence sits in the active component, after `up`: a rigid row-major 3x4 rotation-and-translation or 4x4 matrix, flat or as rows, as occurrence.move takes it; a scale, shear or mirror is refused before the file is read); tolerance? (see occurrence.move)"),
CommandSpec::new("file.insert_mesh", "Insert Mesh", insert_mesh)
.at("SOLID", "INSERT")
.icon("import")
@@ -218,7 +218,20 @@ fn insert_step(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, "only STEP, IGES, FreeCAD and Rhino files (.step, .stp, .igs, .iges, .fcstd, .3dm) can be inserted"));
}
let options = step_options(p, cmd)?;
add_step(s, path, str_(p, "name"), &options)
let placement = match p.get("matrix") {
None | Some(Value::Null) => None,
Some(m) => Some(super::component::matrix::rigid(m, super::component::matrix::tolerance(p, cmd)?, cmd)?),
};
let mut out = add_step(s, path, str_(p, "name"), &options)?;
if let Some(m) = placement {
let occ = out["occurrence"].as_u64().ok_or_else(|| EngineError::Other("the import has no placement to move".into()))?;
if let Some(o) = s.doc_mut().occurrences.iter_mut().find(|o| o.id == occ) {
o.transform = solidcraft_doc::mat_mul(&m, &o.transform);
out["transform"] = json!(o.transform);
}
s.refresh();
}
Ok(out)
}
fn open(s: &mut Session, p: &Value) -> Result<Value> {
+1
View File
@@ -369,6 +369,7 @@ fn add_rigid_joints(s: &mut Session, pairs: &[(u64, u64, Mat)]) -> Result<Vec<u6
offset: 0.0,
angle: 0.0,
suppressed: false,
exprs: Default::default(),
}
})
.collect();
+1 -1
View File
@@ -88,7 +88,7 @@ fn measure(s: &mut Session, p: &Value) -> Result<Value> {
if !want.is_empty() && bodies.len() != want.len() {
return Err(bad("inspect.measure", "unknown body name"));
}
// Sheet metal keeps the edges where bends meet flat faces (as Fusion does): count its
// Sheet metal keeps the edges where bends meet flat faces (so bends stay visible): count its
// B-rep faces as they are, without merging coplanar neighbours.
let sheets = s.model.state().sheets.clone();
for b in &mut bodies {
+100 -11
View File
@@ -8,7 +8,7 @@ use std::collections::{BTreeMap, BTreeSet};
use serde_json::{Value, json};
use solidcraft_doc::expr::Kind;
use solidcraft_doc::joints::cam::{self, Cam, CamProfile, Follower};
use solidcraft_doc::joints::{self, Coupling, Joint, JointKind, JointOrigin, MotionLink, NamedOrigin, Size, Snap, XRef};
use solidcraft_doc::joints::{self, Coupling, Joint, JointExprs, JointKind, JointOrigin, MotionLink, NamedOrigin, Size, Snap, XRef};
use solidcraft_geom::{Aabb3, Vec3};
use super::CommandSpec;
@@ -23,7 +23,10 @@ pub static COMMANDS: &[CommandSpec] = &[
"type: rigid|revolute|slider|cylindrical|pin_slot|planar|ball; a, b: joint origins ({occurrence, face|circle|edge|point: [x,y,z] in world, or face|circle: \"<face name>\"; end?, x? as joint.origin} | origin name); \
values?: [angles in deg or expressions, distances]; limits?: [[min, max] | null…]; flip?: bool (default false: the origins are mated, \
b's z against a's as two faces meet; true: b's z along a's, so two origins given at one point with the same z stay as they are); \
offset?; angle?; name?; quiet?: bool (leave out dof and fixed)",
offset?: mm or an expression; angle?: deg or an expression (default: none, or a half turn where that turns b less, so faces that \
already meet stay as they are); name?; quiet?: bool (leave out dof and fixed). An expression (text) is \
kept with the joint and worked out again whenever the design changes, so the joint follows the parameters it names; a joint whose \
expression no longer works out (a parameter deleted) is left out of the solve with the reason in its errors",
),
CommandSpec::new("joint.as_built", "As-Built Joint", as_built).at("SOLID", "ASSEMBLE").icon("as_built").params(
"type, a: occurrence, b: occurrence (stay where they are); at?: joint origin (default: b's origin) — the joint's frame; \
@@ -31,7 +34,7 @@ pub static COMMANDS: &[CommandSpec] = &[
),
CommandSpec::new("joint.rigid_group", "Rigid Group", rigid_group).at("SOLID", "ASSEMBLE").icon("rigid_group").params("occurrences: [ids or names] (move together, as they are)"),
CommandSpec::new("joint.drive", "Drive Joints", drive).at("SOLID", "ASSEMBLE").icon("drive").params(
"joint: id|name; value | values: [angles in deg or expressions, distances] (values not given keep theirs; clamped to the limits; the other joints of a \
"joint: id|name; value | values: [angles in deg or expressions, distances] (values not given keep theirs; a value driven drops the expression joint.create gave it; clamped to the limits; the other joints of a \
closed loop follow, and the motion stops where the loop can no longer close → stopped_by_loop); quiet?: bool (leave out dof \
and fixed) → values: [{name, value, unit: deg|mm, limits}] as joint.list gives them; moved: whether any occurrence moved; \
reason: why the joint can move nothing (suppressed, its origins lost, or its chain joined to nothing grounded → unconnected: \
@@ -65,10 +68,12 @@ pub static COMMANDS: &[CommandSpec] = &[
"a: the cam's joint (revolute); b: the follower's joint, a slider (a translating follower) or a revolute (an oscillating one), b driven by a; profile: {sketch: id|name, index?: the profile (default: the largest)} | {edges: [[x,y,z]…] (points on edges of the cam's bodies forming one closed loop, world), occurrence?}: the cam's outline, in a plane square to its axis, read again whenever the design changes; follower?: knife|roller|flat (default knife; flat: a face square to a slider's line); radius?: the roller's radius (mm, an expression or {circle: [x,y,z], occurrence?}: the roller's round face, which is its tip too); tip?: a joint origin where the follower touches: its knife's point or its roller's centre, such as {circle: [x,y,z]} on the roller (needed for an oscillating follower; a translating one's line runs through it, by default along its slider's axis); flip?: bool (ride the cam from the other side than the one the follower is on now); link?: index (replace that motion link) → link, lift: {min, max, rise, unit} (b's travel over a turn of the cam), reverse (the side it rides from), follower_offset (mm: how far a translating follower's line passes from the cam's axis); b keeps where it is",
),
CommandSpec::new("joint.limits", "Joint Limits", limits).icon("joint").params("joint; index?: value index (default 0); min?, max? (omit both to clear)"),
CommandSpec::new("joint.edit", "Edit Joint", edit).icon("edit").params("joint; type?, flip?, offset?, angle?, suppressed?, name?"),
CommandSpec::new("joint.edit", "Edit Joint", edit)
.icon("edit")
.params("joint; type?, flip?, offset?, angle? (a number, or an expression the joint then follows), suppressed?, name?"),
CommandSpec::new("joint.delete", "Delete Joint", delete).icon("delete").params("joint: id|name (or origin: name, link: index)"),
CommandSpec::new("joint.list", "List Joints", list).icon("list").noundo().params(
"→ joints (type, occurrences, values, limits), origins, links, degrees of freedom, conflicts, floating: [{occurrences, joints}] \
"→ joints (type, occurrences, values, limits; expressions: {offset?, angle?, values?} when they were typed as expressions), origins, links, degrees of freedom, conflicts, floating: [{occurrences, joints}] \
(joint chains joined to nothing grounded: the solver places none of them, so driving them moves nothing)",
),
CommandSpec::new("joint.solve", "Solve Joints", solve_cmd).icon("compute").params("re-place the occurrences from the joints → conflicts, errors, dof"),
@@ -80,10 +85,39 @@ pub static COMMANDS: &[CommandSpec] = &[
/// Re-solve the joints and move the occurrences they place (after any edit). Gears, racks and
/// screws first take their ratios from the radii and pitches they are defined by.
pub fn resolve(s: &mut Session) -> joints::Solution {
reexpress(s);
recouple(s);
resolve_holding(s, &BTreeMap::new())
}
/// Work out the offsets, angles and values joints were given as expressions again (a parameter
/// may have changed). One that can't be worked out (its parameter gone) keeps its numbers; the
/// solve leaves that joint out and says why.
fn reexpress(s: &mut Session) {
let changed: Vec<(usize, (f64, f64, Vec<f64>))> = s
.doc
.assembly
.joints
.iter()
.enumerate()
.filter(|(_, j)| !j.exprs.is_empty())
.filter_map(|(i, j)| {
let (o, a, mut q) = joints::expressed(&s.doc, j).ok()?;
joints::clamp(j, &mut q);
((o, a) != (j.offset, j.angle) || q != j.values).then_some((i, (o, a, q)))
})
.collect();
if changed.is_empty() {
return;
}
let doc = s.doc_mut();
for (i, (o, a, q)) in changed {
if let Some(j) = doc.assembly.joints.get_mut(i) {
(j.offset, j.angle, j.values) = (o, a, q);
}
}
}
/// Work out the ratios (and cam tables) of the coupled motion links again (a radius, a parameter
/// or a cam's profile may have changed). A coupling that can't be worked out (its face gone)
/// keeps its last ratio and table; `joint.list` says why.
@@ -449,6 +483,28 @@ pub(crate) fn value(s: &Session, v: &Value, angle: bool, cmd: &str) -> Result<f6
s.doc.eval(&e, kind).map_err(|e| bad(cmd, e.to_string()))
}
/// The expression a number was typed as, kept so the joint follows the parameters it names
/// (`None` for a plain number, or for text that names no parameter, such as `"5 mm"`).
fn typed(v: Option<&Value>) -> Option<String> {
match v {
Some(Value::String(t)) if !solidcraft_doc::expr::references(t).is_empty() => Some(t.trim().to_string()),
_ => None,
}
}
/// The expressions among `values` (or `value`), by index.
fn value_exprs(p: &Value) -> Vec<Option<String>> {
let mut out: Vec<Option<String>> = match (p.get("values"), p.get("value")) {
(Some(Value::Array(a)), _) => a.iter().map(|v| typed(Some(v))).collect(),
(_, Some(v)) => vec![typed(Some(v))],
_ => Vec::new(),
};
while out.last().is_some_and(Option::is_none) {
out.pop();
}
out
}
fn values_param(s: &Session, p: &Value, kind: JointKind, cmd: &str) -> Result<Vec<f64>> {
let n = kind.dofs().len();
let list: Vec<Value> = match (p.get("values"), p.get("value")) {
@@ -574,10 +630,10 @@ fn joint(s: &mut Session, p: &Value) -> Result<Value> {
None => 0.0,
};
let angle = match p.get("angle") {
Some(v) => value(s, v, true, cmd)?,
None => 0.0,
Some(v) => Some(value(s, v, true, cmd)?),
None => None,
};
let j = Joint {
let mut j = Joint {
id,
name,
kind,
@@ -587,9 +643,15 @@ fn joint(s: &mut Session, p: &Value) -> Result<Value> {
limits: limits_param(s, p, kind, cmd)?,
flip: bool_(p, "flip").unwrap_or(false),
offset,
angle,
angle: angle.unwrap_or(0.0),
suppressed: false,
exprs: JointExprs { offset: typed(p.get("offset")), angle: typed(p.get("angle")), values: value_exprs(p) },
};
// No angle given: B keeps its turn about the joint's axis where a half turn would otherwise
// come only from how its origin's x was chosen.
if angle.is_none() {
j.angle = joints::least_turn_angle(&s.doc, &s.model.state(), &j);
}
add_joint(s, j, p)
}
@@ -622,6 +684,7 @@ fn as_built(s: &mut Session, p: &Value) -> Result<Value> {
offset: 0.0,
angle: 0.0,
suppressed: false,
exprs: Default::default(),
};
add_joint(s, j, p)
}
@@ -666,6 +729,11 @@ fn drive(s: &mut Session, p: &Value) -> Result<Value> {
joints::clamp(j, &mut q);
// Moved as far toward `q` as contact sets and closed loops allow. Values left out of
// `values` are not driven: in a closed loop they follow.
// Driving moves the joint by hand: the values driven drop their expressions (an expression
// given here is worked out once, as contact or a loop may stop the motion short of it).
for i in 0..given.min(kind.dofs().len()) {
j.exprs.set_value(i, None);
}
let before = super::motion::positions(s);
let r = super::motion::drive_joint(s, id, q, given)?;
let sol = joints::solve(&s.doc, &s.model.state());
@@ -1126,15 +1194,26 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
let doc = s.doc_mut();
let Some(j) = doc.assembly.joints.iter_mut().find(|j| j.id == id) else { return Err(bad(cmd, "joint")) };
if let Some(k) = kind {
let was = j.kind;
j.kind = k;
j.values.resize(k.dofs().len(), 0.0);
j.limits.resize(k.dofs().len(), None);
j.exprs.values.truncate(k.dofs().len());
// A value that changes unit with the type (a slide becoming a turn) drops its expression,
// which would no longer work out and leave the joint out of the solve.
for i in 0..j.exprs.values.len() {
if was.is_angle(i) != k.is_angle(i) {
j.exprs.set_value(i, None);
}
}
}
if let Some(o) = offset {
j.offset = o;
j.exprs.offset = typed(p.get("offset"));
}
if let Some(a) = angle {
j.angle = a;
j.exprs.angle = typed(p.get("angle"));
}
if let Some(f) = bool_(p, "flip") {
j.flip = f;
@@ -1192,8 +1271,12 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
.map(|j| {
let named = named_values(j, vals.get(&j.id).map(Vec::as_slice).unwrap_or_default());
let world = |o: &JointOrigin| s.doc.origin_world(&st, o).ok().map(|m| json!({"origin": [m[3][0], m[3][1], m[3][2]], "z": [m[2][0], m[2][1], m[2][2]]}));
json!({"id": j.id, "name": j.name, "type": j.kind, "a": j.a.occurrence, "b": j.b.occurrence, "values": named,
"flip": j.flip, "offset": j.offset, "suppressed": j.suppressed, "visible": s.joint_visible(j.id), "frame_a": world(&j.a), "frame_b": world(&j.b)})
let mut out = json!({"id": j.id, "name": j.name, "type": j.kind, "a": j.a.occurrence, "b": j.b.occurrence, "values": named,
"flip": j.flip, "offset": j.offset, "suppressed": j.suppressed, "visible": s.joint_visible(j.id), "frame_a": world(&j.a), "frame_b": world(&j.b)});
if !j.exprs.is_empty() {
out["expressions"] = json!(j.exprs);
}
out
})
.collect();
let links: Vec<Value> = s
@@ -1327,6 +1410,9 @@ pub(crate) fn overlapping_pairs(boxes: &[Aabb3], limit: usize) -> (Vec<(usize, u
#[path = "joint_edge_tests.rs"]
mod edge_tests;
#[cfg(test)]
#[path = "joints_exprs_tests.rs"]
mod exprs_tests;
#[cfg(test)]
#[path = "joints_face_tests.rs"]
mod face_tests;
#[cfg(test)]
@@ -1338,3 +1424,6 @@ mod reach_tests;
#[cfg(test)]
#[path = "joints_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "joints_turn_tests.rs"]
mod turn_tests;
+146
View File
@@ -0,0 +1,146 @@
//! Joints whose offset, angle or values are typed as expressions follow the parameters they name.
use serde_json::json;
use solidcraft_geom::Vec3;
use super::tests::{occ, place, run};
use crate::Session;
/// Two 10 mm cubes in components A (grounded) and B, a `gap` and a `turn` parameter.
fn 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, "solid.box", json!({"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]}));
}
let o = occ(&s, "A");
run(&mut s, "occurrence.ground", json!({"occurrence": o, "grounded": true}));
run(&mut s, "parameters.add", json!({"name": "gap", "expression": "5 mm"}));
run(&mut s, "parameters.add", json!({"name": "turn", "expression": "0 deg"}));
s
}
/// Where B's bottom corner (20, 0, 0 in B) is.
fn b_corner(s: &Session) -> Vec3 {
place(s, "B", Vec3::new(20.0, 0.0, 0.0))
}
fn near(a: Vec3, b: Vec3) -> bool {
a.dist(b) < 1e-6
}
#[test]
fn an_offset_and_an_angle_typed_as_expressions_follow_their_parameters() {
let mut s = cubes();
run(
&mut s,
"joint.create",
json!({"type": "rigid", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}, "offset": "gap", "angle": "turn", "name": "Seat"}),
);
let z = |s: &Session| place(s, "B", Vec3::new(25.0, 5.0, 0.0)).z;
assert!((z(&s) - 15.0).abs() < 1e-6, "{}", z(&s));
// The gap changes: B rides up with it, without touching the joint.
run(&mut s, "parameters.change", json!({"name": "gap", "expression": "12 mm"}));
assert!((z(&s) - 22.0).abs() < 1e-6, "{}", z(&s));
// A parameter the gap is worked out from changes too.
run(&mut s, "parameters.add", json!({"name": "base", "expression": "2 mm"}));
run(&mut s, "parameters.change", json!({"name": "gap", "expression": "base * 3"}));
assert!((z(&s) - 16.0).abs() < 1e-6, "{}", z(&s));
// The angle turns B about the seat's axis (its centre stays, its corner swings round).
let centre = place(&s, "B", Vec3::new(25.0, 5.0, 0.0));
let before = b_corner(&s);
run(&mut s, "parameters.change", json!({"name": "turn", "expression": "90 deg"}));
assert!(near(place(&s, "B", Vec3::new(25.0, 5.0, 0.0)), centre));
let after = b_corner(&s);
assert!((before - centre).dot(after - centre).abs() < 1e-6, "{before:?} {after:?}");
// The design keeps the expressions, so they are there after saving and opening.
let j = s.doc.assembly.joints.first().cloned().unwrap_or_else(|| panic!("no joint"));
assert_eq!((j.exprs.offset.as_deref(), j.exprs.angle.as_deref()), (Some("gap"), Some("turn")));
let saved = serde_json::to_value(&j).unwrap_or_default();
assert_eq!((&saved["exprs"]["offset"], &saved["exprs"]["angle"]), (&json!("gap"), &json!("turn")), "{saved}");
let listed = run(&mut s, "joint.list", json!({}));
assert_eq!(listed["joints"][0]["expressions"]["offset"], json!("gap"), "{listed}");
// A number typed over the expression drops it: B stays when the gap changes again.
run(&mut s, "joint.edit", json!({"joint": "Seat", "offset": 4}));
run(&mut s, "parameters.change", json!({"name": "gap", "expression": "30 mm"}));
assert!((z(&s) - 14.0).abs() < 1e-6, "{}", z(&s));
assert!(s.doc.assembly.joints.first().is_some_and(|j| j.exprs.offset.is_none() && j.exprs.angle.is_some()));
}
#[test]
fn a_joint_value_typed_as_an_expression_follows_it_until_it_is_driven() {
let mut s = cubes();
run(
&mut s,
"joint.create",
json!({"type": "slider", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}, "values": ["gap"], "limits": [[0, 20]], "name": "Rail"}),
);
let z = |s: &Session| place(s, "B", Vec3::new(25.0, 5.0, 0.0)).z;
assert!((z(&s) - 15.0).abs() < 1e-6, "{}", z(&s));
run(&mut s, "parameters.change", json!({"name": "gap", "expression": "8 mm"}));
assert!((z(&s) - 18.0).abs() < 1e-6, "{}", z(&s));
// Past the limits it is held at them, like a typed value.
run(&mut s, "parameters.change", json!({"name": "gap", "expression": "50 mm"}));
assert!((z(&s) - 30.0).abs() < 1e-6, "{}", z(&s));
// Driven by hand it drops the expression (contact or a loop may stop it short of any value).
run(&mut s, "joint.drive", json!({"joint": "Rail", "value": 1}));
run(&mut s, "parameters.change", json!({"name": "gap", "expression": "9 mm"}));
assert!((z(&s) - 11.0).abs() < 1e-6, "{}", z(&s));
assert!(s.doc.assembly.joints.first().is_some_and(|j| j.exprs.is_empty()));
}
#[test]
fn renaming_a_parameter_carries_into_joints_and_deleting_it_is_refused() {
let mut s = cubes();
run(&mut s, "joint.create", json!({"type": "rigid", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}, "offset": "gap + 1", "name": "Seat"}));
run(&mut s, "parameters.rename", json!({"name": "gap", "new_name": "clearance"}));
assert_eq!(s.doc.assembly.joints.first().and_then(|j| j.exprs.offset.clone()).as_deref(), Some("clearance + 1"));
run(&mut s, "parameters.change", json!({"name": "clearance", "expression": "9 mm"}));
assert!((place(&s, "B", Vec3::new(25.0, 5.0, 0.0)).z - 20.0).abs() < 1e-6);
// The joint uses it, so it can't be deleted from under the joint.
let e = s.execute("parameters.delete", &json!({"name": "clearance"})).unwrap_err().to_string();
assert!(e.contains("joint Seat (offset)"), "{e}");
}
#[test]
fn a_joint_whose_expression_stops_working_out_is_left_out_with_the_reason() {
let mut s = cubes();
run(&mut s, "joint.create", json!({"type": "rigid", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}, "offset": "gap", "name": "Seat"}));
let at = b_corner(&s);
// The gap becomes an angle: the offset no longer works out as a length. B stays where it
// was (not thrown to an old number) and the solve says why.
run(&mut s, "parameters.change", json!({"name": "gap", "expression": "30 deg", "unit": "deg"}));
assert!(near(b_corner(&s), at), "{:?}", b_corner(&s));
let r = run(&mut s, "joint.solve", json!({}));
let why = r["errors"][0]["why"].as_str().unwrap_or_default().to_string();
assert!(why.contains("Seat") && why.contains("offset"), "{r}");
// Fixed again, the joint places B again.
run(&mut s, "parameters.change", json!({"name": "gap", "expression": "2 mm", "unit": "mm"}));
assert!((place(&s, "B", Vec3::new(25.0, 5.0, 0.0)).z - 12.0).abs() < 1e-6);
assert_eq!(run(&mut s, "joint.solve", json!({}))["errors"], json!([]));
}
#[test]
fn a_value_that_changes_unit_with_the_joint_type_drops_its_expression() {
let mut s = cubes();
run(&mut s, "joint.create", json!({"type": "slider", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}, "values": ["gap"], "name": "Rail"}));
// The slide becomes a turn: `gap` (a length) can't be its angle, so the turn keeps a number
// and the joint stays in the solve.
let r = run(&mut s, "joint.edit", json!({"joint": "Rail", "type": "revolute"}));
assert_eq!(r["errors"], json!([]), "{r}");
assert!(s.doc.assembly.joints.first().is_some_and(|j| j.exprs.is_empty()));
}
#[test]
fn text_that_names_no_parameter_is_kept_as_a_number() {
let mut s = cubes();
run(
&mut s,
"joint.create",
json!({"type": "slider", "a": {"face": [5, 5, 10]}, "b": {"face": [25, 5, 0]}, "offset": "3 mm", "angle": "0 deg", "values": ["2 * 2 mm"]}),
);
let j = s.doc.assembly.joints.first().cloned().unwrap_or_else(|| panic!("no joint"));
assert!(j.exprs.is_empty(), "{:?}", j.exprs);
assert!((j.offset - 3.0).abs() < 1e-12 && j.values.first().is_some_and(|v| (v - 4.0).abs() < 1e-12), "{j:?}");
}
@@ -265,3 +265,31 @@ fn origins_on_a_body_placed_by_an_offset_agree_named_or_not() {
assert!(near(named.0, unnamed.0) && near(named.1, unnamed.1), "{named:?} {unnamed:?}");
assert!(near(named.0, at(Vec3::new(20.0, 20.0, 5.0))) && near(named.1, up), "{named:?}");
}
#[test]
fn a_round_origin_keeps_its_end_when_the_face_it_opens_onto_gets_other_neighbours() {
// A plate `T` thick with a groove across its top (so the top face is in pieces, each named
// by the faces round it), a small hole and a 6 mm hole in the left piece. The origin is
// picked on the 6 mm hole's wall near the top, and names the top piece as its end.
let mut s = Session::default();
run(&mut s, "parameters.change", json!({"name": "T", "expression": "2 mm"}));
run(&mut s, "component.create", json!({"name": "Plate"}));
run(&mut s, "solid.box", json!({"length": 60, "width": 100, "height": "T", "corner": [0, 0, 0]}));
run(&mut s, "solid.box", json!({"length": 4, "width": 100, "height": 1, "corner": [28, 0, "T - 1 mm"], "operation": "cut"}));
run(&mut s, "solid.hole", json!({"position": [10, 50, "T"], "diameter": 4}));
let small = s.doc.features.last().map_or(0, |f| f.id);
run(&mut s, "solid.hole", json!({"position": [20, 50, "T"], "diameter": 6}));
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "joint.origin", json!({"occurrence": "Plate", "circle": [23, 50, 1.6], "name": "O"}));
let top = |s: &Session| {
let o = s.doc.assembly.origins.iter().find(|o| o.name == "O").map(|o| o.origin.clone());
o.and_then(|o| solidcraft_doc::joints::resolve_origin(&s.doc, &s.model.state(), &o).ok()).map(|m| (m[3][2], m[2][2]))
};
assert_eq!(top(&s), Some((2.0, 1.0)));
// Thicker, the pick is nearer the bottom; without the small hole the top piece is named by
// fewer faces. The origin stays on the top.
run(&mut s, "parameters.change", json!({"name": "T", "expression": "4 mm"}));
run(&mut s, "timeline.suppress", json!({"feature": small, "suppressed": true}));
let (z, dz) = top(&s).unwrap_or_default();
assert!((z - 4.0).abs() < 1e-9 && (dz - 1.0).abs() < 1e-9, "{z} {dz}");
}
@@ -0,0 +1,79 @@
//! A new joint given no angle keeps the part's turn about the joint's axis: two faces that
//! already meet stay as they are instead of the part spinning a half turn about their normal.
use serde_json::json;
use solidcraft_geom::Vec3;
use super::tests::{occ, place, run};
use crate::Session;
/// A grounded panel 80 x 30 x 3 at z 3 and a hinge plate of the same size under it, each with a
/// hole off its middle at x = 20: the two faces that meet at z = 3 have opposite normals.
fn panel_and_hinge() -> Session {
let mut s = Session::default();
run(&mut s, "component.create", json!({"name": "Panel"}));
run(&mut s, "solid.box", json!({"length": 80, "width": 30, "height": 3, "corner": [0, 0, 3]}));
run(&mut s, "solid.hole", json!({"position": [20, 15, 6], "diameter": 5.5}));
run(&mut s, "component.activate", json!({"component": "root"}));
run(&mut s, "component.create", json!({"name": "Hinge"}));
run(&mut s, "solid.box", json!({"length": 80, "width": 30, "height": 3, "corner": [0, 0, 0]}));
run(&mut s, "solid.hole", json!({"position": [20, 15, 3], "diameter": 5.5}));
run(&mut s, "component.activate", json!({"component": "root"}));
let panel = occ(&s, "Panel");
run(&mut s, "occurrence.ground", json!({"occurrence": panel, "grounded": true}));
s
}
fn stays(s: &Session) -> bool {
[Vec3::new(20.0, 15.0, 0.0), Vec3::new(80.0, 0.0, 3.0)].iter().all(|p| place(s, "Hinge", *p).dist(*p) < 1e-6)
}
#[test]
fn joining_two_faces_that_already_meet_leaves_the_part_where_it_is() {
// Each face's frame takes an x of its own; with opposite normals those x are opposite, and
// the joint used to spin the hinge a half turn about the normal, its hole to x = 60.
for kind in ["rigid", "slider", "planar"] {
let mut s = panel_and_hinge();
let r = run(
&mut s,
"joint.create",
json!({"type": kind, "a": {"occurrence": "Panel", "face": [30, 25, 3]}, "b": {"occurrence": "Hinge", "face": [30, 25, 3]}, "name": "J"}),
);
assert!(stays(&s), "{kind}: the hinge moved to {:?}: {r}", place(&s, "Hinge", Vec3::new(20.0, 15.0, 0.0)));
// Its value still moves it along the joint's axis (the faces' normal), without a turn.
if kind == "slider" {
run(&mut s, "joint.drive", json!({"joint": "J", "value": 5}));
let p = place(&s, "Hinge", Vec3::new(20.0, 15.0, 0.0));
assert!(p.dist(Vec3::new(20.0, 15.0, -5.0)) < 1e-6, "{p:?}");
}
}
// An angle given is kept as given.
let mut s = panel_and_hinge();
run(
&mut s,
"joint.create",
json!({"type": "rigid", "a": {"occurrence": "Panel", "face": [30, 25, 3]}, "b": {"occurrence": "Hinge", "face": [30, 25, 3]}, "angle": 0}),
);
assert!(!stays(&s));
let j = s.doc.assembly.joints.last().map(|j| j.angle);
assert_eq!(j, Some(0.0));
}
#[test]
fn an_angle_typed_as_an_expression_is_not_replaced_by_the_kept_turn() {
// `turn` is 0 deg: the hinge takes the half turn the angle says, not the one that would
// leave it where it is, and follows the parameter when it changes.
let mut s = panel_and_hinge();
run(&mut s, "parameters.add", json!({"name": "turn", "expression": "0 deg"}));
run(
&mut s,
"joint.create",
json!({"type": "rigid", "a": {"occurrence": "Panel", "face": [30, 25, 3]}, "b": {"occurrence": "Hinge", "face": [30, 25, 3]}, "angle": "turn", "name": "J"}),
);
assert!(!stays(&s));
let j = s.doc.assembly.joints.last().map(|j| (j.angle, j.exprs.angle.clone()));
assert_eq!(j, Some((0.0, Some("turn".to_string()))));
run(&mut s, "parameters.change", json!({"name": "turn", "expression": "180 deg"}));
assert!(stays(&s), "{:?}", place(&s, "Hinge", Vec3::new(20.0, 15.0, 0.0)));
assert!(s.doc.assembly.joints.last().is_some_and(|j| j.exprs.angle.as_deref() == Some("turn")));
}
+9 -9
View File
@@ -5,7 +5,7 @@
//! have a direction (planar faces, straight edges), the angle.
use serde_json::{Value, json};
use solidcraft_geom::{Mesh, Vec3};
use solidcraft_geom::{Mesh, UnitVec3, Vec3};
use super::measure_shape::{Shape, Slot, add_round, centre};
use crate::params::bad;
@@ -84,7 +84,7 @@ struct Item {
/// The circle through a polyline's points (all on one within `tol`): a fallback for edges the
/// kernel can't name (mesh bodies).
fn fit_circle(pts: &[Vec3], tol: f64) -> Option<(Vec3, Vec3, f64)> {
fn fit_circle(pts: &[Vec3], tol: f64) -> Option<(Vec3, UnitVec3, f64)> {
let n = pts.len().checked_sub(1)?;
let (a, b, c) = (*pts.first()?, *pts.get(n / 3)?, *pts.get(2 * n / 3)?);
let (u, v) = (b - a, c - a);
@@ -93,7 +93,7 @@ fn fit_circle(pts: &[Vec3], tol: f64) -> Option<(Vec3, Vec3, f64)> {
return None;
}
let center = a + (v * u.len2() - u * v.len2()).cross(w) * (1.0 / (2.0 * w.len2()));
let axis = w.normalized()?;
let axis = w.unit()?;
let radius = center.dist(a);
let on = pts.iter().all(|p| (p.dist(center) - radius).abs() <= tol && (*p - center).dot(axis).abs() <= tol);
(on && radius.is_finite()).then_some((center, axis, radius))
@@ -123,7 +123,7 @@ fn edge_round(b: &solidcraft_doc::ModelBody, m: &Mesh, index: usize, whole: bool
}
/// Points along a circle or arc (`angle` from `start` about `axis`).
fn arc_points(center: Vec3, axis: Vec3, start: Vec3, angle: f64) -> Vec<Vec3> {
fn arc_points(center: Vec3, axis: UnitVec3, start: Vec3, angle: f64) -> Vec<Vec3> {
let x = start - center;
let y = axis.cross(x);
let n = 128;
@@ -157,7 +157,7 @@ fn sketch_curve(ss: &solidcraft_doc::SolvedSketch, id: &str) -> Option<Item> {
let sk = &ss.sketch;
let c = sk.curves.get(sk.curve_index(id)?)?;
let at = |i: usize| sk.points.get(i).map(|p| ss.plane.to_world(p.pos));
let normal = ss.plane.normal();
let normal = ss.plane.normal().unit()?;
let (shape, pts) = match c.kind {
CurveKind::Circle { c, r } => {
let center = at(c)?;
@@ -203,7 +203,7 @@ fn profile(ss: &solidcraft_doc::SolvedSketch, index: usize) -> Option<Item> {
let pr = ss.profiles.get(index)?;
let outline: Vec<Vec3> = pr.region.outer.polyline(1e-3).iter().map(|p| ss.plane.to_world(*p)).collect();
let mut json = json!({"type": "profile", "area_mm2": pr.region.area()});
let normal = ss.plane.normal();
let normal = ss.plane.normal().unit()?;
// Arcs about one centre merge (a circle is two halves); straight pieces stay as they are.
let mut arcs: Vec<(Vec2, f64, f64)> = Vec::new();
let mut lines: Vec<(Vec2, Vec2)> = Vec::new();
@@ -242,7 +242,7 @@ fn profile(ss: &solidcraft_doc::SolvedSketch, index: usize) -> Option<Item> {
slot = Some(s);
}
}
let planar = Some(normal);
let planar = Some(*normal);
let geo = Geo::Points(densify(&outline));
let shape = if matches!(shape, Shape::Other) { Shape::Plane { point: ss.plane.origin, normal } } else { shape };
Some(Item { geo, dir: planar, json, shape, slot })
@@ -308,7 +308,7 @@ fn describe(s: &Session, sel: &Sel) -> Option<Item> {
let patch = m.surface_patch(*index);
let tris = patch.iter().flat_map(|f| face_tris(&m, *f)).collect::<Vec<_>>();
let area: f64 = tris.iter().map(|[a, b, c]| (*b - *a).cross(*c - *a).len() * 0.5).sum();
let normals: Vec<Vec3> = tris.iter().filter_map(|[a, b, c]| (*b - *a).cross(*c - *a).normalized()).collect();
let normals: Vec<UnitVec3> = tris.iter().filter_map(|[a, b, c]| (*b - *a).cross(*c - *a).unit()).collect();
let planar = normals.first().copied().filter(|n0| normals.iter().all(|n| n.dot(*n0) > 1.0 - 1e-6));
let mut json = json!({"type": "face", "area_mm2": area, "planar": planar.is_some()});
let verts: Vec<Vec3> = tris.iter().flat_map(|t| t.iter().copied()).collect();
@@ -328,7 +328,7 @@ fn describe(s: &Session, sel: &Sel) -> Option<Item> {
if let Some(sl) = &slot {
json["slot"] = sl.json();
}
Some(Item { geo: Geo::Tris(tris), dir: planar, json, shape, slot })
Some(Item { geo: Geo::Tris(tris), dir: planar.map(Vec3::from), json, shape, slot })
}
Sel::Body { name } => {
let m = st.body(name)?.mesh();
+25 -26
View File
@@ -4,7 +4,7 @@
//! round item measures from its centre or axis, with the closest and farthest distances too.
use serde_json::{Value, json};
use solidcraft_geom::Vec3;
use solidcraft_geom::{UnitVec3, Vec3};
use std::f64::consts::{PI, TAU};
@@ -17,7 +17,7 @@ pub(crate) enum Shape {
/// around the axis (2π all the way round).
Round {
center: Vec3,
axis: Vec3,
axis: UnitVec3,
radius: f64,
angle: f64,
planar: bool,
@@ -26,7 +26,7 @@ pub(crate) enum Shape {
},
Plane {
point: Vec3,
normal: Vec3,
normal: UnitVec3,
},
Line {
a: Vec3,
@@ -70,8 +70,7 @@ pub(crate) fn add_round(v: &mut Value, shape: &Shape) {
/// Cylinder `axis` through `origin` of radius `radius` over the points of its face: the
/// round shape, centred level with the middle of the face, with its extent and coverage.
pub(crate) fn cylinder(origin: Vec3, axis: Vec3, radius: f64, pts: &[Vec3]) -> Option<Shape> {
let axis = axis.normalized()?;
pub(crate) fn cylinder(origin: Vec3, axis: UnitVec3, radius: f64, pts: &[Vec3]) -> Option<Shape> {
if pts.is_empty() || !(radius.is_finite() && radius > 0.0) {
return None;
}
@@ -83,7 +82,7 @@ pub(crate) fn cylinder(origin: Vec3, axis: Vec3, radius: f64, pts: &[Vec3]) -> O
}
/// The angle around the axis the points cover: a whole turn less the widest gap between them.
pub(crate) fn coverage(center: Vec3, axis: Vec3, pts: &[Vec3]) -> f64 {
pub(crate) fn coverage(center: Vec3, axis: UnitVec3, pts: &[Vec3]) -> f64 {
let x = axis.any_perp();
let y = axis.cross(x);
let mut angles: Vec<f64> = pts
@@ -105,7 +104,7 @@ pub(crate) fn coverage(center: Vec3, axis: Vec3, pts: &[Vec3]) -> f64 {
}
/// The unit direction a point leaves the axis at, towards the middle of the face's points.
fn bulge(center: Vec3, axis: Vec3, pts: &[Vec3]) -> Option<Vec3> {
fn bulge(center: Vec3, axis: UnitVec3, pts: &[Vec3]) -> Option<Vec3> {
let sum = pts.iter().fold(Vec3::ZERO, |acc, p| {
let d = *p - center;
acc + (d - axis * d.dot(axis)).normalized().unwrap_or(Vec3::ZERO)
@@ -114,7 +113,7 @@ fn bulge(center: Vec3, axis: Vec3, pts: &[Vec3]) -> Option<Vec3> {
}
/// Closest points of two infinite lines (a point and a unit direction each).
fn line_line(p1: Vec3, d1: Vec3, p2: Vec3, d2: Vec3) -> (Vec3, Vec3) {
fn line_line(p1: Vec3, d1: UnitVec3, p2: Vec3, d2: UnitVec3) -> (Vec3, Vec3) {
let w = p1 - p2;
let b = d1.dot(d2);
let denom = 1.0 - b * b;
@@ -127,8 +126,8 @@ fn line_line(p1: Vec3, d1: Vec3, p2: Vec3, d2: Vec3) -> (Vec3, Vec3) {
(p1 + d1 * s, p2 + d2 * t)
}
fn parallel(a: Vec3, b: Vec3) -> bool {
a.cross(b).len() < 1e-6
fn parallel(a: impl Into<Vec3>, b: impl Into<Vec3>) -> bool {
a.into().cross(b).len() < 1e-6
}
/// A measurement from a round item's centre or axis.
@@ -214,7 +213,7 @@ fn from_round(a: &Shape, b: &Shape) -> Option<Centre> {
}
}
Shape::Line { a: la, b: lb } => {
let dir = (lb - la).normalized()?;
let dir = (lb - la).unit()?;
let across = dir.dot(na).abs() < 1e-6;
if pa {
// A circle/arc has a fixed centre. Project that point onto the picked
@@ -342,7 +341,7 @@ mod tests {
use solidcraft_geom::Vec2;
fn circle(c: Vec3, r: f64) -> Shape {
Shape::Round { center: c, axis: Vec3::Z, radius: r, angle: TAU, planar: true, lo: 0.0, hi: 0.0 }
Shape::Round { center: c, axis: UnitVec3::Z, radius: r, angle: TAU, planar: true, lo: 0.0, hi: 0.0 }
}
#[test]
@@ -367,8 +366,8 @@ mod tests {
#[test]
fn arcs_on_different_planes_measure_between_their_centres() {
let a = Shape::Round { center: Vec3::ZERO, axis: Vec3::Z, radius: 2.0, angle: PI, planar: true, lo: 0.0, hi: 0.0 };
let b = Shape::Round { center: Vec3::new(3.0, 4.0, 12.0), axis: Vec3::X, radius: 4.0, angle: PI / 2.0, planar: true, lo: 0.0, hi: 0.0 };
let a = Shape::Round { center: Vec3::ZERO, axis: UnitVec3::Z, radius: 2.0, angle: PI, planar: true, lo: 0.0, hi: 0.0 };
let b = Shape::Round { center: Vec3::new(3.0, 4.0, 12.0), axis: UnitVec3::X, radius: 4.0, angle: PI / 2.0, planar: true, lo: 0.0, hi: 0.0 };
let c = centre(&a, &b).unwrap();
assert_eq!((c.from, c.to, c.distance), (Vec3::ZERO, Vec3::new(3.0, 4.0, 12.0), 13.0));
assert!(c.min.is_none() && c.max.is_none());
@@ -377,10 +376,10 @@ mod tests {
#[test]
fn a_circle_to_a_cylinder_uses_its_fixed_centre_and_the_cylinders_axis() {
let a = circle(Vec3::new(0.0, 0.0, 10.0), 4.0);
for axis in [Vec3::Z, Vec3::X] {
for axis in [UnitVec3::Z, UnitVec3::X] {
let b = Shape::Round { center: Vec3::new(34.0, 19.0, 27.0), axis, radius: 4.0, angle: TAU, planar: false, lo: -5.0, hi: 5.0 };
let c = centre(&a, &b).unwrap();
let expected = if axis == Vec3::Z { Vec3::new(34.0, 19.0, 10.0) } else { Vec3::new(0.0, 19.0, 27.0) };
let expected = if axis == UnitVec3::Z { Vec3::new(34.0, 19.0, 10.0) } else { Vec3::new(0.0, 19.0, 27.0) };
assert_eq!((c.from, c.to), (Vec3::new(0.0, 0.0, 10.0), expected));
assert!((c.distance - c.from.dist(expected)).abs() < 1e-12);
let swapped = centre(&b, &a).unwrap();
@@ -390,8 +389,8 @@ mod tests {
#[test]
fn two_cylindrical_faces_still_measure_axis_pitch() {
let a = Shape::Round { center: Vec3::new(0.0, 0.0, 5.0), axis: Vec3::Z, radius: 4.0, angle: TAU, planar: false, lo: -5.0, hi: 5.0 };
let b = Shape::Round { center: Vec3::new(34.0, 19.0, 27.0), axis: Vec3::Z, radius: 4.0, angle: TAU, planar: false, lo: -5.0, hi: 5.0 };
let a = Shape::Round { center: Vec3::new(0.0, 0.0, 5.0), axis: UnitVec3::Z, radius: 4.0, angle: TAU, planar: false, lo: -5.0, hi: 5.0 };
let b = Shape::Round { center: Vec3::new(34.0, 19.0, 27.0), axis: UnitVec3::Z, radius: 4.0, angle: TAU, planar: false, lo: -5.0, hi: 5.0 };
let c = centre(&a, &b).unwrap();
assert_eq!((c.from, c.to), (Vec3::new(0.0, 0.0, 5.0), Vec3::new(34.0, 19.0, 5.0)));
assert!((c.distance - 1517_f64.sqrt()).abs() < 1e-12);
@@ -399,8 +398,8 @@ mod tests {
#[test]
fn a_cylinder_to_a_side_plane_measures_from_its_axis() {
let cyl = Shape::Round { center: Vec3::new(10.0, 15.0, 5.0), axis: Vec3::Z, radius: 2.5, angle: TAU, planar: false, lo: -5.0, hi: 5.0 };
let plane = Shape::Plane { point: Vec3::ZERO, normal: Vec3::new(-1.0, 0.0, 0.0) };
let cyl = Shape::Round { center: Vec3::new(10.0, 15.0, 5.0), axis: UnitVec3::Z, radius: 2.5, angle: TAU, planar: false, lo: -5.0, hi: 5.0 };
let plane = Shape::Plane { point: Vec3::ZERO, normal: -UnitVec3::X };
let c = centre(&cyl, &plane).unwrap();
assert!((c.distance - 10.0).abs() < 1e-12);
assert!((c.min.unwrap() - 7.5).abs() < 1e-12 && (c.max.unwrap() - 12.5).abs() < 1e-12);
@@ -408,12 +407,12 @@ mod tests {
let d = centre(&plane, &cyl).unwrap();
assert_eq!((d.from, d.to), (c.to, c.from));
// A cylinder crossing a plane has no single axis distance.
assert!(centre(&cyl, &Shape::Plane { point: Vec3::ZERO, normal: Vec3::Z }).is_none());
assert!(centre(&cyl, &Shape::Plane { point: Vec3::ZERO, normal: UnitVec3::Z }).is_none());
}
#[test]
fn an_axis_to_a_crossing_edge_is_the_distance_between_the_lines() {
let cyl = Shape::Round { center: Vec3::new(10.0, 15.0, 5.0), axis: Vec3::Z, radius: 2.5, angle: TAU, planar: false, lo: -5.0, hi: 5.0 };
let cyl = Shape::Round { center: Vec3::new(10.0, 15.0, 5.0), axis: UnitVec3::Z, radius: 2.5, angle: TAU, planar: false, lo: -5.0, hi: 5.0 };
let edge = Shape::Line { a: Vec3::new(0.0, 0.0, 10.0), b: Vec3::new(40.0, 0.0, 10.0) };
let c = centre(&cyl, &edge).unwrap();
assert!((c.distance - 15.0).abs() < 1e-12);
@@ -425,7 +424,7 @@ mod tests {
fn a_circle_or_arc_to_a_line_keeps_its_real_center() {
let edge = Shape::Line { a: Vec3::new(0.0, 0.0, 6.0), b: Vec3::new(68.0, 0.0, 6.0) };
for angle in [TAU, PI / 2.0] {
let round = Shape::Round { center: Vec3::new(34.0, 25.0, 20.0), axis: Vec3::Z, radius: 12.0, angle, planar: true, lo: 0.0, hi: 0.0 };
let round = Shape::Round { center: Vec3::new(34.0, 25.0, 20.0), axis: UnitVec3::Z, radius: 12.0, angle, planar: true, lo: 0.0, hi: 0.0 };
let c = centre(&round, &edge).unwrap();
assert_eq!((c.from, c.to), (Vec3::new(34.0, 25.0, 20.0), Vec3::new(34.0, 0.0, 6.0)));
assert!((c.distance - 25_f64.hypot(14.0)).abs() < 1e-12);
@@ -442,7 +441,7 @@ mod tests {
#[test]
fn a_tilted_arc_to_a_sloped_line_keeps_all_components() {
let edge = Shape::Line { a: Vec3::ZERO, b: Vec3::new(6.0, 0.0, 6.0) };
for axis in [Vec3::X, Vec3::Y, Vec3::Z] {
for axis in [UnitVec3::X, UnitVec3::Y, UnitVec3::Z] {
let arc = Shape::Round { center: Vec3::new(3.0, 4.0, 6.0), axis, radius: 2.0, angle: PI, planar: true, lo: 0.0, hi: 0.0 };
let c = centre(&arc, &edge).unwrap();
assert!(c.from.dist(Vec3::new(3.0, 4.0, 6.0)) < 1e-12);
@@ -468,8 +467,8 @@ mod tests {
let ring = |n: usize, span: f64| -> Vec<Vec3> {
(0..=n).map(|i| Vec3::new((span * i as f64 / n as f64).cos(), (span * i as f64 / n as f64).sin(), 0.0)).collect()
};
assert!((coverage(Vec3::ZERO, Vec3::Z, &ring(32, PI)) - PI).abs() < 1e-9);
assert_eq!(coverage(Vec3::ZERO, Vec3::Z, &ring(32, TAU)), TAU);
assert!((coverage(Vec3::ZERO, UnitVec3::Z, &ring(32, PI)) - PI).abs() < 1e-9);
assert_eq!(coverage(Vec3::ZERO, UnitVec3::Z, &ring(32, TAU)), TAU);
}
#[test]
+82
View File
@@ -580,3 +580,85 @@ fn compact_component_list_counts_and_skips_identity_transforms() {
// Body names on request.
assert_eq!(block(&run(&mut s, "component.list", json!({"compact": true, "bodies": true})))["bodies"], json!(["Cube"]));
}
/// A script's 3x4 placement matrix (rotation rows, then the translation column) moves an
/// occurrence exactly as the same turn and move given as an axis, an angle and a translation.
#[test]
fn matrix_move_turns_a_quarter_and_moves() {
let mut s = Session::default();
let (_, occ) = created(&mut s, "Block");
run(&mut s, "solid.box", json!({"length": 2, "width": 1, "height": 1}));
// 90 degrees about Z, then (10, 20, 30): (x, y, z) goes to (10 - y, 20 + x, 30 + z).
let r = run(&mut s, "occurrence.move", json!({"occurrence": occ, "matrix": [0, -1, 0, 10, 1, 0, 0, 20, 0, 0, 1, 30]}));
let t = &r["transform"];
for (col, expected) in [[0.0, 1.0, 0.0, 0.0], [-1.0, 0.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [10.0, 20.0, 30.0, 1.0]].iter().enumerate() {
for (row, x) in expected.iter().enumerate() {
near(&t[col][row], *x);
}
}
// The box's centre (1, 0.5, 0.5) lands at (9.5, 21, 30.5).
let props = run(&mut s, "occurrence.properties", json!({"occurrence": occ}));
for (i, x) in [9.5, 21.0, 30.5].into_iter().enumerate() {
near(&props["total"]["center_of_mass"][i], x);
}
// The same as axis/angle/translate, and the 4x4 form as rows says the same.
let mut other = Session::default();
let (_, o2) = created(&mut other, "Block");
let r2 = run(&mut other, "occurrence.move", json!({"occurrence": o2, "axis": [0, 0, 1], "angle": "90 deg", "translate": [10, 20, 30]}));
let mut rows = Session::default();
let (_, o3) = created(&mut rows, "Block");
let r3 = run(&mut rows, "occurrence.move", json!({"occurrence": o3, "matrix": [[0, -1, 0, 10], [1, 0, 0, 20], [0, 0, 1, 30], [0, 0, 0, 1]]}));
for col in 0..4 {
for row in 0..4 {
near(&r2["transform"][col][row], t[col][row].as_f64().unwrap());
near(&r3["transform"][col][row], t[col][row].as_f64().unwrap());
}
}
}
/// A rotation that is one only up to rounding is orthonormalised; a scale, a shear or a mirror
/// is refused with the reason, and the occurrence stays where it was.
#[test]
fn matrix_move_orthonormalises_rounding_and_refuses_what_is_not_rigid() {
let mut s = Session::default();
let (_, occ) = created(&mut s, "Block");
let (sin, cos) = 30f64.to_radians().sin_cos();
let e = 3e-10;
let nearly = json!([cos + e, -sin, 0, 5, sin, cos - e, e, 0, 0, -e, 1.0 + e, 0]);
let r = run(&mut s, "occurrence.move", json!({"occurrence": occ, "matrix": nearly, "space": "world"}));
let m: solidcraft_doc::Mat = serde_json::from_value(r["transform"].clone()).unwrap();
for i in 0..3 {
for j in 0..3 {
let dot: f64 = (0..3).map(|k| m[i][k] * m[j][k]).sum();
assert!((dot - f64::from(u8::from(i == j))).abs() < 1e-15, "column {i}·{j} = {dot}");
}
}
assert!((m[0][0] - cos).abs() < 1e-9 && (m[0][1] - sin).abs() < 1e-9 && (m[3][0] - 5.0).abs() < 1e-12, "{m:?}");
let before = s.doc.occurrences.iter().find(|o| o.id == occ).unwrap().transform;
let refused = |s: &mut Session, p: Value| s.execute("occurrence.move", &p).unwrap_err().to_string();
let scaled = refused(&mut s, json!({"occurrence": occ, "matrix": [1.001, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0]}));
assert!(scaled.contains("not rigid") && scaled.contains("2.00"), "{scaled}");
let sheared = refused(&mut s, json!({"occurrence": occ, "matrix": [1, 0.01, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0]}));
assert!(sheared.contains("not rigid"), "{sheared}");
let mirrored = refused(&mut s, json!({"occurrence": occ, "matrix": [-1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0]}));
assert!(mirrored.contains("solid.mirror"), "{mirrored}");
let projective = refused(&mut s, json!({"occurrence": occ, "matrix": [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0.5, 0, 0, 1]}));
assert!(projective.contains("fourth row"), "{projective}");
// A looser tolerance is the caller's to ask for, up to 1e-3.
let rounded = json!([0.866, -0.5, 0, 0, 0.5, 0.866, 0, 0, 0, 0, 1, 0]);
assert!(refused(&mut s, json!({"occurrence": occ, "matrix": rounded})).contains("not rigid"));
for p in [
json!({"occurrence": occ, "matrix": rounded, "tolerance": 1}),
json!({"occurrence": occ, "matrix": [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], "translate": [1, 0, 0]}),
json!({"occurrence": occ, "matrix": [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0], "translate": [1, 0, 0]}),
json!({"occurrence": occ, "matrix": [1, 0, 0, 1e12, 0, 1, 0, 0, 0, 0, 1, 0]}),
json!({"occurrence": occ, "matrix": [[1, 0, 0, 0], [0, 1, 0, 0]]}),
json!({"occurrence": occ, "matrix": "identity"}),
json!({"occurrence": occ, "matrix": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]}),
] {
assert!(s.execute("occurrence.move", &p).is_err(), "{p}");
}
assert_eq!(s.doc.occurrences.iter().find(|o| o.id == occ).unwrap().transform, before);
run(&mut s, "occurrence.move", json!({"occurrence": occ, "matrix": rounded, "tolerance": 1e-3}));
}
@@ -0,0 +1,88 @@
#![allow(clippy::unwrap_used, clippy::panic)]
//! #2424: curves start at the ends of an associated offset spline, and follow them.
use serde_json::{Value, json};
use solidcraft_doc::Document;
use solidcraft_sketch::{ConstraintKind, CurveKind, Sketch};
use crate::Session;
fn run(s: &mut Session, command: &str, params: Value) -> Value {
s.execute(command, &params).unwrap_or_else(|error| panic!("{command}: {error}"))
}
fn solved(s: &Session, id: u64) -> Sketch {
s.model.state().sketch(id).unwrap().sketch.clone()
}
/// An open fit-point spline `s1` and its associated offset; returns the offset's id.
fn offset_spline(s: &mut Session) -> (u64, String) {
run(s, "sketch.create", json!({"plane":"XY"}));
let sketch = s.active_sketch.unwrap();
run(s, "sketch.spline.fit_point", json!({"points":[[0,0],[10,5],[20,0],[30,5]],"ids":["s1"]}));
let result = run(s, "sketch.offset", json!({"curves":["s1"],"distance":"2 mm","chain":true}));
(sketch, result["curves"][0].as_str().unwrap().into())
}
fn coincident(sk: &Sketch, a: usize, b: usize) -> bool {
sk.constraints.iter().any(|k| matches!(k.kind, ConstraintKind::Coincident { p, q } if (p, q) == (a, b) || (p, q) == (b, a)))
}
#[test]
fn lines_drawn_from_offset_spline_ends_get_their_own_coincident_points() {
let mut s = Session::default();
let (id, output) = offset_spline(&mut s);
// Close the shape between the spline and its offset at both ends.
run(&mut s, "sketch.line", json!({"points":["s1.start", format!("{output}.start")],"ids":["l1"]}));
run(&mut s, "sketch.line", json!({"points":[format!("{output}.end"), "s1.end"],"ids":["l2"]}));
let sk = solved(&s, id);
sk.validate_freeform_offsets().unwrap();
let o = sk.curve_index(&output).unwrap();
let (start, end) = sk.curves[o].kind.ends().unwrap();
let (l1, l2) = (sk.curve_index("l1").unwrap(), sk.curve_index("l2").unwrap());
let (CurveKind::Line { b: l1_end, .. }, CurveKind::Line { a: l2_start, .. }) = (&sk.curves[l1].kind, &sk.curves[l2].kind) else {
panic!("lines")
};
assert!(*l1_end != start && *l2_start != end, "the offset keeps sole ownership of its nodes");
assert!(coincident(&sk, start, *l1_end) && coincident(&sk, end, *l2_start));
assert!(sk.points[*l1_end].pos.dist(sk.points[start].pos) < 1e-9);
// Moving the source moves the offset, and the lines follow its ends.
let from = sk.point(sk.resolve_point("s1.start").unwrap()).unwrap();
run(&mut s, "sketch.drag", json!({"entity":"s1.start","from":[from.x, from.y],"to":[from.x - 3.0, from.y + 2.0]}));
let moved = solved(&s, id);
moved.validate_freeform_offsets().unwrap();
assert!(moved.points[start].pos.dist(sk.points[start].pos) > 1.0, "the offset followed its source");
assert!(moved.points[*l1_end].pos.dist(moved.points[start].pos) < 1e-6);
assert!(moved.points[*l2_start].pos.dist(moved.points[end].pos) < 1e-6);
// The design saves and loads with the connection.
let doc = Document::from_json(&s.doc.to_json()).unwrap();
let loaded = doc.sketch(id).unwrap();
loaded.validate_freeform_offsets().unwrap();
assert!(coincident(loaded, start, *l1_end));
}
#[test]
fn an_arc_from_an_offset_end_is_drawn_and_undone_atomically() {
let mut s = Session::default();
let (id, output) = offset_spline(&mut s);
let before = s.doc.to_json();
run(&mut s, "sketch.arc.three_point", json!({"start":format!("{output}.end"),"through":[33,3],"end":"s1.end"}));
let sk = solved(&s, id);
sk.validate_freeform_offsets().unwrap();
let end = sk.curves[sk.curve_index(&output).unwrap()].kind.ends().unwrap().1;
assert_eq!(sk.curves.iter().filter(|c| c.kind.uses(end)).count(), 1);
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.to_json(), before);
}
#[test]
fn an_open_offset_spline_shows_only_its_ends() {
let mut s = Session::default();
let (id, output) = offset_spline(&mut s);
let sk = solved(&s, id);
let o = sk.curve_index(&output).unwrap();
let (start, end) = sk.curves[o].kind.ends().unwrap();
let nodes = sk.curves[o].kind.point_ids();
assert!(nodes.len() > 2);
for i in nodes {
assert_eq!(crate::view::sketch_point_visible(&sk, i), i == start || i == end, "node {i}");
}
}
+4 -2
View File
@@ -179,7 +179,7 @@ pub static COMMANDS: &[CommandSpec] = &[
.params("entities: [curve, point or constraint ids]"),
CommandSpec::new("sketch.move_point", "Drag Sketch Point", move_point).icon("sketch_move").enabled(in_sketch).params("point: ref, to: [x,y]"),
CommandSpec::new("sketch.drag", "Drag Sketch Geometry", drag).icon("sketch_move").enabled(in_sketch).live(drag_live).params(
"entity: point or curve, from: [x,y] where it was grabbed, to: [x,y], live?: bool: a point goes to `to`, a line (spline, conic) slides by to - from, a circle or arc grabbed on its edge takes the radius to `to`; the rest moves as little as it can and keeps its solution branch (a long move is followed in short steps, each solved with the entity held; a step that cannot be solved that way is approached as far as it can be and the next ones tried; when none past it solves, the last one is left to the sketch's ordinary solve, which may pull it back or leave the sketch unsolved; a step that would sweep an arc through its start is undone and the drag ends there). Returns `reached`: how much of the way from `from` to `to` the geometry followed (0..1), and `at`: where the grabbed spot went (`from` moved as the dragged points did), the `from` of the next move. `live: true` (each frame of an interactive drag) solves the sketch once and leaves the features after it as they were (stale) until `sketch.drag_end` or any command that reads the model; `dof` is then the one from before the drag",
"entity: point or curve, from: [x,y] where it was grabbed, to: [x,y], live?: bool: a point moves toward `to` within the design constraints; when exact cursor placement is impossible, bounded feasible steps minimize cursor distance while keeping design constraints mandatory. A line (spline, conic) slides by to - from; a circle or arc grabbed on its edge takes the radius to `to`. Long moves use continuation to retain the solution branch, and a step that would sweep an arc through its start is rejected. Returns `reached`: progress along the requested move (0..1; feasible point motion projects its actual displacement onto that move), and `at`: where the grabbed spot went (`from` moved as the dragged points did), the `from` of the next move. `live: true` solves the sketch and leaves later features stale until `sketch.drag_end` or a command that reads the model; `dof` is then the one from before the drag",
),
CommandSpec::new("sketch.drag_end", "End Sketch Drag", drag_end).icon("sketch_move").enabled(in_sketch).doc_only().params(
"constraint?: {command: a sketch.constraint.* id, params}: ends a drag made of `sketch.drag` `live: true` frames: adds the constraint it let go on (not kept if it over-constrains the sketch), then evaluates the whole model once; returns the constraint's result and the sketch's solved state and degrees of freedom",
@@ -275,6 +275,8 @@ pub(super) fn edit_solved<T>(
// The stored sketch is solved for its stored values.
let stored = sk.driving_values();
let (out, left) = f(&mut sk, &mut doc)?;
// A curve that took an offset spline's generated node (drawn from its end) gets its own point there.
sk.detach_offset_points()?;
let offsets = !sk.offsets.is_empty() || !sk.freeform_offsets.is_empty();
let rep = match left {
// A drag step solved it (held, with the dimensions' values applied).
@@ -1435,7 +1437,7 @@ fn keep_size(sk: &Sketch, e: Ent) -> Vec<ConstraintKind> {
}
}
/// Satisfy a just-added constraint the way Fusion applies it: the first-picked entities stay
/// Satisfy a just-added constraint the way a dimensional-constraint tool applies it: the first-picked entities stay
/// put and only the second moves (first keeping its size, so it turns and slides, then also
/// resizing).
/// If the second can't move (fixed, projected or fully determined), the first moves instead;
+1 -1
View File
@@ -166,7 +166,7 @@ fn filleting_a_side_keeps_its_original_midpoint() {
/// The spout of a basin: a vertical riser and a horizontal top, the top's length dimensioned
/// to the corner and the corner's height dimensioned from the origin. Filleting the corner used
/// to drop the height and leave the length on the shortened straight part, so the top came out
/// a fillet radius too long. Like Fusion, the corner now stays as a virtual sharp corner (a point
/// a fillet radius too long. The corner now stays as a virtual sharp corner (a point
/// on both lines' extensions) and the dimensions measure to it.
#[test]
fn a_fillet_keeps_the_dimensions_of_its_corner_on_a_virtual_sharp() {
+44 -7
View File
@@ -104,29 +104,31 @@ impl Path {
}
}
/// Drag: the grabbed entity follows the cursor exactly (a point goes to it; a line or spline
/// Drag: the grabbed entity follows the cursor where possible (a point moves toward it; a line or spline
/// slides by the cursor's move; a circle or arc grabbed on its edge changes radius about its
/// centre; a conic grabbed on its curve reads its fullness, `rho`, off the cursor) and the rest
/// of the sketch moves as little as it can, staying on the
/// solution it started on. Fixed geometry stays. A step that cannot be solved with the entity
/// held (the path crosses where the geometry cannot reach) is approached by halving the way to
/// it, and the next steps are tried from the last solved state, the last of them being the whole
/// remaining move. When none past it solves, the first failed step stays placed but unsolved:
/// remaining move. A point whose exact pin fails instead minimizes cursor distance in the
/// design's remaining freedom, retaining only corrected, feasible poses. For other entities,
/// when none past it solves, the first failed step stays placed but unsolved:
/// the edit's ordinary, non-strict solve that follows (seeded on the last solved state, see
/// [`Left::MovedFrom`]) then pulls it back (fully constrained geometry) or leaves the sketch
/// unsolved for the user to see. A step that would sweep an arc through its own start is undone
/// and the drag ends there. Returns how far it got and what it leaves for the edit's solve
/// ([`Left::Solved`]: the held solve of the last step it kept left the sketch solved).
///
/// `live`: one frame of an interactive drag, whose held solves share one frame's decomposition
/// budget ([`Budget::frame`]); otherwise each has a solve's own.
/// `live`: one frame of an interactive drag, whose held solves and cursor correction share
/// one frame's work budget ([`Budget::frame`]); otherwise each has a solve's own.
pub(super) fn drag_entity(sk: &mut Sketch, e: Ent, from: Vec2, to: Vec2, live: bool) -> (Dragged, Left) {
let Some((path, hold, n, short)) = prepare(sk, e, from, to) else {
return (Dragged { reached: 0.0, moving: Vec::new() }, Left::Unsolved);
};
#[cfg(test)]
let (n, short) = (n.min(step_cap::get()), short.min(step_cap::get()));
let moving = path.points().filter_map(|q| Some((q, sk.point(q)?))).collect();
let moving: Vec<_> = path.points().filter_map(|q| Some((q, sk.point(q)?))).collect();
let mut frame = live.then(frame_budget);
// Only success matters to these solves: the degrees of freedom of a sketch with the entity
// held are not the sketch's. Each is seeded on the last solved state (`last`), so what the
@@ -211,6 +213,41 @@ pub(super) fn drag_entity(sk: &mut Sketch, e: Ent, from: Vec2, to: Vec2, live: b
failed = Some(t);
}
}
// First retain held continuation across unreachable stretches of an otherwise reachable
// path (for example an arm dragged through the hole around its shoulder). If it cannot
// arrive, optimize the full cursor target without relaxing the design constraints.
if failed.is_some()
&& let Ent::Point(q) = e
{
let (points, radii) = sk.coupled(&hold.points, &hold.radii);
let size = smallest_size(sk, &hold, Some(Group { points: &points, radii: &radii }));
let max_step = (size * SHORT_STEP_FRACTION).min(path.length()).max(MIN_STEP);
#[cfg(test)]
let spent = frame.as_ref().map_or(0, Budget::spent);
let followed = solidcraft_sketch::move_point_towards(sk, q, to, max_step, frame.as_mut(), |before, after| {
let mut geometry = Geometry::default();
geometry.take(before);
!geometry.sweep_wrapped(after)
});
#[cfg(test)]
if let Some(budget) = &frame {
solves.work += budget.spent() - spent;
solves.cut |= budget.exhausted();
}
if followed {
#[cfg(test)]
last_drag::set(solves);
let reached = moving
.iter()
.find(|(p, _)| *p == q)
.and_then(|(_, start)| {
let d = to - *start;
sk.point(q).map(|at| if at.dist(to) < 1e-6 || d.dot(d) == 0.0 { 1.0 } else { ((at - *start).dot(d) / d.dot(d)).clamp(0.0, 1.0) })
})
.unwrap_or(0.0);
return (Dragged { reached, moving }, Left::Solved);
}
}
#[cfg(test)]
last_drag::set(solves);
let left = match failed {
@@ -225,8 +262,8 @@ pub(super) fn drag_entity(sk: &mut Sketch, e: Ent, from: Vec2, to: Vec2, live: b
(Dragged { reached: good, moving }, left)
}
/// The held solves of one drag: how many, the decomposition work they did, and whether one ran
/// out of budget.
/// The held solves of one drag: how many, their shared work (including cursor correction),
/// and whether it ran out of budget.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub(super) struct Solves {
pub count: usize,
+27 -1
View File
@@ -257,6 +257,31 @@ fn tie(sk: &mut Sketch, pieces: &[usize]) -> Result<()> {
Ok(())
}
/// Dimensions and relations that measure the whole of curve `ci` (a line's length, an equal
/// length, its midpoint, an arc's length or sweep) would stay on the piece that keeps its id
/// after a trim and stretch that piece back to the old size, moving the rest of the sketch. A
/// length becomes the distance between the curve's old ends, as a fillet keeps it to its virtual
/// corner (it goes with an end the trim removes); the others go.
fn release_extent(sk: &mut Sketch, ci: usize) {
let ends = match sk.curves.get(ci).map(|c| &c.kind) {
Some(CurveKind::Line { a, b }) => Some((*a, *b)),
_ => None,
};
sk.constraints.retain_mut(|k| match k.kind {
ConstraintKind::Length { l, value } if l == ci => match ends {
Some((p, q)) => {
k.kind = ConstraintKind::Distance { p, q, value };
true
}
None => false,
},
ConstraintKind::Equal { a, b } if ends.is_some() && (a == ci || b == ci) => false,
ConstraintKind::Midpoint { l, .. } if l == ci => false,
ConstraintKind::ArcLength { c, .. } | ConstraintKind::ArcSweep { c, .. } if c == ci => false,
_ => true,
});
}
/// The cut parameters bracketing `tq` on curve `ci`: (before, after) with their cutter curves.
fn bracket(sk: &Sketch, ci: usize, tq: f64) -> (Vec<(f64, Vec2, usize)>, bool) {
let Some(sh) = sk.shape(ci) else { return (Vec::new(), false) };
@@ -305,6 +330,7 @@ fn trim(s: &mut Session, p: &Value) -> Result<Value> {
doc.prune_model_params();
return Ok(json!({"deleted": id}));
}
release_extent(sk, ci);
let ts: Vec<f64> = cuts.iter().map(|c| c.0).collect();
let (pieces, pts) = split_at(sk, ci, &ts)?;
// The piece containing tq.
@@ -638,7 +664,7 @@ struct CornerKept {
/// 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, as in Fusion. Otherwise None: the
/// 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<Option<CornerKept>> {
let used = sk.curves.iter().any(|x| uses(x, c));
@@ -1585,3 +1585,37 @@ fn offset_driver_format18_offsets_keep_fixed_distances_on_upgrade() {
assert!(!sk.constraints.iter().any(|c| matches!(c.kind, ConstraintKind::OffsetDistance { .. })));
assert_eq!(ids(&result["curves"]).len(), sk.offsets[0].outputs.len());
}
#[test]
fn trim_keeps_the_rest_of_a_dimensioned_line_where_it_was() {
// A 40 x 40 square, its right side dimensioned with the Dimension tool (a line length).
let mut s = new_sketch();
let sq = ids(&run(&mut s, "sketch.line", json!({"points": [[0, 0], [40, 0], [40, 40], [0, 40]], "closed": true, "infer": true}))["curves"]);
let (right, top) = (sq[1].clone(), sq[2].clone());
run(&mut s, "sketch.dimension", json!({"entities": [right]}));
// A step drawn across the top right corner, its ends on the top and right sides.
let step = ids(&run(&mut s, "sketch.line", json!({"points": [[23, 40], [23, 23], [40, 23]], "infer": true}))["curves"]);
run(&mut s, "sketch.constraint.coincident", json!({"a": format!("{}.start", step[0]), "b": top}));
run(&mut s, "sketch.constraint.coincident", json!({"a": format!("{}.end", step[1]), "b": right}));
let corner = |s: &Session| {
let sk = sketch(s);
sk.point(sk.resolve_point(&format!("{}.end", step[1])).unwrap()).unwrap()
};
let forty = |s: &Session| {
sketch(s).constraints.iter().filter(|c| c.kind.value().is_some_and(|v| close(v, 40.0))).map(|c| c.kind.clone()).collect::<Vec<_>>()
};
// Trim the right side above the step: the 40 stays between the square's corners, which are
// both still there, instead of stretching the 23 mm that is left back to 40.
run(&mut s, "sketch.trim", json!({"curve": right, "at": [40, 30]}));
assert!(corner(&s).dist(Vec2::new(40.0, 23.0)) < 1e-6, "the step moved to {:?}", corner(&s));
let sk = sketch(&s);
let ends = sk.shape(sk.curve_index(&right).unwrap()).unwrap();
assert!(matches!(ends, solidcraft_sketch::Shape::Line { a, b } if close(a.dist(b), 23.0)), "{ends:?}");
assert!(matches!(forty(&s)[..], [ConstraintKind::Distance { .. }]), "{:?}", forty(&s));
// Trim the top side right of the step: the corner the 40 measured to goes, and the 40 with it.
run(&mut s, "sketch.trim", json!({"curve": top, "at": [30, 40]}));
assert!(corner(&s).dist(Vec2::new(40.0, 23.0)) < 1e-6, "the step moved to {:?}", corner(&s));
assert!(forty(&s).is_empty(), "{:?}", forty(&s));
}
+1 -1
View File
@@ -550,7 +550,7 @@ pub(super) fn project_internal_faces(s: &mut Session, id: u64, body: &str, indic
Ok(links)
}
/// Project the edges of the face a new sketch was created on (Fusion's default).
/// Project the edges of the face a new sketch was created on (the default).
pub(super) fn auto_project_face(s: &mut Session, id: u64, _at: Vec3) -> Result<Option<String>> {
let mut doc = (*s.doc).clone();
let Some(anchor) = doc
@@ -609,3 +609,71 @@ fn a_rectangle_put_onto_a_projected_edge_stays_a_closed_profile() {
let reopened = Session::new(solidcraft_doc::Document::from_json(&s.doc.to_json()).unwrap());
assert!((volume(&reopened, "Pad") - 3000.0).abs() < 1e-6, "{}", volume(&reopened, "Pad"));
}
/// A face with letters cut into it, extruded as a cut: its edges come into the face's sketch
/// as the letters' exact curves (a font's quadratics and cubics), not fitted to the mesh, so the
/// tool's walls lie on the letters' walls and the cut takes what the same region picked in the
/// sketch takes (#1360). Fitted to the mesh (684 lines, 31 arcs and 18 splines here), they lay
/// a few hundredths off the letters' walls, and the boolean found no result.
#[test]
fn a_face_engraved_with_text_projects_its_letters_exactly_and_extrudes() {
let volume = |s: &mut Session| run(s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
let engraved = || {
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":[105,26]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance":40}));
run(&mut s, "sketch.create", json!({"plane":{"face":[50,13,40]}}));
run(&mut s, "sketch.text", json!({"text":"textvraiment tres long","at":[3,9],"height":6.5}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"distance":-1,"operation":"cut"}));
s
};
// The face round the letters, drawn in a sketch in its plane.
let mut drawn = engraved();
let before = volume(&mut drawn);
run(&mut drawn, "sketch.create", json!({"plane":"XY","offset":40}));
run(&mut drawn, "sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[105,26]}));
run(&mut drawn, "sketch.text", json!({"text":"textvraiment tres long","at":[3,9],"height":6.5}));
run(&mut drawn, "sketch.finish", json!({}));
run(&mut drawn, "solid.extrude", json!({"distance":-3.5,"operation":"cut","profiles":[{"point":[1,1]}]}));
let taken = before - volume(&mut drawn);
assert!(taken > 0.0, "{taken}");
// The engraved face itself, extruded as a cut.
let mut s = engraved();
let id = run(&mut s, "solid.extrude", json!({"distance":-3.5,"operation":"cut","face":[1,1,40]}))["feature"].as_u64().unwrap();
let r = s.model.result(id).unwrap();
assert!(r.error.is_none(), "{r:?}");
let face = before - volume(&mut s);
assert!((face - taken).abs() < 1e-6 * before, "{face} {taken}");
// Its sketch got the letters as Bézier pieces.
let st = s.model.state();
let sketch = &st.sketches.last().unwrap().sketch;
let pieces =
sketch.curves.iter().filter(|c| c.link.is_some() && matches!(c.kind, solidcraft_sketch::CurveKind::Spline { control: true, .. })).count();
assert!(pieces > 100, "{pieces}");
}
/// A design saved before free-form edges came in exactly, by the build before it: an "S" cut
/// 1 mm into a 30 × 20 × 10 block, then a sketch on that face (its edges projected, the letter's
/// fitted to the mesh as 72 lines, 9 arcs and 2 splines) with a circle cut 3 mm. On reopening, the
/// face's link is laid out again with the letter's exact pieces, and the part builds the same.
#[test]
fn a_face_sketch_saved_with_fitted_letters_reopens_and_builds_the_same() {
let text = include_str!("../../../../tests/projection/before-1508/engraved_face_sketched.json");
let mut s = Session::new(solidcraft_doc::Document::from_json(text).unwrap());
let volume = run(&mut s, "inspect.measure", json!({}))["total"]["volume_mm3"].as_f64().unwrap();
for name in ["Extrude2", "Sketch3", "Extrude3"] {
let id = s.doc.find_feature(name).unwrap().id;
let r = s.model.result(id).unwrap();
assert!(r.error.is_none(), "{name}: {:?}", r.error);
}
// The build that saved it measured 5916.018831 mm³.
assert!((volume - 5916.018830904924).abs() < 1e-3, "{volume}");
let st = s.model.state();
let sketch = &st.sketches.last().unwrap().sketch;
let pieces =
sketch.curves.iter().filter(|c| c.link.is_some() && matches!(c.kind, solidcraft_sketch::CurveKind::Spline { control: true, .. })).count();
assert!(pieces > 0, "{pieces}");
}
+1 -1
View File
@@ -61,7 +61,7 @@ fn trim_cases() {
let _ = r;
let mut s = new_sketch();
let l = add(&mut s, "sketch.line", json!({"points": [[0, 0], [10, 0]]}))[0].clone();
// No crossings: the whole line goes (Fusion deletes it).
// No crossings: the whole line goes (it is deleted).
let r = s.execute("sketch.trim", &json!({"curve": l, "at": [5, 0]}));
sound_or_empty(&s);
assert!(r.is_ok() || r.is_err());
+19
View File
@@ -1524,3 +1524,22 @@ fn the_first_dimension_does_not_scale_a_sketch_that_has_a_size() {
let out = run(&mut s, "sketch.dimension", json!({"entities": ["l1", "l2"], "value": "60 deg", "scale_sketch": true}));
assert!(out.get("scaled").is_none(), "{out}");
}
/// The cursor fallback shares the held solves' frame budget; a scripted drag can finish later.
#[test]
fn cursor_fallback_respects_an_exhausted_live_frame_budget() {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[0, 0], [-10, 0]], "ids": ["crank"], "infer": false}));
run(&mut s, "sketch.dimension", json!({"entities": ["crank"], "value": 10}));
let target = v(-10.0, 16.0);
let nearest = target * (10.0 / target.len());
super::drag::last_drag::with_frame_work(0, || {
run(&mut s, "sketch.drag", json!({"entity": "crank.end", "from": [-10, 0], "to": [target.x, target.y], "live": true}));
});
let at = pt(&s, "crank.end");
assert!(at.dist(nearest) > 1.0, "an exhausted frame must not start unbudgeted cursor work: {at:?}");
assert!((at.len() - 10.0).abs() < 1e-6);
assert!(s.model.state().sketch(s.active_sketch.unwrap()).unwrap().report.ok());
run(&mut s, "sketch.drag", json!({"entity": "crank.end", "from": [at.x, at.y], "to": [target.x, target.y], "live": false}));
assert!(pt(&s, "crank.end").dist(nearest) < 1e-4, "scripted motion retains the full cursor objective");
}
+66 -11
View File
@@ -6,7 +6,12 @@ use super::CommandSpec;
use crate::params::{bad, num, req_vec2, req_vec3, vec3};
use crate::{Result, Session};
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new("view.navigate", "Navigate View", navigate)
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("view.graphics_preset", "Graphics Preset", graphics_preset)
.icon("settings").noundo().doc_only().params("preset: performance|quality → preset, enabled, display_quality, supersampling, supersampling_adaptive; Quality: AO, Fine, adaptive 2x SSAA; Performance: no AO, Normal, no SSAA"),
CommandSpec::new("view.ambient_occlusion", "Ambient Occlusion", ambient_occlusion)
.icon("settings").noundo().doc_only().params("enabled: bool → enabled; apply soft contact shading in the GPU viewport"),
CommandSpec::new("view.navigate", "Navigate View", navigate)
.icon("orbit")
.noundo()
.doc_only()
@@ -91,16 +96,8 @@ fn continuous(mut cam: Camera, p: &Value) -> Result<Value> {
cam.pan(delta.x, delta.y, viewport()?.y);
} else if p.get("pivot").is_some() {
let pivot = req_vec3(cmd, p, "pivot")?;
let size = viewport()?;
let before = cam.to_screen(pivot, size.x, size.y, 1.0);
cam.orbit(delta.x, delta.y);
let after = cam.to_screen(pivot, size.x, size.y, 1.0);
if let (Some((ax, ay, _)), Some((bx, by, _))) = (before, after) {
// Pan operates at target depth. Perspective screen correction must instead
// use the pivot's depth after orbiting; lateral pan leaves that depth fixed.
let scale = if cam.fov > 0.0 { (cam.eye() - pivot).dot(cam.back()) / cam.distance } else { 1.0 };
cam.pan((ax - bx) * scale, (ay - by) * scale, size.y);
}
viewport()?;
cam.orbit_about(delta.x, delta.y, pivot);
} else {
cam.orbit(delta.x, delta.y);
}
@@ -294,3 +291,61 @@ mod tests {
}
}
}
fn ambient_occlusion(_s: &mut Session, p: &Value) -> Result<Value> {
let enabled = p.get("enabled").and_then(Value::as_bool).ok_or_else(|| bad("view.ambient_occlusion", "enabled must be a boolean"))?;
Ok(json!({"enabled": enabled}))
}
#[cfg(test)]
mod ambient_occlusion_tests {
use super::*;
#[test]
fn validates_display_setting_without_editing_design_or_history() {
let mut s = Session::default();
let before = s.doc.to_json();
for enabled in [true, false] {
assert_eq!(s.execute("view.ambient_occlusion", &json!({"enabled": enabled})).unwrap(), json!({"enabled": enabled}));
}
for p in [json!({}), json!({"enabled": 1}), json!({"enabled": "true"}), json!({"enabled": null})] {
assert!(s.execute("view.ambient_occlusion", &p).is_err());
}
assert_eq!(s.doc.to_json(), before);
assert!(s.undo.is_empty());
}
}
/// One preset for contact shading, tessellation and supersampling. MSAA stays independent.
fn graphics_preset(_s: &mut Session, p: &Value) -> Result<Value> {
let preset = p.get("preset").and_then(Value::as_str);
let enabled = match preset {
Some("quality") => true,
Some("performance") => false,
_ => return Err(bad("view.graphics_preset", "preset must be performance or quality")),
};
Ok(json!({"preset": preset, "enabled": enabled, "display_quality": if enabled { "fine" } else { "normal" },
"supersampling": if enabled { 2.0 } else { 1.0 }, "supersampling_adaptive": true}))
}
#[cfg(test)]
mod graphics_preset_tests {
use super::*;
#[test]
fn presets_validate_without_changing_design_or_history() {
let mut s = Session::default();
let before = s.doc.to_json();
for (preset, enabled) in [("quality", true), ("performance", false)] {
assert_eq!(
s.execute("view.graphics_preset", &json!({"preset":preset})).unwrap(),
json!({"preset":preset,"enabled":enabled,"display_quality":if enabled { "fine" } else { "normal" },
"supersampling":if enabled { 2.0 } else { 1.0 },"supersampling_adaptive":true})
);
}
for params in [json!({}), json!({"preset":"fast"}), json!({"preset":true}), json!({"preset":null})] {
assert!(s.execute("view.graphics_preset", &params).is_err());
}
assert_eq!(s.doc.to_json(), before);
assert!(s.undo.is_empty());
}
}
+1 -1
View File
@@ -71,7 +71,7 @@ fn a_large_sheet_shows_its_small_bends_and_holes_round() {
let (mut bends, mut walls) = (0, 0);
for face in 0..b.body.face_count() {
let Some(FaceSurface::Cylinder { origin, axis, radius }) = face_surface(&b.body, face) else { continue };
let (steps, widest) = steps_around(&m, face, origin, axis);
let (steps, widest) = steps_around(&m, face, origin, *axis);
assert!(widest <= DISPLAY_ANGLE * 1.001, "radius {radius}: a facet spans {} degrees", widest.to_degrees());
if (radius - 2.0).abs() < 1e-6 {
// Half of a hole's wall.
+52
View File
@@ -322,3 +322,55 @@ fn sketch_floating_in_the_gap_touching_neither_still_drills_down() {
near(base - volume(&mut s), pi() * 25.0 * 4.0);
assert!(!solid_at(&s, 20.0, 20.0, 4.0) && solid_at(&s, 20.0, 20.0, 16.0));
}
/// #2387: a drill point crossing the sloping back of a wall must leave a real hole.
#[test]
fn drilled_sketch_points_break_through_a_sloping_back_face() {
let mut s = Session::default();
run(&mut s, "sketch.create", json!({"plane": {"origin": [0, 0, 0], "x_dir": [1, 0, 0], "y_dir": [0, 0, 1]}, "name": "Wall"}));
run(&mut s, "sketch.line", json!({"points": [[0, 0], [15, 0], [15, 30], [7.5, 30], [0, 0]]}));
run(&mut s, "sketch.finish", json!({}));
run(&mut s, "solid.extrude", json!({"sketch": "Wall", "distance": 40, "direction": "negative"}));
run(&mut s, "sketch.create", json!({"plane": {"origin": [15, 0, 0], "x_dir": [0, 1, 0], "y_dir": [0, 0, 1]}, "name": "Pts"}));
run(&mut s, "sketch.line", json!({"points": [[5, 16], [10, 16]], "ids": ["l10"]}));
run(&mut s, "sketch.point", json!({"point": [30, 16], "id": "p12"}));
run(&mut s, "sketch.finish", json!({}));
run(
&mut s,
"solid.hole",
json!({"sketch": "Pts", "points": ["l10.end", "p12"], "depth": "10 mm", "diameter": "5 mm", "type": "drilled", "tip_angle": "118 deg", "name": "H"}),
);
let id = s.doc.find_feature("H").unwrap().id;
let result = s.model.result(id).unwrap();
assert!(result.error.is_none());
assert!(result.boolean_diagnostics.unverified().is_none());
// The cylindrical bore and cone, less the oblique conical cap outside x = z/4.
let r = 2.5;
let k = 59f64.to_radians().tan();
let tip = r / k;
let outside = 4.0 - (5.0 - tip);
let cap = pi() * k * k * outside.powi(3) / (3.0 * (1.0 - (k / 4.0).powi(2)).powf(1.5));
let expected = 2.0 * (pi() * r * r * (10.0 + tip / 3.0) - cap);
let left = volume(&mut s);
// The measured curved faces are tessellated; 0.05 mm³ is 0.013% of the removed material.
assert!((13_500.0 - left - expected).abs() < 0.05, "removed {} vs {expected}", 13_500.0 - left);
assert_eq!(s.model.state().bodies.len(), 1);
for b in &s.model.state().bodies {
assert!(b.body.validity().is_empty());
assert_eq!(b.body.shell_count(), 1);
}
for y in [10.0, 30.0] {
assert!(!solid_at(&s, 10.0, y, 16.0), "bore at y = {y}");
assert!(!solid_at(&s, 4.2, y, 16.0), "tip breaks through at y = {y}");
assert!(solid_at(&s, 10.0, y + 3.0, 16.0), "material beside the bore at y = {y}");
}
let saved = s.doc.to_json();
run(&mut s, "edit.undo", json!({}));
near(volume(&mut s), 13_500.0);
run(&mut s, "edit.redo", json!({}));
assert_eq!(s.doc.to_json(), saved);
near(volume(&mut s), left);
let mut reopened = Session::new(solidcraft_doc::Document::from_json(&saved).unwrap());
assert!(reopened.model.result(id).unwrap().error.is_none());
near(volume(&mut reopened), left);
}
+1 -1
View File
@@ -221,7 +221,7 @@ pub struct Session {
pub log: Vec<String>,
/// Increments on every document change (views use it to refresh).
pub revision: u64,
/// Project a face's edges into a sketch created on it (Fusion's default; `sketch.auto_project`).
/// Project a face's edges into a sketch created on it (the default; `sketch.auto_project`).
pub auto_project: bool,
/// The component new sketches and features go into (0 = the root).
pub active_component: u64,
+1 -1
View File
@@ -88,7 +88,7 @@ fn split_pieces_count_and_move_apart() {
// Measure counts the two halves of the top as two faces.
let m = run(&mut s, "inspect.measure", json!({ "bodies": ["B"] }))["bodies"][0].clone();
assert_eq!(m["faces"].as_u64(), Some(7), "{m}");
// Offset Face on one half moves that half only (Fusion): a 20 × 30 step 5 high.
// Offset Face on one half moves that half only: a 20 × 30 step 5 high.
run(&mut s, "solid.offset_face", json!({"faces": [[10, 15, 20]], "distance": 5}));
let (_, v) = faces_volume(&mut s, "B");
assert!((v - 27000.0).abs() < 1e-6 * 27000.0, "{v}");
+1 -1
View File
@@ -652,7 +652,7 @@ fn sketch_tools_and_constraints() {
assert!(s.execute("sketch.line", &json!({"points": [[0, 0], [1, 1]]})).is_err(), "needs an active sketch");
}
/// The recipe format of the Fusion oracle (01-box) drives the same commands.
/// The recipe format of the 01-box reference drives the same commands.
#[test]
fn script_runs_commands() {
let mut s = Session::default();
+1 -1
View File
@@ -232,7 +232,7 @@ fn shell_of_a_filleted_box() {
/// The first tutorial part: a 60×40×15 plate with its top front edge and front right edge
/// rounded r2, shelled with the top open. At 2 mm the rounds' inner sides collapse to sharp
/// edges (as in Fusion); at 1.5 mm the top's round, tangent to the open top, keeps its
/// edges (the intended behaviour); at 1.5 mm the top's round, tangent to the open top, keeps its
/// tangency (every face moves in, the opening is swept out through the top).
#[test]
fn shell_of_the_tutorial_plate() {
@@ -11,8 +11,8 @@
//!
//! Each seed is its own test (`seed_00` … `seed_95`), so the runner spreads them over the CI
//! shards and their cores: a few seeds take minutes, and grouped four ways they made one test
//! of half an hour. Pull requests that touch `crates/kernel` run every 8th seed (ci.yml);
//! pushes to main and the nightly run every seed.
//! of half an hour. Pull requests that can change them run every seed but the slowest few
//! (`SLOW_SEEDS` in ci.yml); the merge queue, pushes to main and the nightly run every seed.
use std::collections::BTreeMap;
use std::path::PathBuf;
@@ -202,3 +202,29 @@ fn the_bodies_are_named_after_the_solid_the_product_or_the_instance() {
run(&mut again, "solid.move", json!({"bodies": ["Post-1"], "translate": [0, 0, 5]}));
assert_eq!(body_names(&again), ["Plate-1", "Post-1"]);
}
/// `matrix` places the inserted file's component, after `up`, in the same command (one undo
/// step); a matrix that is not rigid is refused before anything is added.
#[test]
fn a_step_file_can_be_inserted_at_a_matrix() {
let path = frame_file("matrix");
let quarter = json!([0, -1, 0, 100, 1, 0, 0, 0, 0, 0, 1, 0]);
let mut s = Session::default();
let r = run(&mut s, "file.insert_step", json!({"path": path, "matrix": quarter}));
// (x, y, z) goes to (100 - y, x, z): the frame (0..40, 0..34, 0..20) spans x 66..100, y 0..40.
let (lo, hi) = extent(&s);
assert!(near(lo, [66.0, 0.0, 0.0]) && near(hi, [100.0, 40.0, 20.0]), "{lo:?} {hi:?} {r}");
assert_eq!(r["transform"][3][0], json!(100.0), "{r}");
assert_eq!(s.undo.len(), 1);
// Stood up first ((0..40, -20..0, 0..34)), then turned and moved.
let mut d = Session::default();
run(&mut d, "file.insert_step", json!({"path": path, "up": "y", "matrix": quarter}));
let (lo, hi) = extent(&d);
assert!(near(lo, [100.0, 0.0, 0.0]) && near(hi, [120.0, 40.0, 34.0]), "{lo:?} {hi:?}");
let mut d = Session::default();
let e = d.execute("file.insert_step", &json!({"path": path, "matrix": [2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 2, 0]})).unwrap_err().to_string();
assert!(e.contains("not rigid"), "{e}");
assert!(d.doc.features.is_empty() && d.doc.occurrences.is_empty());
}
+32 -10
View File
@@ -301,7 +301,8 @@ pub fn sketch_curve_visible(sk: &Sketch, i: usize) -> bool {
}
/// Point roles shown and picked individually. Associated offsets keep their generated
/// fit nodes private until their association is broken. Build once per sketch/frame.
/// fit nodes private until their association is broken, except an open offset's two ends,
/// which other curves snap and constrain to. Build once per sketch/frame.
pub struct SketchPointVisibility<'a> {
hidden: std::collections::HashSet<&'a str>,
}
@@ -321,7 +322,9 @@ impl<'a> SketchPointVisibility<'a> {
let mut hidden: std::collections::HashSet<&str> =
sk.points.iter().filter(|p| p.link.as_deref().is_some_and(|l| text.contains(l))).map(|p| p.id.as_str()).collect();
for curve in sk.curves.iter().filter(|c| outputs.contains(c.id.as_str())) {
hidden.extend(curve.kind.point_ids().into_iter().filter_map(|i| sk.points.get(i).map(|p| p.id.as_str())));
let ends = curve.kind.ends();
let interior = curve.kind.point_ids().into_iter().filter(|i| ends.is_none_or(|(a, b)| *i != a && *i != b));
hidden.extend(interior.filter_map(|i| sk.points.get(i).map(|p| p.id.as_str())));
}
Self { hidden }
}
@@ -332,7 +335,7 @@ impl<'a> SketchPointVisibility<'a> {
}
/// Should sketch point `i` be drawn or picked individually? Text outlines and derived
/// offset fit nodes are private. Batch callers use [`SketchPointVisibility`].
/// offset fit nodes (but an open offset's ends) are private. Batch callers use [`SketchPointVisibility`].
pub fn sketch_point_visible(sk: &Sketch, i: usize) -> bool {
sk.points.get(i).is_some_and(|p| SketchPointVisibility::new(sk).takes(p))
}
@@ -349,6 +352,20 @@ pub fn scene_with_section(s: &Session, cam: &Camera, section: Option<(Vec3, Vec3
scene_with_guides(s, cam, section, Guides::default())
}
/// The plane of the sketch that is open, if any: it always shows a grid, whatever the Grid switch
/// says (outside a sketch the grid is the user's choice).
pub fn open_sketch_plane(s: &Session) -> Option<Plane> {
let id = s.active_sketch?;
s.world_state().sketch(id).map(|ss| ss.plane)
}
/// The grid on a sketch plane: its centre (the view target's foot on the plane, snapped to the
/// major step in the plane's own axes) and the plane's x and y directions.
pub fn sketch_grid_frame(plane: &Plane, target: Vec3, major: f64) -> (Vec3, Vec3, Vec3) {
let snap = |d: Vec3| d * (((target - plane.origin).dot(d) / major).round() * major);
(plane.origin + snap(plane.x) + snap(plane.y), plane.x, plane.y)
}
/// What a headless render draws besides the model: the ground grid and the origin's axes.
/// Both are on by default; the grid lies on Z 0, so it cuts through a model that straddles it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -434,12 +451,17 @@ fn build_scene(
if guides.grid {
let (minor, major) = grid_step(cam.half_height());
let ext = (cam.half_height() * 3.0 / major).ceil() * major;
let tf = cam.to_frame(cam.target);
// Headless pictures keep the grid on the ground, the viewport's default.
let (u, v) = (Vec3::X, Vec3::Y);
let snap = |a: Vec3| a * ((tf.dot(a) / major).round() * major);
let c = snap(u) + snap(v);
let w = |p: Vec3| cam.from_frame(p);
// Headless pictures keep the grid on the ground, the viewport's default; an open sketch
// has its own plane's grid (world coordinates, no camera frame).
let (c, u, v, w): (Vec3, Vec3, Vec3, Box<dyn Fn(Vec3) -> Vec3>) = if let Some(pl) = open_sketch_plane(s) {
let (c, u, v) = sketch_grid_frame(&pl, cam.target, major);
(c, u, v, Box::new(|p| p))
} else {
let tf = cam.to_frame(cam.target);
let (u, v) = (Vec3::X, Vec3::Y);
let snap = |a: Vec3| a * ((tf.dot(a) / major).round() * major);
(snap(u) + snap(v), u, v, Box::new(|p| cam.from_frame(p)))
};
let n = ((ext / minor) as i64).clamp(1, 400);
for i in -n..=n {
let t = i as f64 * minor;
@@ -528,7 +550,7 @@ pub fn section_segments(m: &solidcraft_geom::Mesh, o: Vec3, n: Vec3) -> Vec<(Vec
}
/// Is a sketch drawn? The active one is; others unless hidden. Finished sketches are hidden
/// once a feature uses them, like Fusion's default, unless shown one by one.
/// once a feature uses them, by default, unless shown one by one.
pub fn sketch_shown(s: &Session, id: u64) -> bool {
sketch_drawn(s, id, true)
}
+107
View File
@@ -0,0 +1,107 @@
//! Cursor distance is minimized within the design's freedom; lengths stay mandatory.
#[path = "support/four_bar.rs"]
mod four_bar;
use serde_json::json;
use solidcraft_geom::Vec2;
fn drag(s: &mut solidcraft_engine::Session, target: Vec2) {
let from = four_bar::point(s, "crank.end").unwrap();
s.execute("sketch.drag", &json!({"entity":"crank.end","from":[from.x,from.y],"to":[target.x,target.y],"live":true})).unwrap();
}
#[test]
fn four_bar_large_off_locus_drag_reaches_the_closest_feasible_point() {
for target in [Vec2::new(-10.0, 8.0), Vec2::new(-10.0, -8.0), Vec2::new(20.0, 20.0), Vec2::new(20.0, 0.0), Vec2::new(1000.0, -1000.0)] {
let mut s = four_bar::session().unwrap();
let side = four_bar::branch(&s).unwrap();
drag(&mut s, target);
let actual = four_bar::point(&s, "crank.end").unwrap();
let nearest = target * (10.0 / target.len());
assert!(actual.dist(nearest) < 1e-4, "target {target:?}, actual {actual:?}, expected {nearest:?}");
assert!(four_bar::length_error(&s).unwrap() < 1e-6);
assert_eq!(four_bar::branch(&s).unwrap(), side);
let state = s.model.state();
let report = &state.sketch(s.active_sketch.unwrap()).unwrap().report;
assert!(report.ok());
assert_eq!(report.dof, 1);
s.execute("sketch.drag_end", &json!({})).unwrap();
assert!(four_bar::point(&s, "crank.end").unwrap().dist(nearest) < 1e-4);
s.execute("edit.undo", &json!({})).unwrap();
assert!(four_bar::point(&s, "crank.end").unwrap().dist(Vec2::new(-10.0, 0.0)) < 1e-7);
}
}
#[test]
fn four_bar_reverse_motion_and_stationary_cursor_do_not_drift() {
let mut s = four_bar::session().unwrap();
let side = four_bar::branch(&s).unwrap();
for target in [Vec2::new(-10.0, 16.0), Vec2::new(-10.0, -16.0), Vec2::new(0.0, 25.0), Vec2::new(0.0, -25.0)] {
drag(&mut s, target);
let at = four_bar::point(&s, "crank.end").unwrap();
assert!(
at.dist(target * (10.0 / target.len())) < 1e-4,
"target {target:?}, actual {at:?}, length error {}",
four_bar::length_error(&s).unwrap()
);
for _ in 0..8 {
drag(&mut s, target);
assert!(four_bar::point(&s, "crank.end").unwrap().dist(at) < 1e-4);
assert!(four_bar::length_error(&s).unwrap() < 1e-6);
assert_eq!(four_bar::branch(&s).unwrap(), side);
}
}
}
#[test]
fn four_bar_one_event_and_split_moves_agree_and_keep_the_branch() {
let target = Vec2::new(-10.0, 16.0);
let mut single = four_bar::session().unwrap();
let mut split = four_bar::session().unwrap();
let start = four_bar::point(&single, "crank.end").unwrap();
let side = four_bar::branch(&single).unwrap();
drag(&mut single, target);
for i in 1..=16 {
drag(&mut split, start + (target - start) * (i as f64 / 16.0));
assert!(four_bar::length_error(&split).unwrap() < 1e-6);
assert_eq!(four_bar::branch(&split).unwrap(), side);
}
assert!(four_bar::point(&single, "crank.end").unwrap().dist(four_bar::point(&split, "crank.end").unwrap()) < 1e-4);
}
#[test]
fn four_bar_live_cursor_motion_updates_driven_dimensions_before_release() {
let mut s = four_bar::session().unwrap();
s.execute("sketch.dimension", &json!({"entities": ["crank"], "type": "horizontal", "driven": true})).unwrap();
let id = s.active_sketch.unwrap();
let dimension = s.doc.sketch(id).unwrap().constraints.last().unwrap().id.clone();
drag(&mut s, Vec2::new(-10.0, 8.0));
let at = four_bar::point(&s, "crank.end").unwrap();
assert!(at.dist(Vec2::new(-10.0, 8.0) * (10.0 / 164.0_f64.sqrt())) < 1e-4);
let state = s.model.state();
for sketch in [s.doc.sketch(id).unwrap(), &state.sketch(id).unwrap().sketch] {
let c = sketch.constraints.iter().find(|c| c.id == dimension).unwrap();
assert!(c.driven);
assert!((c.kind.value().unwrap() - at.x.abs()).abs() < 1e-6, "reference dimension must measure the final live pose: {:?}", c.kind);
}
}
#[test]
fn four_bar_other_joint_follows_the_rocker_arc_and_its_limits() {
let base = Vec2::new(42.0, 0.0);
let lo = -(-1264.0_f64 / 1680.0).acos();
let hi = -(0.2_f64).acos();
for angle in [-2.6_f64, -2.1, -1.5, -1.1] {
let mut s = four_bar::session().unwrap();
let side = four_bar::branch(&s).unwrap();
let from = four_bar::point(&s, "coupler.end").unwrap();
let target = base + Vec2::new(angle.cos(), angle.sin()) * 60.0;
let nearest = base + Vec2::new(angle.clamp(lo, hi).cos(), angle.clamp(lo, hi).sin()) * 20.0;
s.execute("sketch.drag", &json!({"entity":"coupler.end","from":[from.x,from.y],"to":[target.x,target.y],"live":true})).unwrap();
assert!(four_bar::point(&s, "coupler.end").unwrap().dist(nearest) < 1e-4, "angle {angle}");
assert!(four_bar::length_error(&s).unwrap() < 1e-6);
assert_eq!(four_bar::branch(&s).unwrap(), side);
s.execute("sketch.drag_end", &json!({})).unwrap();
assert!(four_bar::point(&s, "coupler.end").unwrap().dist(nearest) < 1e-4);
}
}
+45
View File
@@ -0,0 +1,45 @@
//! Public-safe 10/40/20/42 mm linkage, matching the reported one-degree-of-freedom sketch.
use serde_json::json;
use solidcraft_engine::Session;
use solidcraft_geom::Vec2;
pub type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
pub fn session() -> Result<Session> {
let mut s = Session::default();
s.execute("sketch.create", &json!({"plane":"XZ"}))?;
let x = -10.0 + (1600.0 - 400.0 + 52.0 * 52.0) / 104.0;
let y = -(1600.0_f64 - (x + 10.0) * (x + 10.0)).sqrt();
s.execute("sketch.line", &json!({"points":[[0,0],[-10,0],[x,y],[42,0]],"ids":["crank","coupler","rocker"],"infer":false}))?;
s.execute("sketch.line", &json!({"points":["crank.start","rocker.end"],"ids":["ground"],"infer":false}))?;
for (line, length) in [("crank", 10.0), ("coupler", 40.0), ("rocker", 20.0), ("ground", 42.0)] {
s.execute("sketch.dimension", &json!({"entities":[line],"value":length}))?;
}
s.execute("sketch.constraint.horizontal_vertical", &json!({"line":"ground"}))?;
Ok(s)
}
pub fn point(s: &Session, name: &str) -> Result<Vec2> {
let sk = s.doc.sketch(s.active_sketch.ok_or("missing active sketch")?)?;
sk.resolve_point(name).and_then(|p| sk.point(p)).ok_or_else(|| format!("missing point {name}").into())
}
pub fn length_error(s: &Session) -> Result<f64> {
let mut error = 0.0_f64;
for (a, b, length) in [
("crank.start", "crank.end", 10.0),
("coupler.start", "coupler.end", 40.0),
("rocker.start", "rocker.end", 20.0),
("ground.start", "ground.end", 42.0),
] {
error = error.max((point(s, a)?.dist(point(s, b)?) - length).abs());
}
error = error.max(point(s, "crank.start")?.len());
error = error.max(point(s, "ground.end")?.dist(Vec2::new(42.0, 0.0)));
Ok(error)
}
pub fn branch(s: &Session) -> Result<f64> {
let a = point(s, "crank.end")?;
Ok((point(s, "rocker.end")? - a).cross(point(s, "coupler.end")? - a).signum())
}
+4
View File
@@ -532,6 +532,10 @@ impl NurbsSurface {
pub fn homogeneous(&self) -> &[HPoint] {
&self.ctrl
}
/// The control net.
pub fn net(&self) -> crate::Grid<HPoint> {
crate::Grid::checked(self.count, self.ctrl.clone())
}
/// Control point `(i, j)`.
pub fn point(&self, i: usize, j: usize) -> HPoint {
self.ctrl.get(j * self.count[0] + i).copied().unwrap_or(HPoint::ZERO)
+144
View File
@@ -0,0 +1,144 @@
//! A rectangular net of values: a surface's control points, a sample grid. Rectangular by
//! construction, so code that walks it never meets a missing cell: a grid is made from a
//! function of `(i, j)`, from one row, or from rows checked once ([`Grid::from_u_rows`]).
/// `count[0]` values along u by `count[1]` along v; value `(i, j)` (i along u) is stored at
/// `j·count[0] + i`, u fastest.
#[derive(Clone, Debug, PartialEq)]
pub struct Grid<T> {
count: [usize; 2],
cells: Vec<T>,
}
impl<T> Grid<T> {
/// A grid of `f(i, j)` for every cell; `None` when `count[0]·count[1]` overflows.
pub fn from_fn(count: [usize; 2], mut f: impl FnMut(usize, usize) -> T) -> Option<Grid<T>> {
let total = count[0].checked_mul(count[1])?;
let cu = count[0].max(1);
Some(Grid { count, cells: (0..total).map(|k| f(k % cu, k / cu)).collect() })
}
/// One row of values along u: `count` is `[cells.len(), 1]` (a curve's control points seen
/// as a net).
pub fn row(cells: Vec<T>) -> Grid<T> {
Grid { count: [cells.len(), 1], cells }
}
/// A grid whose caller already holds `count[0]·count[1]` cells (a checked surface's net).
pub(crate) fn checked(count: [usize; 2], cells: Vec<T>) -> Grid<T> {
Grid { count, cells }
}
/// Values along u and along v.
pub fn count(&self) -> [usize; 2] {
self.count
}
pub fn is_empty(&self) -> bool {
self.cells.is_empty()
}
/// Value `(i, j)`, i along u.
pub fn get(&self, i: usize, j: usize) -> Option<&T> {
if i < self.count[0] { self.cells.get(j * self.count[0] + i) } else { None }
}
/// The values, u fastest.
pub fn cells(&self) -> &[T] {
&self.cells
}
/// The runs along u, one per v index: run `j` holds values `(0, j)`, `(1, j)`, ….
pub fn lines(&self) -> std::slice::Chunks<'_, T> {
self.cells.chunks(self.count[0].max(1))
}
/// The grid with u and v swapped: value `(i, j)` becomes value `(j, i)`.
pub fn transposed(&self) -> Grid<T>
where
T: Clone,
{
let [cu, cv] = self.count;
let cells = (0..self.cells.len()).filter_map(|k| self.get(k / cv.max(1), k % cv.max(1)).cloned()).collect();
Grid { count: [cv, cu], cells }
}
/// The values to change in place; the grid keeps its shape.
pub fn cells_mut(&mut self) -> &mut [T] {
&mut self.cells
}
pub fn into_cells(self) -> Vec<T> {
self.cells
}
pub fn map<U>(self, f: impl FnMut(T) -> U) -> Grid<U> {
Grid { count: self.count, cells: self.cells.into_iter().map(f).collect() }
}
/// [`Grid::map`] with a step that can fail: the first failure.
pub fn try_map<U, E>(self, f: impl FnMut(T) -> Result<U, E>) -> Result<Grid<U>, E> {
Ok(Grid { count: self.count, cells: self.cells.into_iter().map(f).collect::<Result<_, _>>()? })
}
/// The grid as rows, one per u index: `rows[i][j]` is value `(i, j)` (truck's layout).
pub fn into_u_rows(self) -> Vec<Vec<T>> {
let cu = self.count[0];
let mut rows: Vec<Vec<T>> = (0..cu).map(|_| Vec::with_capacity(self.count[1])).collect();
for (k, v) in self.cells.into_iter().enumerate() {
if let Some(row) = rows.get_mut(k % cu) {
row.push(v);
}
}
rows
}
}
impl<T: Clone> Grid<T> {
/// A grid from rows, one per u index: `rows[i][j]` is value `(i, j)` (truck's layout).
/// `None` when the rows differ in length, the one check rows from elsewhere need.
pub fn from_u_rows<R: AsRef<[T]>>(rows: &[R]) -> Option<Grid<T>> {
let cv = rows.first().map_or(0, |r| r.as_ref().len());
if rows.iter().any(|r| r.as_ref().len() != cv) {
return None;
}
let cells = (0..cv).flat_map(|j| rows.iter().filter_map(move |r| r.as_ref().get(j).cloned())).collect();
Some(Grid { count: [rows.len(), cv], cells })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rows_go_in_and_come_back_out() {
// rows[i][j]: two along u, three along v.
let rows = vec![vec![(0, 0), (0, 1), (0, 2)], vec![(1, 0), (1, 1), (1, 2)]];
let g = Grid::from_u_rows(&rows).unwrap();
assert_eq!(g.count(), [2, 3]);
assert_eq!(g.cells(), &[(0, 0), (1, 0), (0, 1), (1, 1), (0, 2), (1, 2)]);
assert_eq!(g.get(1, 2), Some(&(1, 2)));
assert_eq!(g.get(2, 0), None);
assert_eq!(g.into_u_rows(), rows);
assert_eq!(Grid::row(vec![1, 2, 3]).count(), [3, 1]);
let g = Grid::from_fn([3, 2], |i, j| (i, j)).unwrap();
assert_eq!(g.lines().collect::<Vec<_>>(), vec![&[(0, 0), (1, 0), (2, 0)][..], &[(0, 1), (1, 1), (2, 1)][..]]);
let t = g.transposed();
assert_eq!(t.count(), [2, 3]);
assert!((0..3).all(|i| (0..2).all(|j| t.get(j, i) == g.get(i, j))));
assert!(Grid::from_fn([usize::MAX, 2], |_, _| 0).is_none());
}
#[test]
fn only_rectangles_are_grids() {
assert!(Grid::from_u_rows(&[vec![1, 2], vec![3]]).is_none());
let empty: [Vec<u8>; 0] = [];
assert!(Grid::from_u_rows(&empty).is_some_and(|g| g.is_empty()));
}
#[test]
fn try_map_stops_at_the_first_failure() {
assert_eq!(Grid::row(vec![1, -1, -2]).try_map(|x| if x > 0 { Ok(x) } else { Err(x) }), Err(-1));
}
}
+4
View File
@@ -18,6 +18,7 @@ pub mod compare;
pub mod env;
pub mod fill;
pub mod fill_general;
mod grid;
mod mesh;
pub mod mesh_distance;
pub mod network;
@@ -27,14 +28,17 @@ pub mod section;
pub mod spline_space;
pub mod tess;
pub mod tspline;
mod unit_vec3;
mod vec;
pub use arcfit::MAX_FIT_PIECES;
pub use blend::hermite_bezier;
pub use bvh::{Bvh, MeshIndex};
pub use grid::Grid;
pub use mesh::{Aabb3, BoxedTriangle, InsideIndex, MassProperties, Mesh, MeshMeasure, find_close_pair};
pub use plane::Plane;
pub use profile::{Loop2, MAX_BEZIER_CTRL, MAX_BEZIER_DEGREE, MAX_DERIVATIVE_ORDER, Region2, Seg2, bezier, regions_apart};
pub use unit_vec3::UnitVec3;
pub use vec::{Vec2, Vec3};
/// Geometric tolerance for coincidence tests (mm).
+2 -2
View File
@@ -31,8 +31,8 @@ impl Plane {
}
/// Plane through `origin` with normal `n` (in-plane axes chosen deterministically).
pub fn from_normal(origin: Vec3, n: Vec3) -> Option<Plane> {
let n = n.normalized()?;
pub fn from_normal(origin: Vec3, n: impl Into<Vec3>) -> Option<Plane> {
let n = n.into().normalized()?;
// Horizontal planes keep world X as sketch x; others use the horizontal direction Z × n.
let cand = if n.dot(Vec3::Z).abs() > 0.9 { Vec3::X } else { Vec3::Z.cross(n) };
let x = (cand - n * cand.dot(n)).normalized()?;
+120
View File
@@ -0,0 +1,120 @@
//! A unit vector: a 3D vector of length 1.
use std::ops::{Add, Deref, Mul, Neg, Sub};
use serde::{Deserialize, Serialize};
use crate::Vec3;
/// A unit vector: length 1 up to rounding. Made only from a vector with a length
/// ([`UnitVec3::new`], [`Vec3::unit`]), so a function taking one need not normalise it or
/// handle a zero vector again. It reads as the [`Vec3`] it holds (`d.dot(v)`, `*d`), and
/// [`Vec3::dot`] and [`Vec3::cross`] take one.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(into = "Vec3", try_from = "Vec3")]
pub struct UnitVec3(Vec3);
impl UnitVec3 {
pub const X: UnitVec3 = UnitVec3(Vec3::X);
pub const Y: UnitVec3 = UnitVec3(Vec3::Y);
pub const Z: UnitVec3 = UnitVec3(Vec3::Z);
/// `v` scaled to length 1; `None` when it has no length (or is not finite).
pub fn new(v: Vec3) -> Option<UnitVec3> {
v.normalized().map(UnitVec3)
}
pub fn vec(self) -> Vec3 {
self.0
}
}
impl Vec3 {
/// This vector scaled to length 1 ([`UnitVec3::new`]).
pub fn unit(self) -> Option<UnitVec3> {
UnitVec3::new(self)
}
}
impl Deref for UnitVec3 {
type Target = Vec3;
fn deref(&self) -> &Vec3 {
&self.0
}
}
impl From<UnitVec3> for Vec3 {
fn from(d: UnitVec3) -> Vec3 {
d.0
}
}
impl TryFrom<Vec3> for UnitVec3 {
type Error = &'static str;
fn try_from(v: Vec3) -> Result<UnitVec3, Self::Error> {
UnitVec3::new(v).ok_or("a unit vector needs a finite vector with a length")
}
}
impl From<&UnitVec3> for Vec3 {
fn from(d: &UnitVec3) -> Vec3 {
d.0
}
}
impl Neg for UnitVec3 {
type Output = UnitVec3;
fn neg(self) -> UnitVec3 {
UnitVec3(-self.0)
}
}
/// `+` and `-` with a [`Vec3`] or another unit vector, giving a [`Vec3`].
macro_rules! sum_ops {
($($l:ty, $r:ty);*) => {$(
impl Add<$r> for $l {
type Output = Vec3;
fn add(self, o: $r) -> Vec3 {
Vec3::from(self) + Vec3::from(o)
}
}
impl Sub<$r> for $l {
type Output = Vec3;
fn sub(self, o: $r) -> Vec3 {
Vec3::from(self) - Vec3::from(o)
}
}
)*};
}
sum_ops!(UnitVec3, Vec3; Vec3, UnitVec3; UnitVec3, UnitVec3);
impl Mul<f64> for UnitVec3 {
type Output = Vec3;
fn mul(self, s: f64) -> Vec3 {
self.0 * s
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_unit_vector_has_length_one() {
let d = Vec3::new(0.0, 3.0, 4.0).unit().unwrap();
assert_eq!(*d, Vec3::new(0.0, 0.6, 0.8));
assert_eq!(d * 5.0, Vec3::new(0.0, 3.0, 4.0));
assert_eq!(*-d, Vec3::new(0.0, -0.6, -0.8));
assert_eq!(d.dot(Vec3::Z), 0.8);
assert_eq!(UnitVec3::Z.cross(UnitVec3::X), Vec3::Y);
assert_eq!(Vec3::X.dot(d), 0.0);
assert_eq!(Vec3::X + d, Vec3::new(1.0, 0.6, 0.8));
assert!((d - UnitVec3::Z).dist(Vec3::new(0.0, 0.6, -0.2)) < 1e-15);
}
#[test]
fn no_length_no_unit_vector() {
assert_eq!(Vec3::ZERO.unit(), None);
assert_eq!(Vec3::new(f64::NAN, 0.0, 1.0).unit(), None);
assert_eq!(Vec3::new(f64::INFINITY, 0.0, 0.0).unit(), None);
}
}
+6 -2
View File
@@ -96,10 +96,14 @@ impl Vec3 {
pub const fn new(x: f64, y: f64, z: f64) -> Self {
Vec3 { x, y, z }
}
pub fn dot(self, o: Vec3) -> f64 {
/// Takes a [`Vec3`] or a [`UnitVec3`](crate::UnitVec3).
pub fn dot(self, o: impl Into<Vec3>) -> f64 {
let o = o.into();
self.x * o.x + self.y * o.y + self.z * o.z
}
pub fn cross(self, o: Vec3) -> Vec3 {
/// Takes a [`Vec3`] or a [`UnitVec3`](crate::UnitVec3).
pub fn cross(self, o: impl Into<Vec3>) -> Vec3 {
let o = o.into();
Vec3::new(self.y * o.z - self.z * o.y, self.z * o.x - self.x * o.z, self.x * o.y - self.y * o.x)
}
pub fn len(self) -> f64 {
+1
View File
@@ -325,6 +325,7 @@ fn frame(scene: &Scene, cam: &Camera, [width, height]: [u32; 2], key: u64, acces
access,
outline: true,
eye: [eye[0], eye[1], eye[2], if cam.fov > 0.0 { 1.0 } else { 0.0 }],
ao_radius: 0.0,
ssaa: 1.0,
}
}
+16 -4
View File
@@ -14,14 +14,17 @@ use wgpu::util::DeviceExt;
use crate::pbr;
mod ao;
/// Triangle vertex: position (3 × f32), normal (3 × f32), sRGB colour (4 × u8).
pub const TRI_SIZE: usize = 28;
pub const PBR_SIZE: usize = pbr::VERTEX_SIZE;
/// Line instance: a, b (3 × f32 each), colour (4 × u8), width in pixels (f32).
pub const LINE_SIZE: usize = 32;
/// The shader's `U`: the view-projection matrix, 9 `vec4`s, the 8 analysis colours, their
/// flags and the depth direction (`uniform_bytes`).
const UNIFORM_SIZE: u64 = 368;
/// The shader's `U`: the view-projection matrix, 10 `vec4`s (the tenth carries the ambient
/// occlusion radius), the 8 analysis colours, their flags and the depth direction
/// (`uniform_bytes`).
const UNIFORM_SIZE: u64 = 384;
/// A body drawn from its display plan, evaluated on the GPU: the plan, and a colour per face
/// (the plan's face numbering; faces past the end take the last colour).
@@ -245,6 +248,8 @@ pub struct Frame {
/// Eye position and 1 for a perspective camera (0: orthographic), for the section cap's depth
/// and the depth bias.
pub eye: [f32; 4],
/// World-space occlusion radius; zero skips the depth prepass and all AO sampling.
pub ao_radius: f32,
/// Supersampling (#1288): draw the frame this many times the viewport's pixels in each
/// direction and filter it down (1: off). Under 1 the frame is drawn smaller and filtered up
/// (the product preview past its pixel budget, #1464).
@@ -541,6 +546,8 @@ impl Batches {
/// The viewport's pipelines and the scenes uploaded to one device, for one kind of target.
pub struct Renderer {
/// The AO depth prepass resources (a 1 × 1 stand-in while ambient occlusion is off).
ao: ao::Resources,
tri: wgpu::RenderPipeline,
/// `tri` and `tri_cut` for planned bodies (positions and normals from the GPU evaluation,
/// colours per vertex, indexed).
@@ -959,9 +966,10 @@ impl Renderer {
count: None,
}],
});
let ao = ao::Resources::new(device, &bgl, &module, reversed);
let solid_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("sc_solid"),
bind_group_layouts: &[Some(&bgl), Some(&access_bgl)],
bind_group_layouts: &[Some(&bgl), Some(&access_bgl), Some(&ao.layout)],
immediate_size: 0,
});
let built = [Some(tri_layout())];
@@ -1108,6 +1116,7 @@ impl Renderer {
})
});
Renderer {
ao,
depth_reset,
target: t,
max_texture: device.limits().max_texture_dimension_2d,
@@ -1305,6 +1314,7 @@ impl Renderer {
res.access_bind = access_texture(device, Some(queue), &res.access_bgl, a.size, &a.depth);
res.access_version = a.version;
}
res.ao.prepare(device, encoder, &res.bind, cb.ao_radius, cb.size_px, res.target.reversed_depth(), [&res.model, &res.preview]);
// Images: upload new or changed textures, drop removed ones, rebuild the quads.
res.textures.retain(|id, _| cb.images.iter().any(|i| i.id == *id));
res.quads.clear();
@@ -1414,6 +1424,7 @@ impl Renderer {
lines(pass, &res.overlay, &m.under);
lines(pass, &res.overlay, &sk.under);
pass.set_bind_group(1, &res.access_bind, &[]);
pass.set_bind_group(2, &res.ao.bind, &[]);
let planned = |pass: &mut wgpu::RenderPass<'static>, p: &wgpu::RenderPipeline| {
for d in &m.planned {
pass.set_pipeline(p);
@@ -1570,6 +1581,7 @@ pub fn uniform_bytes(cb: &Frame, w: f32, h: f32, linear: bool, reversed: bool) -
None => v.extend([0.0; 8]),
}
v.extend(cb.eye);
v.extend([cb.ao_radius, 0.0, 0.0, 0.0]);
v.extend(cb.analysis_colors);
v.extend([if reversed { 1.0 } else { 0.0 }, FACE_STEPS as f32, FACE_SLOPE, 0.0]);
v.iter().flat_map(|x| x.to_le_bytes()).collect()
+167
View File
@@ -0,0 +1,167 @@
//! The optional AO depth prepass. Only opaque model and preview triangles participate: their
//! depth is drawn once into a single-sample depth map the lit fragment samples for occlusion.
//! The map is independent of the window's MSAA, and the depth runs the same way as the
//! viewport's own buffer (see [`super::depth_compare`] and [`super::far_depth`]).
use super::{Batches, color_layout, depth_compare, far_depth, planned_layout, tri_layout};
pub(super) struct Resources {
pub layout: wgpu::BindGroupLayout,
pub bind: wgpu::BindGroup,
depth: wgpu::TextureView,
size: [u32; 2],
pipeline: wgpu::RenderPipeline,
planned_pipeline: wgpu::RenderPipeline,
}
fn texture(device: &wgpu::Device, layout: &wgpu::BindGroupLayout, size: [u32; 2]) -> (wgpu::TextureView, wgpu::BindGroup) {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("sc_ao_depth"),
size: wgpu::Extent3d { width: size[0], height: size[1], depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Depth32Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let depth = texture.create_view(&Default::default());
let bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("sc_ao_depth"),
layout,
entries: &[wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&depth) }],
});
(depth, bind)
}
impl Resources {
pub fn new(device: &wgpu::Device, uniform: &wgpu::BindGroupLayout, module: &wgpu::ShaderModule, reversed: bool) -> Self {
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("sc_ao_depth"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Depth,
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
}],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("sc_ao_prepass"),
bind_group_layouts: &[Some(uniform)],
immediate_size: 0,
});
let built = [Some(tri_layout())];
let evaluated = [Some(planned_layout()), Some(color_layout())];
let pipeline = |planned: bool| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("sc_ao_prepass"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module,
entry_point: Some("vs_tri"),
buffers: if planned { &evaluated } else { &built },
compilation_options: Default::default(),
},
primitive: wgpu::PrimitiveState::default(),
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(true),
depth_compare: Some(depth_compare(reversed, wgpu::CompareFunction::Less)),
stencil: Default::default(),
bias: Default::default(),
}),
multisample: wgpu::MultisampleState::default(),
fragment: Some(wgpu::FragmentState {
module,
entry_point: Some("fs_ao_depth"),
targets: &[],
compilation_options: Default::default(),
}),
multiview_mask: None,
cache: None,
})
};
let planned_pipeline = pipeline(true);
let pipeline = pipeline(false);
let (depth, bind) = texture(device, &layout, [1, 1]);
Self { layout, bind, depth, size: [1, 1], pipeline, planned_pipeline }
}
pub fn prepare(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
uniform: &wgpu::BindGroup,
radius: f32,
size_px: [f32; 2],
reversed: bool,
batches: [&Batches; 2],
) {
let enabled = radius.is_finite() && radius > 0.0;
// Bound memory at large window sizes; normalized coordinates also handle downsampling.
let size = target_size(enabled, size_px, device.limits().max_texture_dimension_2d);
if size != self.size {
(self.depth, self.bind) = texture(device, &self.layout, size);
self.size = size;
}
if !enabled {
return;
}
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("sc_ao_prepass"),
color_attachments: &[],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.depth,
depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(far_depth(reversed)), store: wgpu::StoreOp::Store }),
stencil_ops: None,
}),
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
pass.set_bind_group(0, uniform, &[]);
for batch in batches {
pass.set_pipeline(&self.pipeline);
if let Some(tris) = &batch.tris {
for (buffer, count) in &tris.parts {
pass.set_vertex_buffer(0, buffer.slice(..));
pass.draw(0..*count, 0..1);
}
}
pass.set_pipeline(&self.planned_pipeline);
for draw in &batch.planned {
pass.set_vertex_buffer(0, draw.mesh.vertices.slice(..));
pass.set_vertex_buffer(1, draw.colors.slice(..));
pass.set_index_buffer(draw.mesh.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.draw_indexed(0..draw.mesh.index_count, 0, 0..1);
}
}
}
}
fn target_size(enabled: bool, pixels: [f32; 2], device_limit: u32) -> [u32; 2] {
if !enabled {
return [1, 1];
}
let limit = device_limit.clamp(1, 2048);
let pixels = pixels.map(|s| if s.is_finite() { s.max(1.0) } else { 1.0 });
let scale = (limit as f32 / pixels[0].max(pixels[1])).min(1.0);
pixels.map(|s| (s * scale).ceil().clamp(1.0, limit as f32) as u32)
}
#[cfg(test)]
mod tests {
use super::target_size;
#[test]
fn depth_map_is_bounded_and_released_when_disabled() {
assert_eq!(target_size(true, [6000.0, 3000.0], 8192), [2048, 1024]);
assert_eq!(target_size(true, [6000.0, 3000.0], 1024), [1024, 512]);
assert_eq!(target_size(false, [6000.0, 3000.0], 8192), [1, 1]);
assert_eq!(target_size(true, [f32::NAN, f32::INFINITY], 8192), [1, 1]);
assert_eq!(target_size(true, [0.0, -1.0], 8192), [1, 1]);
}
}
+63 -5
View File
@@ -492,6 +492,12 @@ fn polyline_mid(pts: &[Vec3]) -> (Vec3, f64) {
(pts.first().copied().unwrap_or_default(), len)
}
#[cfg(test)]
thread_local! {
/// Solids [`Body::new`] meshed on this thread to orient them (tests count the work an import does).
pub(crate) static ORIENTED: std::cell::Cell<usize> = const { std::cell::Cell::new(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.
@@ -559,13 +565,17 @@ impl Body {
Ok((volume, missing))
})
};
let (mut v, mut missing) = orient(tol)?;
#[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, missing) = orient(fine)?;
(v, _) = orient(fine)?;
}
if v < 0.0 {
solid.not();
@@ -574,7 +584,7 @@ impl Body {
solid: std::sync::Arc::new(solid),
size: if span.is_finite() && span > 0.0 { span } else { 0.0 },
volume: Some(std::sync::Arc::new(std::sync::Mutex::new(None))),
coarse_missing: (v >= 0.0).then_some((tol, missing)),
coarse_missing: Some((tol, missing)),
mesh: None,
color: None,
paint: None,
@@ -595,7 +605,15 @@ impl Body {
if solid.boundaries().is_empty() {
return Err(KernelError::Failed("empty result".into()));
}
let mut solid = rephase_revolved(solid);
Body::placed_as_is(rephase_revolved(solid), flip, coarse)
}
/// [`Body::new_placed`] for a solid whose revolved faces were already turned away from
/// their seam (a placement of an oriented body keeps them where they were).
fn placed_as_is(mut solid: Solid, flip: bool, coarse: Option<(f64, usize)>) -> Result<Body> {
if solid.boundaries().is_empty() {
return Err(KernelError::Failed("empty result".into()));
}
let span = span_of(&solid);
if flip {
solid.not();
@@ -616,6 +634,23 @@ impl Body {
})
}
/// This oriented body's solid moved by `m` (an affine placement), as [`Body::new_placed`]
/// makes it: the orientation comes from the matrix, so a part placed many times (assembly
/// instances) is meshed to orient it only once. Topology only; appearance is the caller's.
pub(crate) fn placed(&self, m: &mt::Matrix4) -> Result<Body> {
use mt::SquareMatrix as _;
if *m == mt::Matrix4::from_scale(1.0) {
return Ok(self.clone());
}
let a = [[m.x.x, m.x.y, m.x.z, m.x.w], [m.y.x, m.y.y, m.y.z, m.y.w], [m.z.x, m.z.y, m.z.z, m.z.w], [m.w.x, m.w.y, m.w.z, m.w.w]];
let det = m.determinant();
if !det.is_finite() || det.abs() < 1e-12 {
return Err(KernelError::Invalid("degenerate placement".into()));
}
let rigid = crate::ops::rigid_placement(&a);
Body::placed_as_is(mt::builder::transformed(&*self.solid, *m), det < 0.0, rigid.then_some(self.coarse_missing).flatten())
}
pub(crate) fn inherit_regularization_protection(mut self, input: &Body) -> Self {
self.regularization_protected |= input.regularization_protected;
self
@@ -1254,7 +1289,7 @@ impl Body {
if self.is_mesh() {
return 0;
}
let tol = (self.size() * 1e-2).max(1e-3);
let tol = orientation_tolerance(self.size());
if let Some((cached_tol, missing)) = self.coarse_missing
&& cached_tol == tol
{
@@ -1365,6 +1400,29 @@ impl Body {
.collect())
}
/// Edges `indices` (as in [`Body::tessellate`]'s `edges`) in `plane`'s coordinates,
/// exactly: lines, circular arcs, and free-form curves as the Bézier spans they are, for a
/// sketch to project them. `None` for an edge that doesn't lie in a plane parallel to
/// `plane`, isn't read exactly or isn't there, and for every edge of a mesh body.
pub fn planar_edges(&self, plane: &solidcraft_geom::Plane, indices: &[usize]) -> Vec<Option<Vec<solidcraft_geom::Seg2>>> {
let none = || vec![None; indices.len()];
if self.is_mesh() {
return none();
}
let tol = (self.size() * 1e-7).max(1e-9) * 10.0;
guard("planar edges", || {
let edges = Self::unique_edges(&self.solid);
Ok(indices
.iter()
.map(|i| {
let mut work = 262_144usize;
edges.get(*i).and_then(|e| crate::prism_curve::projected(e, plane, tol, &mut work))
})
.collect())
})
.unwrap_or_else(|_| none())
}
/// Faces with area and centroid.
pub fn faces(&self, tol: f64) -> Result<Vec<FaceInfo>> {
let m = self.tessellate(tol)?;
+3
View File
@@ -18,6 +18,8 @@ pub enum BooleanStrategy {
GrownBoss,
PlanarFallback,
ToolNudge,
/// A rotationally symmetric tool turned about its own axis; its volume is unchanged.
ToolReseam,
Piecewise,
/// No retry gave a result, and the answer from sample points was kept although the bodies'
/// facetings disagree with it.
@@ -61,6 +63,7 @@ impl BooleanFallback {
BooleanStrategy::GrownBoss => format!("boss grown {:.6e} mm into the material around the bore it fills", self.face_shift_mm),
BooleanStrategy::PlanarFallback => "exact planar fallback (no displacement)".into(),
BooleanStrategy::ToolNudge => format!("tool displacement {:.6e} mm, rotation {:.6e} rad", self.translation_mm, self.rotation_rad),
BooleanStrategy::ToolReseam => format!("axisymmetric tool reseamed by {:.6e} rad (no displacement)", self.rotation_rad),
BooleanStrategy::Piecewise => "piecewise retry (no displacement)".into(),
BooleanStrategy::UnverifiedShortcut => format!(
"not verified: the bodies' facets pass through each other by more than {:.6e} mm, but they could not be intersected there; that part is ignored",
+11 -3
View File
@@ -3,14 +3,22 @@
//! cylinder recut from silently removing an additional material boundary that
//! the fallback's coarse material samples do not see.
use crate::body::{Body, from_p3};
use solidcraft_geom::{Plane, Seg2, Vec3};
use solidcraft_geom::{Plane, Seg2, UnitVec3, Vec3};
use std::collections::HashSet;
use truck_modeling as mt;
pub(super) fn boundaries_clear(body: &Body, bore_faces: &[usize], origin: Vec3, axis: Vec3, radius: f64, lo: f64, hi: f64, tol: f64) -> Option<()> {
pub(super) fn boundaries_clear(
body: &Body,
bore_faces: &[usize],
origin: Vec3,
axis: UnitVec3,
radius: f64,
lo: f64,
hi: f64,
tol: f64,
) -> Option<()> {
let plane = Plane::from_normal(origin, axis)?;
if !origin.is_finite()
|| !axis.is_finite()
|| ![radius, lo, hi, tol].iter().all(|v| v.is_finite())
|| radius <= tol
|| hi <= lo
+2 -2
View File
@@ -1,6 +1,6 @@
#![allow(clippy::unwrap_used, clippy::panic)]
use crate::*;
use solidcraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3};
use solidcraft_geom::{Loop2, Plane, Region2, Seg2, UnitVec3, Vec2, Vec3};
fn fixture() -> (Body, Body, Vec3, f64) {
let slope = 1f64.to_radians().tan();
@@ -113,7 +113,7 @@ fn unproved_curved_boundary_between_clear_endpoints_refuses_recut() {
assert!(a.len() > 5. && b.len() > 5., "endpoint samples miss the interior excursion");
assert!(body.tessellate_with(1e-4, true).unwrap().contains(Vec3::new(4.5, 0., 16.06)));
let before = step_export(&[&body], "curved source").unwrap();
assert!(crate::bore_guard::boundaries_clear(&body, &[], Vec3::ZERO, Vec3::Z, 5., 14., 24., 1e-6).is_none());
assert!(crate::bore_guard::boundaries_clear(&body, &[], Vec3::ZERO, UnitVec3::Z, 5., 14., 24., 1e-6).is_none());
assert_eq!(step_export(&[&body], "curved source").unwrap(), before);
}

Some files were not shown because too many files have changed in this diff Show More